mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-25 06:11:36 +00:00
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:
parent
b29bab1884
commit
6a71577e05
@ -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).
|
||||
|
||||
51
server.py
51
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,
|
||||
|
||||
@ -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
|
||||
? '<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 });
|
||||
}
|
||||
|
||||
// ── 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 '<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 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 @@
|
||||
'<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>' +
|
||||
// A–Z 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>' +
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
85
tests/js/v3_az_rail.test.js
Normal file
85
tests/js/v3_az_rail.test.js
Normal file
@ -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');
|
||||
});
|
||||
@ -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):
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user