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 01:49:01 -05:00 committed by GitHub
parent b29bab1884
commit 6a71577e05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 448 additions and 8 deletions

View File

@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **AZ fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
- **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work returning to a song after wandering into Settings Tone Builder is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption).

View File

@ -1995,10 +1995,24 @@ class MetadataDB:
stems_lacks: list[str] | None = None,
has_lyrics: int | None = None,
tunings: list[str] | None = None,
sort: str = "artist",
want_sort_letters: bool = False,
naming_mode: str = "legacy") -> dict:
"""Aggregate stats for the letter bar. Accepts the same filter
params as query_page so the letter counts stay synchronized
with the grid when filters are active."""
with the grid when filters are active.
`sort` selects the column the v3 jump rail's `sort_letters`
breakdown keys on (artist for artist sorts, title for title
sorts) so the rail's present-letters match the grid's actual
order; other sorts fall back to artist (the rail is hidden for
them client-side anyway). The legacy `letters` field is always
the artist breakdown, unchanged, for the dashboard + classic tree.
`sort_letters` is computed (and the key included) ONLY when
`want_sort_letters` is set the jump rail opts in, while the
dashboard / v2 tree read only `letters` and skip the extra
per-letter aggregate scan."""
where, params = self._build_where(
q=q, favorites_only=favorites_only, format_filter=format_filter,
artist_filter=artist_filter, album_filter=album_filter,
@ -2029,7 +2043,30 @@ class MetadataDB:
letters[key] = letters.get(key, 0) + count
else:
letters["#"] = letters.get("#", 0) + count
return {"total_songs": total, "total_artists": artist_count, "letters": letters}
result = {"total_songs": total, "total_artists": artist_count, "letters": letters}
# Active-sort letter buckets for the v3 jump rail. Counts SONGS (the
# grid's unit, unlike `letters` which counts distinct artists) per
# first-letter bucket of the column the active sort keys on, so a tap
# on a present letter always finds a card. Non-AZ first chars bucket
# under '#'. Only artist/title sorts are alphabetical; anything else
# keys on artist here but the client hides the rail for it. Computed
# only when the caller opts in, so non-rail callers skip the scan.
if want_sort_letters:
sort_col = "title" if sort in ("title", "title-desc") else "artist"
sort_rows = self.conn.execute(
f"SELECT UPPER(SUBSTR(COALESCE({sort_col}, ''), 1, 1)) AS letter, COUNT(*) "
f"FROM songs {where} GROUP BY letter", params
).fetchall()
sort_letters: dict[str, int] = {}
for letter, count in sort_rows:
count = int(count or 0)
if count <= 0:
continue
key = str(letter or "")
bucket = key if (key.isascii() and key.isalpha()) else "#"
sort_letters[bucket] = sort_letters.get(bucket, 0) + count
result["sort_letters"] = sort_letters
return result
class AudioEffectsMappingDB:
@ -2602,7 +2639,7 @@ def _require_library_provider_capability(provider: object, capability: str) -> N
)
_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode",)
_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters")
def _filter_provider_kwargs(method: object, kwargs: dict) -> dict:
@ -4539,15 +4576,21 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", provider: str = "local",
sort: str = "artist", sort_letters: int = 0,
naming_mode: str = "legacy"):
"""Aggregate stats for the UI. Accepts the same filter params as
/api/library so the letter bar mirrors the active grid filter set."""
/api/library so the letter bar mirrors the active grid filter set.
`sort` selects the column the jump rail's `sort_letters` keys on;
`sort_letters=1` opts into that breakdown (the rail), so non-rail
callers skip the extra per-letter aggregate."""
library_provider = _get_library_provider(provider)
_require_library_provider_capability(library_provider, "library.read")
return await _call_library_provider_async(
library_provider,
"query_stats",
naming_mode=naming_mode,
sort=sort,
want_sort_letters=bool(sort_letters),
**_library_filter_args(
q=q, favorites=favorites, format=format,
artist=artist, album=album,

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>' +

View File

@ -1117,3 +1117,75 @@ html.fb-immersive #v3-main > .screen.active {
inset: 0;
overflow: hidden;
}
/* — AZ jump rail (v3 Songs grid; static/v3/songs.js) — */
/* Fixed to the right edge next to the scroller's scrollbar; vertically
centered. Shown only for the grid view + alphabetical (artist/title) sorts. */
.v3-azrail {
position: fixed;
right: 2px;
top: 50%;
transform: translateY(-50%);
z-index: 25;
display: flex;
flex-direction: column;
align-items: center;
max-height: 84vh;
padding: 4px 1px;
user-select: none;
-webkit-user-select: none;
touch-action: none; /* let a drag scrub the rail without scrolling the page */
}
.v3-azrail-letter {
appearance: none;
-webkit-appearance: none;
background: none;
border: 0;
color: #94a3b8; /* fb-textDim */
font-size: .62rem;
font-weight: 700;
line-height: 1.05;
padding: 1px 4px;
margin: 0;
cursor: pointer;
border-radius: 4px;
}
.v3-azrail-letter:hover:not([disabled]),
.v3-azrail-letter.is-active {
color: #0ea5e9; /* fb-primary */
}
.v3-azrail-letter:focus-visible {
outline: 2px solid #38bdf8; /* fb-primaryHi */
outline-offset: 1px;
}
.v3-azrail-letter[disabled] {
color: rgba(148, 163, 184, .28);
cursor: default;
}
/* Drag indicator bubble (Android fast-scroll pattern). */
.v3-azbubble {
position: fixed;
right: 2.6rem;
top: 50%;
transform: translateY(-50%);
z-index: 26;
width: 2.6rem;
height: 2.6rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: .7rem;
background: #0ea5e9; /* fb-primary */
color: #f8fafc; /* fb-text */
font-size: 1.15rem;
font-weight: 800;
box-shadow: 0 6px 22px rgba(0, 0, 0, .45);
pointer-events: none;
}
.v3-azrail.hidden,
.v3-azbubble.hidden { display: none; }
/* Coarse-pointer / short viewports: the 27-letter rail can crowd a phone edge.
Tighten it; a collapse-to-anchors pass is a follow-up. */
@media (max-height: 640px) {
.v3-azrail-letter { font-size: .55rem; padding: 0 4px; }
}

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');
});

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):

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"