From 6a71577e058a8993d376c77d4f8d201f812dcdc8 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Mon, 29 Jun 2026 01:49:01 -0500 Subject: [PATCH] =?UTF-8?q?feat(v3=20library):=20A=E2=80=93Z=20fast-scroll?= =?UTF-8?q?=20jump=20rail=20on=20the=20Songs=20grid=20(#634)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + server.py | 51 +++++++- static/v3/songs.js | 202 +++++++++++++++++++++++++++++++- static/v3/v3.css | 72 ++++++++++++ tests/js/v3_az_rail.test.js | 85 ++++++++++++++ tests/test_library_filters.py | 41 ++++++- tests/test_library_providers.py | 4 +- 7 files changed, 448 insertions(+), 8 deletions(-) create mode 100644 tests/js/v3_az_rail.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 22fdcb5..15e2e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **A–Z 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). diff --git a/server.py b/server.py index 0dbf258..0b25d2f 100644 --- a/server.py +++ b/server.py @@ -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-A–Z 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, diff --git a/static/v3/songs.js b/static/v3/songs.js index 732d113..aa0d730 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -51,8 +51,34 @@ artistCatalog: [], renderedHash: '', scrollBound: false, songsById: {}, selectMode: false, selected: new Set(), + railLetters: null, railJumping: false, }; + // ── A–Z jump rail ─────────────────────────────────────────────────────── + // Ordered buckets shown on the rail: '#' (non-alphabetic) first, then A–Z. + 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-A–Z (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 ? '
' + overlayActs.map(actBtn).join('') + '
' : ''; - return '
' + + return '
' + '
' + '' + tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + overlay + @@ -724,6 +750,169 @@ }, { passive: true }); } + // ── A–Z 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 ''; + }).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 A–Z 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 @@ '' + '' + '
' + + // A–Z jump rail (grid + alphabetical sorts only; populated by + // refreshRail). The bubble shows the current letter while dragging. + '' + + '' + // Filter drawer + overlay '' + '' + diff --git a/static/v3/v3.css b/static/v3/v3.css index 996c072..7c1a70a 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -1117,3 +1117,75 @@ html.fb-immersive #v3-main > .screen.active { inset: 0; overflow: hidden; } + +/* — A–Z 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; } +} diff --git a/tests/js/v3_az_rail.test.js b/tests/js/v3_az_rail.test.js new file mode 100644 index 0000000..047e725 --- /dev/null +++ b/tests/js/v3_az_rail.test.js @@ -0,0 +1,85 @@ +// Pins the v3 Songs A–Z 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-A–Z 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-A–Z 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'); +}); diff --git a/tests/test_library_filters.py b/tests/test_library_filters.py index 1f09795..3205243 100644 --- a/tests/test_library_filters.py +++ b/tests/test_library_filters.py @@ -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): diff --git a/tests/test_library_providers.py b/tests/test_library_providers.py index 8a06874..b6e7b6f 100644 --- a/tests/test_library_providers.py +++ b/tests/test_library_providers.py @@ -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"