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

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

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

* fix(v3 library): harden A–Z jump rail (review P2/P3)

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>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-06-29 08:49:01 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent b29bab1884
commit 6a71577e05
7 changed files with 448 additions and 8 deletions
+85
View File
@@ -0,0 +1,85 @@
// Pins the v3 Songs AZ jump rail wiring in static/v3/songs.js.
//
// The rail lets a user jump the library grid to artists/titles starting with a
// letter (Plex/Radarr/iOS-contacts pattern). Because the grid is forward-only,
// server-paged infinite scroll, the jump pages through to the target card then
// scrolls — and the rail only offers letters the server reports present for the
// active sort+filter (so a tap always terminates at a real card). It is shown
// only for the grid view + alphabetical (artist/title) sorts.
//
// Source-level only — same strategy as tests/js/highway_3d_camera_framing.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const src = fs.readFileSync(SONGS_JS, 'utf8');
test('the rail is context-gated to grid view + alphabetical sorts', () => {
// railSortColumn returns the active alpha column or null (recent/year/tuning).
assert.match(src, /function\s+railSortColumn\s*\(\)/);
assert.match(src, /state\.sort === 'artist'[\s\S]*?return 'artist'/);
assert.match(src, /state\.sort === 'title'[\s\S]*?return 'title'/);
assert.match(
src,
/function\s+railVisible\s*\(\)\s*\{\s*return\s+state\.view === 'grid'\s*&&\s*!!railSortColumn\(\)/,
'the rail must be visible only for the grid view + an alphabetical sort',
);
});
test('cards carry a data-letter bucket and non-AZ buckets under #', () => {
assert.match(src, /data-letter="'\s*\+\s*esc\(songBucket\(song\)\)/,
'each card must tag its sort-letter bucket via songBucket(song)');
assert.match(
src,
/function\s+songBucket[\s\S]*?\(ch >= 'A' && ch <= 'Z'\)\s*\?\s*ch\s*:\s*'#'/,
'songBucket must bucket non-AZ first chars under "#"',
);
});
test('refreshRail reads present letters from the stats endpoint (sort-aware)', () => {
assert.match(src, /\/api\/library\/stats\?'\s*\+\s*queryParams/,
'refreshRail must query /api/library/stats with the active filter params');
// Opts into the active-sort breakdown so non-rail callers skip the scan.
assert.match(src, /queryParams\(\{\s*sort_letters:\s*1\s*\}\)/,
'refreshRail must request the sort_letters breakdown');
assert.match(src, /letters\s*=\s*stats\s*&&\s*stats\.sort_letters/,
'refreshRail must prefer the active-sort breakdown (sort_letters)');
// The legacy artist `letters` is only a valid fallback for an artist sort;
// a title sort with no sort_letters hides the rail rather than mislabel it.
assert.match(src, /col === 'artist'[\s\S]*?stats\.letters/,
'refreshRail must only fall back to letters for an artist sort');
// Absent letters are disabled (non-interactive), not just dimmed.
assert.match(src, /present\s*\?\s*''\s*:\s*' disabled'/);
});
test('reload() refreshes the rail', () => {
assert.match(src, /function reload\s*\([\s\S]*?refreshRail\(\)/,
'reload() must call refreshRail() so the rail tracks filter/sort/view changes');
});
test('the rail + drag bubble are rendered in the Songs markup', () => {
assert.match(src, /id="v3-songs-azrail"[\s\S]*?aria-label="Jump to letter"/);
assert.match(src, /id="v3-songs-azbubble"/);
});
test('jumpToLetter pages through to the target then scrolls (load-through)', () => {
// Forward-paging helper used to load rows up to the target letter.
assert.match(src, /async function\s+_loadNextAwait\s*\(\)/);
assert.match(
src,
/async function\s+jumpToLetter[\s\S]*?_loadNextAwait\(\)[\s\S]*?(scrollTo|scrollIntoView)/,
'jumpToLetter must page forward (_loadNextAwait) then scroll to the target card',
);
// A token guards against overlapping jumps (drag scrubbing) — newest wins.
assert.match(src, /_jumpToken\s*===\s*myToken/);
});
test('the rail supports pointer drag-scrub + keyboard arrows', () => {
assert.match(src, /addEventListener\('pointerdown'/);
assert.match(src, /addEventListener\('pointermove'/);
assert.match(src, /ArrowUp'[\s\S]*?ArrowDown'|ArrowDown'[\s\S]*?ArrowUp'/,
'arrow keys must move between present letters');
});
+39 -2
View File
@@ -398,6 +398,39 @@ def test_query_stats_groups_non_ascii_artist_letters_under_hash(client, server_m
assert stats["letters"] == {"#": 1}
def test_query_stats_sort_letters_artist_counts_songs(client, server_mod):
"""The v3 jump rail's `sort_letters` counts SONGS per first-letter bucket
of the active sort column (vs `letters`, which counts distinct artists).
Two songs by the same A-artist → letters {A:1}, sort_letters {A:2}."""
_put(server_mod, filename="a1.archive", title="Song One", artist="Abba")
_put(server_mod, filename="a2.archive", title="Song Two", artist="Abba")
_put(server_mod, filename="b1.archive", title="Another", artist="Beck")
_put(server_mod, filename="num.archive", title="Track", artist="2Pac")
# sort_letters=1 opts into the active-sort breakdown (the jump rail path).
stats = client.get("/api/library/stats", params={"sort": "artist", "sort_letters": 1}).json()
assert stats["letters"] == {"A": 1, "B": 1, "#": 1} # distinct artists
assert stats["sort_letters"] == {"A": 2, "B": 1, "#": 1} # songs
# Without the opt-in, the extra breakdown is not computed or returned.
plain = client.get("/api/library/stats", params={"sort": "artist"}).json()
assert "sort_letters" not in plain
assert plain["letters"] == {"A": 1, "B": 1, "#": 1}
def test_query_stats_sort_letters_follow_title_sort(client, server_mod):
"""With a title sort, the rail buckets key on the TITLE's first letter,
not the artist's, so a tap lands on a real card in the grid's order."""
_put(server_mod, filename="z1.archive", title="Apple", artist="Zztop")
_put(server_mod, filename="z2.archive", title="Banana", artist="Zztop")
stats = client.get("/api/library/stats", params={"sort": "title", "sort_letters": 1}).json()
assert stats["sort_letters"] == {"A": 1, "B": 1}
# The legacy artist breakdown is unchanged regardless of sort — both songs
# share one artist, so it stays a single distinct-artist Z bucket.
assert stats["letters"] == {"Z": 1}
def test_query_stats_ignores_null_letter_counts(server_mod):
"""Legacy/corrupt rows can surface as NULL-ish letter aggregate
rows on some SQLite builds. The stats endpoint should ignore those
@@ -429,9 +462,13 @@ def test_query_stats_ignores_null_letter_counts(server_mod):
server_mod.meta_db.conn.close()
server_mod.meta_db.conn = FakeConn()
stats = server_mod.meta_db.query_stats()
stats = server_mod.meta_db.query_stats(want_sort_letters=True)
assert stats == {"total_songs": 1, "total_artists": 1, "letters": {"T": 1}}
# `sort_letters` (the v3 jump-rail breakdown) shares the GROUP BY letter
# path in this fake, so it surfaces the same single live bucket when the
# caller opts in.
assert stats == {"total_songs": 1, "total_artists": 1,
"letters": {"T": 1}, "sort_letters": {"T": 1}}
def test_compound_sort_with_legacy_dir_desc_doesnt_error(client, seeded):
+3 -1
View File
@@ -213,7 +213,9 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
assert stats["letters"] == {"R": 1}
assert "page" not in provider.stats_kwargs
assert "size" not in provider.stats_kwargs
assert "sort" not in provider.stats_kwargs
# `sort` is forwarded to query_stats now (the v3 jump rail keys its
# present-letter breakdown on the active sort column); defaults to "artist".
assert provider.stats_kwargs.get("sort") == "artist"
tunings = client.get("/api/library/tuning-names", params={"provider": "remote:frodo"}).json()
assert tunings["tunings"][0]["name"] == "E Standard"