Skip to content

feat(v3 library): A–Z fast-scroll jump rail on the Songs grid - #634

Merged
byrongamatos merged 2 commits into
mainfrom
feat/v3-library-az-rail
Jun 29, 2026
Merged

feat(v3 library): A–Z fast-scroll jump rail on the Songs grid#634
byrongamatos merged 2 commits into
mainfrom
feat/v3-library-az-rail

Conversation

@ChrisBeWithYou

Copy link
Copy Markdown
Contributor

What

A vertical A–Z jump rail on the v3 Songs grid — the Plex / Radarr / iOS-contacts "fast scroll" pattern, pinned to the right edge next to the scrollbar. Tap a letter, drag to scrub (with a live letter bubble), or arrow-key between letters to jump the library to that starting letter.

Closes the "Alphabetical scroll selection next to the scrollbar in library view" idea. The classic (v2) tree already had letter selection; this brings the new v3 grid to parity.

![pattern: Plex/Radarr-style A–Z rail next to the scrollbar]

Behavior

  • Context-gated: shows only for the grid view + alphabetical sorts (artist/title). Hidden for recent/year/tuning (a letter jump is meaningless there — matches Plex/Radarr).
  • Present-letters only: the rail offers just the letters actually in the current sort + filter set, so a tap always lands on a real card. Absent letters are dimmed and non-interactive (disabled, tabindex=-1).
  • Accessible: <nav aria-label="Jump to letter">, per-letter aria-label ("Jump to S, 12 songs"), focus ring, arrow-key navigation; touch-action:none so a drag scrubs instead of scrolling the page.

How it jumps (and an honest limitation)

The grid is forward-only, server-paged infinite scroll with no virtualization, so jumping to a letter that isn't loaded yet means paging through to its first card, then scrolling to it (the same rows you'd otherwise scroll past). A token supersedes an in-flight jump so fast drag-scrubbing doesn't pile up. For very large libraries this loads more pages than ideal — a keyset-seek + virtualized window is the documented scaling follow-up (the devops lens in the library charrette flagged the same).

Backend

/api/library/stats gains an optional sort param and an additive sort_letters map: songs-per-first-letter of the active sort column (artist or title), filter-synced, so the rail's present-letters match the grid's real order. The legacy letters (distinct-artist) field is unchanged — dashboard + classic tree unaffected. sort is dropped by the existing kwarg-filter for third-party providers whose query_stats predates it, so they keep working (the rail just falls back / hides).

Tests

  • tests/test_library_filters.pysort_letters for artist + title sorts; songs-vs-distinct-artist counting; the FakeConn return-shape.
  • tests/test_library_providers.pysort is forwarded to provider query_stats.
  • tests/js/v3_az_rail.test.js — context-gating, data-letter bucketing, stats sort_letters usage, reload() refresh, load-through + scroll, pointer-drag + keyboard.

All green (46 Python in the two library suites + 7 JS).

Notes

