The v3 Songs grid appended every scrolled page and never released nodes,
so card-node count grew unbounded with scroll depth (24 → 624 → 2001 for a
2000-song library). Replace it with a windowed/recycled render: only the
visible window (± overscan) is in the DOM while a #v3-songs-gridsizer
element sized to ceil(total/cols)*rowH gives the scrollbar full-library
geometry; #v3-songs-grid is absolutely positioned to the first visible row.
- state.songs is a sparse, absolutely-indexed store filled a page at a time
by ensureWindow(): the stage-1 keyset cursor for contiguous forward scroll
(O(page)), OFFSET page= for jumps/restore/non-keyset providers. _loadPage
shares an in-flight promise per page and an epoch guard discards a stale
fetch that lands after a reset.
- A–Z rail seeks directly via sort_letters cumulative counts (O(1), no
page-through); bounded scan fallback for legacy providers without it.
- Snapshot/restore is now scrollTop-based (geometry is stable). Select mode,
accuracy badges, ⋮ menu, plugin card actions, and tree/folder coexistence
survive cards recycling; renderWindow re-renders when select mode toggles.
- Plugins get window.v3Songs.visibleCards() + a v3:library-window-rendered
event instead of assuming all cards are present (highway-stutter lesson).
Verified in a browser against a seeded 2001-song library: DOM bounded to
~60 nodes while the count reads "2001 songs", rail jump lands on the target
row, selection survives recycling, scroll-restore exact. Codex-reviewed
(3 findings fixed: stale-fetch epoch guard, await-in-flight page promise,
select-mode resync on cached re-entry).
Frontend-only. Tests: tests/browser/v3-grid-virtualization.spec.ts pins the
bounded-DOM invariant + direct rail jump; tests/js/v3_az_rail.test.js and
v3_songs_scroll.test.js updated to the new wiring.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>