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
+201 -1
View File
@@ -51,8 +51,34 @@
artistCatalog: [], renderedHash: '',
scrollBound: false,
songsById: {}, selectMode: false, selected: new Set(),
railLetters: null, railJumping: false,
};
// ── AZ jump rail ───────────────────────────────────────────────────────
// Ordered buckets shown on the rail: '#' (non-alphabetic) first, then AZ.
const RAIL_BUCKETS = ['#'].concat('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
// The rail only makes sense for the alphabetical sorts; for recent/year/
// tuning a letter jump is meaningless, so it's hidden. Returns the column
// the active sort keys on ('artist' | 'title') or null when not alphabetical.
function railSortColumn() {
if (state.sort === 'artist' || state.sort === 'artist-desc') return 'artist';
if (state.sort === 'title' || state.sort === 'title-desc') return 'title';
return null;
}
// The bucket a song falls in for the active sort: first char of the sort
// column, uppercased; anything non-AZ (digits, symbols, accents, blank)
// buckets under '#'. Mirrors the server's letter grouping in query_stats —
// which keys on raw SUBSTR(col, 1, 1) with no trim, and the grid ORDER BY
// is likewise raw, so we must NOT trim here either: a leading-space title
// sorts (and buckets) under '#' on both sides, keeping the rail consistent.
function songBucket(song) {
const col = railSortColumn();
if (!col) return '';
const raw = String((col === 'title' ? song.title : song.artist) || '');
const ch = raw.charAt(0).toUpperCase();
return (ch >= 'A' && ch <= 'Z') ? ch : '#';
}
function activeFilterCount() {
const f = state.filters;
return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length +
@@ -463,7 +489,7 @@
const overlay = overlayActs.length
? '<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition pointer-events-none"><div class="flex flex-wrap gap-1 justify-center max-w-[90%] pointer-events-auto">' + overlayActs.map(actBtn).join('') + '</div></div>'
: '';
return '<div class="group relative" data-fn="' + esc(key) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer" data-v3-play>' +
'<img src="' + esc(artUrl(song)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + overlay +
@@ -724,6 +750,169 @@
}, { passive: true });
}
// ── AZ jump rail interaction ─────────────────────────────────────────────
// The rail jumps within the contiguous, server-paged grid. Because the grid
// is forward-only infinite scroll (no virtualization), reaching a letter that
// isn't loaded yet means paging forward until its first card exists, then
// scrolling to it — the same rows the user would have scrolled past. The rail
// only offers letters the server reports as present for the active sort+filter
// (so a tap always terminates at a real card). A keyset-seek + virtualized
// window is the scaling follow-up for very large libraries.
function railEl() { return document.getElementById('v3-songs-azrail'); }
function railBubbleEl() { return document.getElementById('v3-songs-azbubble'); }
function railVisible() { return state.view === 'grid' && !!railSortColumn(); }
let _railToken = 0;
async function refreshRail() {
const rail = railEl();
if (!rail) return;
if (!railVisible()) { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; }
const col = railSortColumn();
// A newer refresh (sort/filter/search/provider change) supersedes this
// one — a slow stats response must not repaint a rail the grid moved on.
const myToken = ++_railToken;
// Present letters for the active sort+filter (filter-synced; counts
// songs). `sort_letters=1` opts into the active-sort breakdown so the
// dashboard / v2 tree (which read only `letters`) skip the extra query.
const stats = await jget('/api/library/stats?' + queryParams({ sort_letters: 1 }).toString());
if (_railToken !== myToken || !railVisible()) { // changed mid-fetch
if (_railToken === myToken) rail.classList.add('hidden');
return;
}
// Prefer the active-sort breakdown. `letters` is the artist distinct-
// count, so it only matches the cards on an artist sort; a legacy/third-
// party provider that predates `sort_letters` returns none, in which
// case a title sort would advertise wrong letters — hide the rail then.
let letters = stats && stats.sort_letters;
if (!letters) {
if (col === 'artist') letters = (stats && stats.letters) || {};
else { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; }
}
state.railLetters = letters;
// No present letters (empty or fully-filtered grid) → nothing to jump
// to; hide the rail instead of rendering a column of disabled buttons.
if (!Object.keys(letters).length) { rail.classList.add('hidden'); railBubbleEl()?.classList.add('hidden'); return; }
const desc = state.sort.endsWith('-desc');
const order = desc ? RAIL_BUCKETS.slice().reverse() : RAIL_BUCKETS;
// Roving tabindex: only the first present letter is in the tab order;
// the rest are reached with the arrow keys (see bindRailOnce). Avoids
// dumping up to 27 tab stops into the page.
let firstPresent = true;
rail.innerHTML = order.map((L) => {
const n = letters[L] || 0;
const present = n > 0;
const tabbable = present && firstPresent;
if (tabbable) firstPresent = false;
const name = L === '#' ? 'non-alphabetical' : L;
return '<button type="button" class="v3-azrail-letter" data-letter="' + esc(L) + '"'
+ (present ? '' : ' disabled') + ' tabindex="' + (tabbable ? '0' : '-1') + '"'
+ ' aria-label="Jump to ' + esc(name) + (present ? ', ' + n + ' song' + (n === 1 ? '' : 's') : ' (none)') + '">'
+ esc(L) + '</button>';
}).join('');
rail.classList.remove('hidden');
bindRailOnce();
}
function _setRailActive(letter) {
railEl()?.querySelectorAll('.v3-azrail-letter').forEach((b) => {
b.classList.toggle('is-active', b.getAttribute('data-letter') === letter);
});
}
function _showBubble(letter) { const b = railBubbleEl(); if (b) { b.textContent = letter; b.classList.remove('hidden'); } }
function _hideBubble() { railBubbleEl()?.classList.add('hidden'); }
async function _loadNextAwait() {
if (state.loading) { await _waitForGridIdle(); return loadedCount() < state.total; }
if (loadedCount() >= state.total) return false;
state.page++;
await loadGrid(false);
return loadedCount() < state.total;
}
let _jumpToken = 0;
async function jumpToLetter(letter) {
const grid = document.getElementById('v3-songs-grid');
if (!grid || state.view !== 'grid' || !letter) return;
_setRailActive(letter);
const sel = '[data-letter="' + ((window.CSS && CSS.escape) ? CSS.escape(letter) : letter) + '"]';
const myToken = ++_jumpToken; // a newer jump supersedes this one
// Page forward until the bucket's first card is loaded (or list
// exhausted). The guard is the page count the current total implies
// (+2 slack) rather than a fixed cap, so even a very large library
// stays reachable while a runaway loop is still bounded.
let guard = 0;
const maxPages = Math.ceil((state.total || 0) / PAGE_SIZE) + 2;
while (!grid.querySelector(sel) && loadedCount() < state.total
&& _jumpToken === myToken && guard++ < maxPages) {
const more = await _loadNextAwait();
if (!more) break;
}
if (_jumpToken !== myToken) return;
const target = grid.querySelector(sel);
if (!target) return;
const main = document.getElementById('v3-main');
const toolbar = document.getElementById('v3-songs-toolbar');
const pad = (toolbar ? toolbar.offsetHeight : 0) + 12; // clear the sticky toolbar
if (main) {
const top = target.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop - pad;
main.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
} else {
target.scrollIntoView({ block: 'start', behavior: 'smooth' });
}
}
function bindRailOnce() {
const rail = railEl();
if (!rail || rail._bound) return;
rail._bound = true;
let dragging = false, moved = false, lastDrag = null;
const letterAtY = (y) => {
const els = rail.querySelectorAll('.v3-azrail-letter');
if (!els.length) return null;
for (const el of els) { const r = el.getBoundingClientRect(); if (y >= r.top && y <= r.bottom) return el; }
return y < els[0].getBoundingClientRect().top ? els[0] : els[els.length - 1]; // clamp past ends
};
rail.addEventListener('click', (e) => {
const btn = e.target.closest('.v3-azrail-letter');
if (!btn || btn.disabled) return;
if (moved) { moved = false; return; } // a drag already handled it
jumpToLetter(btn.getAttribute('data-letter'));
});
rail.addEventListener('pointerdown', (e) => {
const btn = e.target.closest('.v3-azrail-letter');
if (!btn) return;
dragging = true; moved = false; lastDrag = null;
try { rail.setPointerCapture(e.pointerId); } catch (_) { /* */ }
_showBubble(btn.getAttribute('data-letter'));
});
rail.addEventListener('pointermove', (e) => {
if (!dragging) return;
const el = letterAtY(e.clientY);
if (!el || el.disabled) return;
moved = true;
const L = el.getAttribute('data-letter');
_showBubble(L);
if (L !== lastDrag) { lastDrag = L; jumpToLetter(L); } // only on change
});
const end = () => { dragging = false; _hideBubble(); };
rail.addEventListener('pointerup', end);
rail.addEventListener('pointercancel', end);
rail.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const btns = [...rail.querySelectorAll('.v3-azrail-letter:not([disabled])')];
const i = btns.indexOf(document.activeElement);
if (i < 0) return;
e.preventDefault();
const next = btns[i + (e.key === 'ArrowDown' ? 1 : -1)];
if (next) {
btns[i].setAttribute('tabindex', '-1'); // roving tabindex follows focus
next.setAttribute('tabindex', '0');
next.focus();
jumpToLetter(next.getAttribute('data-letter'));
}
});
}
// Pin the sticky toolbar directly beneath the sticky topbar. Both live in
// the #v3-main scroller, so without an explicit offset they share top:0 and
// the toolbar covers the topbar's song search. The topbar has two responsive
@@ -913,12 +1102,19 @@
// state.q) and needs a refresh rather than a scroll-preserving no-op.
state.renderedHash = _libraryStateHash();
updateFilterBadge();
// A sort/filter/search/view change rebuilds the grid from page 0, so any
// in-flight letter jump is now paging through a dataset that's about to
// be discarded — supersede it so it can't scroll the rebuilt grid.
_jumpToken++;
// Keep a handle on the load so callers (notably the scroll restore on
// screen re-entry) can await page-0 actually landing before paging
// deeper. The visibility/scroll resets below stay synchronous.
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid');
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
// Refresh the AZ jump rail (shows only for the grid + alphabetical
// sorts; hides itself otherwise). Independent of the grid load.
refreshRail();
{ const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
if (state.view === 'folder') {
_applyMainScrollTop(0);
@@ -985,6 +1181,10 @@
'<div id="lib-folder-controls" style="display:none"></div>' +
'<div id="lib-folder-tree" class="space-y-1 hidden"></div>' +
'<div id="v3-songs-sentinel" class="h-8"></div>' +
// AZ jump rail (grid + alphabetical sorts only; populated by
// refreshRail). The bubble shows the current letter while dragging.
'<nav id="v3-songs-azrail" class="v3-azrail hidden" aria-label="Jump to letter"></nav>' +
'<div id="v3-songs-azbubble" class="v3-azbubble hidden" aria-hidden="true"></div>' +
// Filter drawer + overlay
'<div id="v3-songs-overlay" class="fixed inset-0 bg-black/50 z-40 hidden"></div>' +
'<aside id="v3-songs-drawer" class="fixed top-0 right-0 h-full w-full sm:w-96 bg-fb-sidebar border-l border-fb-border/50 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll"></aside>' +