Part of a broader library charrette (Plex/Jellyfin/*arr/Navidrome/Audiobookshelf/Komga lessons); a follow-up PR for the highest-leverage finding is in progress. This PR is self-contained.

🤖 Generated with Claude Code

Adds a vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the
right edge next to the scrollbar so you can jump the library to a starting
letter — tap, drag-to-scrub with a live letter bubble, or arrow-key between
letters. The classic (v2) tree already had letter selection; this brings the
new v3 grid to parity (it was the gap behind the "alphabetical scroll
selection next to the scrollbar" idea).

It shows ONLY for the grid view + alphabetical (artist/title) sorts, and only
offers letters present in the current sort AND filter set, so a tap always
lands on a real card (absent letters are dimmed + non-interactive). The grid
is forward-only, server-paged infinite scroll with no virtualization, so a
jump pages through to the target card then scrolls to it; a token guards
overlapping jumps (drag) so the newest wins. A keyset-seek + virtualized
window is the scaling follow-up for very large libraries.

Backend: /api/library/stats gains an optional `sort` param and an additive
`sort_letters` map — songs-per-first-letter of the ACTIVE sort column (artist
or title), filter-synced — so the rail's present-letters match the grid's real
order. The legacy `letters` (distinct-artist) field is unchanged, so the
dashboard + classic tree are unaffected. `sort` is dropped for providers whose
query_stats predates it (existing kwarg-filter), so third-party library
providers keep working (rail simply falls back / hides).

Frontend: static/v3/songs.js (refreshRail / jumpToLetter / pointer-drag +
keyboard, cards tagged data-letter), static/v3/v3.css (.v3-azrail + bubble).

Tests: tests/test_library_filters.py (sort_letters artist/title, song-vs-
distinct-artist counting), tests/test_library_providers.py (sort forwarded),
tests/js/v3_az_rail.test.js (gating, data-letter, load-through, drag/keys).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
Addresses the PR #634 review findings (manual + Codex):

P2 correctness
- refreshRail prefers the active-sort `sort_letters`; falls back to the
  artist-based `letters` only on an artist sort, and hides the rail on a
  title sort when a legacy provider returns none (was mislabeling letters).
- reload() bumps `_jumpToken` so an in-flight letter jump can't scroll a
  grid that's being rebuilt from page 0.
- songBucket no longer trims, matching the server SQL + grid ORDER BY raw
  first-char bucketing (a leading-space title now buckets under '#' on both
  sides).

P3 polish
- Paging guard is total-derived (ceil(total/PAGE_SIZE)+2) instead of a
  magic 4000, keeping large libraries reachable while still bounded.
- Roving tabindex: only the first present letter is tabbable; arrow keys
  move it. Removes up to 27 page tab stops.
- `sort_letters` is computed only when the caller opts in
  (want_sort_letters / route `sort_letters=1`); the dashboard + v2 tree
  skip the extra GROUP BY. Added sort + want_sort_letters to the optional
  provider-kwargs so non-introspectable legacy providers drop them.
- _railToken supersedes stale refreshRail responses; hide the rail when no
  letters are present instead of rendering disabled buttons.

Tests updated accordingly (v3_az_rail.test.js, test_library_filters.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@byrongamatos
byrongamatos merged commit 6a71577 into main Jun 29, 2026
0 of 3 checks passed
@byrongamatos
byrongamatos deleted the feat/v3-library-az-rail branch June 29, 2026 06:57
byrongamatos pushed a commit that referenced this pull request Jul 2, 2026
Follow-up to #634. Three rail bugs reported on macOS + Windows (0.3.0,
2026-06-29 — =Scr4tch=, MajorMokoto):

- Taps often did nothing ("clicked O, nothing happened"). pointerdown
  calls setPointerCapture, after which the browser retargets the
  follow-up click to the rail container, so the click handler's
  closest('.v3-azrail-letter') resolved null and a plain tap (no
  pointermove) had no other path. Drive the jump from pointerdown
  itself; reduce the click handler to keyboard activation only
  (e.detail === 0, Enter/Space).

- A drag landed short of the release ("where you release isn't where
  you get sent"). Every letter crossed fired jumpToLetter with
  behavior:'smooth'; stacked smooth-scroll animations over the
  virtualized grid lagged and settled imprecisely. jumpToLetter now
  takes a smooth flag and scrolls instantly ('auto') while scrubbing,
  animating only discrete taps/keyboard jumps, so the grid tracks the
  finger and the release lands on the let-go letter.

- The rail was too small at 1440p and didn't scale. Letters were a
  fixed .62rem glued at right:2px (~13px-tall target). They now scale
  with the viewport (clamp(.72rem, 1.4vh, 1.05rem)), sit off the edge
  with taller/wider equal-width hit targets and a hover/active
  highlight so the scrub target is visible.

Keyboard arrow-nav and present-letter gating are unchanged. Tests:
tests/js/v3_az_rail.test.js gains pointerdown-seek, keyboard-only click
guard, and instant-vs-smooth assertions (809 JS tests; the 13
pre-existing unrelated failures are unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
byrongamatos pushed a commit that referenced this pull request Jul 2, 2026
Follow-up to #634. Three rail bugs reported on macOS + Windows (0.3.0,
2026-06-29 — =Scr4tch=, MajorMokoto):

- Taps often did nothing ("clicked O, nothing happened"). pointerdown
  calls setPointerCapture, after which the browser retargets the
  follow-up click to the rail container, so the click handler's
  closest('.v3-azrail-letter') resolved null and a plain tap (no
  pointermove) had no other path. Drive the jump from pointerdown
  itself; reduce the click handler to keyboard activation only
  (e.detail === 0, Enter/Space).

- A drag landed short of the release ("where you release isn't where
  you get sent"). Every letter crossed fired jumpToLetter with
  behavior:'smooth'; stacked smooth-scroll animations over the
  virtualized grid lagged and settled imprecisely. jumpToLetter now
  takes a smooth flag and scrolls instantly ('auto') while scrubbing,
  animating only discrete taps/keyboard jumps, so the grid tracks the
  finger and the release lands on the let-go letter.

- The rail was too small at 1440p and didn't scale. Letters were a
  fixed .62rem glued at right:2px (~13px-tall target). They now scale
  with the viewport (clamp(.72rem, 1.4vh, 1.05rem)), sit off the edge
  with taller/wider equal-width hit targets and a hover/active
  highlight so the scrub target is visible.

Keyboard arrow-nav and present-letter gating are unchanged. Tests:
tests/js/v3_az_rail.test.js gains pointerdown-seek, keyboard-only click
guard, and instant-vs-smooth assertions (809 JS tests; the 13
pre-existing unrelated failures are unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
byrongamatos added a commit that referenced this pull request Jul 2, 2026
* Fix v3 Songs A–Z rail: reliable taps, precise drag, hittable size

Follow-up to #634. Three rail bugs reported on macOS + Windows (0.3.0,
2026-06-29 — =Scr4tch=, MajorMokoto):

- Taps often did nothing ("clicked O, nothing happened"). pointerdown
  calls setPointerCapture, after which the browser retargets the
  follow-up click to the rail container, so the click handler's
  closest('.v3-azrail-letter') resolved null and a plain tap (no
  pointermove) had no other path. Drive the jump from pointerdown
  itself; reduce the click handler to keyboard activation only
  (e.detail === 0, Enter/Space).

- A drag landed short of the release ("where you release isn't where
  you get sent"). Every letter crossed fired jumpToLetter with
  behavior:'smooth'; stacked smooth-scroll animations over the
  virtualized grid lagged and settled imprecisely. jumpToLetter now
  takes a smooth flag and scrolls instantly ('auto') while scrubbing,
  animating only discrete taps/keyboard jumps, so the grid tracks the
  finger and the release lands on the let-go letter.

- The rail was too small at 1440p and didn't scale. Letters were a
  fixed .62rem glued at right:2px (~13px-tall target). They now scale
  with the viewport (clamp(.72rem, 1.4vh, 1.05rem)), sit off the edge
  with taller/wider equal-width hit targets and a hover/active
  highlight so the scrub target is visible.

Keyboard arrow-nav and present-letter gating are unchanged. Tests:
tests/js/v3_az_rail.test.js gains pointerdown-seek, keyboard-only click
guard, and instant-vs-smooth assertions (809 JS tests; the 13
pre-existing unrelated failures are unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): ignore non-primary buttons on A–Z rail pointerdown (PR #653 review)

Right- or middle-clicking the A–Z rail (or a secondary multi-touch
pointer) no longer triggers a seek; only the primary tap/drag scrubs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants