diff --git a/CHANGELOG.md b/CHANGELOG.md index 53d8dcd..e31c455 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 +- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **A–Z rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`. - **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback). - **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the A–Z rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a "+ Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`. - **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip). diff --git a/static/v3/songs.js b/static/v3/songs.js index c46b3d9..5b64135 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -40,6 +40,9 @@ const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals']; const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'other']; const PAGE_SIZE = 24; + // Extra rows rendered above/below the viewport so a fast scroll doesn't flash + // blank before the next window render lands. + const OVERSCAN_ROWS = 2; const SCROLL_STATE_KEY = 'v3:songs-scroll-state'; const btnCtrl = 'bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary'; @@ -51,7 +54,21 @@ artistCatalog: [], renderedHash: '', scrollBound: false, songsById: {}, selectMode: false, selected: new Set(), - railLetters: null, railJumping: false, + railLetters: null, railLettersAreSongCounts: false, railJumping: false, + // ── Windowed (virtualized) grid, stage 2 of #636 item 3 ── + // state.songs is a SPARSE array indexed by absolute library position + // (0..total-1); only the fetched pages are populated and only the visible + // window ± overscan is ever in the DOM. The sizer element gives the + // scrollbar the full-library geometry. See renderWindow / ensureWindow. + songs: [], // sparse: absoluteIndex → song row + pageCursors: {}, // pageIndex → next_cursor (keyset forward fast-path) + keysetOk: false, // did page 0 return a non-null cursor (local + keyset sort)? + pageProms: {}, // pageIndex → in-flight fetch promise (de-dupe + await) + epoch: 0, // bumped on every reset; a stale in-flight fetch checks it + geom: null, // { cols, rowH, gap } measured from the live grid + winRange: null, // { start, end } last rendered, to skip redundant renders + renderedSelectMode: null, // the selectMode the current window was rendered under + gridResizeBound: false, }; // ── A–Z jump rail ─────────────────────────────────────────────────────── @@ -112,12 +129,13 @@ function _saveLibraryScrollSnapshot() { const main = _getV3MainScroller(); + // Geometry is now stable (the sizer reserves the full scroll height + // regardless of how many cards are actually in the DOM), so the scroll + // position alone is enough to restore — no page-depth bookkeeping. const snap = { hash: _libraryStateHash(), scrollTop: main ? main.scrollTop : 0, view: state.view, - page: state.page, - loadedCount: loadedCount(), }; try { sessionStorage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } catch (e) { /* quota / private mode */ } } @@ -145,9 +163,14 @@ setTimeout(apply, 0); } + // The windowed grid keeps only a slice of cards in the DOM, so "intact" can no + // longer mean "has cards" — it means the grid + sizer chrome exist and page 0 + // is loaded (state.total known, first rows present), so renderWindow() can + // repaint the right slice at any scroll position. function _gridDomIntact() { const grid = document.getElementById('v3-songs-grid'); - return !!grid && loadedCount() > 0; + const sizer = document.getElementById('v3-songs-gridsizer'); + return !!grid && !!sizer && state.total > 0 && state.songs[0] !== undefined; } function _treeDomIntact() { @@ -156,32 +179,6 @@ return !!(tree.querySelector('[data-fn]') || tree.querySelector('details')); } - // Resolve once no grid fetch is in flight. loadGrid early-returns while - // state.loading is set, so paging without waiting would silently skip a - // page (it bumps state.page but the fetch no-ops). Bounded so a wedged - // load can't hang the restore forever. - async function _waitForGridIdle(maxMs) { - const cap = (maxMs == null ? 8000 : maxMs); - let waited = 0; - while (state.loading && waited < cap) { - await new Promise((r) => setTimeout(r, 16)); - waited += 16; - } - } - - async function _ensureGridPagesThrough(targetPage) { - const goal = Math.max(0, Number(targetPage) || 0); - // The initial page-0 load (or an auto-fill) may still be settling; wait - // for the real state.total before deciding how far to page, otherwise a - // total of 0 exits the loop immediately and the depth never restores. - await _waitForGridIdle(); - while (state.page < goal && loadedCount() < state.total) { - if (state.loading) { await _waitForGridIdle(); continue; } - state.page++; - await loadGrid(false); - } - } - function queryParams(extra, opts) { const f = state.filters; const skipArtistAlbum = opts && opts.catalog; @@ -302,6 +299,11 @@ if (treeBtn) treeBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); const folderBtn = document.getElementById('v3-songs-folder-btn'); if (folderBtn) folderBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); + // Select button tracks state.selectMode — the screen-leave teardown clears + // select mode, so a cached-DOM re-entry must re-style the button (and the + // window re-renders without checkboxes via renderWindow's selectMode check). + const selBtn = document.getElementById('v3-songs-select'); + if (selBtn) selBtn.className = btnCtrl + (state.selectMode ? ' bg-fb-primary text-white' : ''); updateFilterBadge(); } @@ -551,6 +553,9 @@ host.innerHTML = meter + shelfHtml; host.classList.remove('hidden'); + // The home block sits above the grid sizer, so its height shifts where the + // window maps in scroll space — repaint the window once it's laid out. + if (state.view === 'grid') requestWindowRender(); // Wire shelf cards → play (mirrors playCard's local path; recents are // always local-library rows, so no provider sync is needed). host.querySelectorAll('.v3-kp-card').forEach((btn) => btn.addEventListener('click', () => { @@ -653,8 +658,11 @@ const overlay = overlayActs.length ? '
' + overlayActs.map(actBtn).join('') + '
' : ''; + // Recycled cards re-render from state, so a selected card must paint its + // ring on initial markup (toggleSelect only adds it to a live node). + const selRing = state.selected.has(key) ? ' ring-2 ring-fb-primary' : ''; return '
' + - '
' + + '
' + '' + tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + overlay + '
' + @@ -665,7 +673,10 @@ '
' + '
' + esc(song.title) + '
' + '
' + esc(song.artist) + '
' + - (arrChips ? '
' + arrChips + '
' : '') + + // Always emit the chip row (even when empty) at a FIXED single-line + // height — uniform card height is what makes the windowed grid's + // absolute-position math exact (.v3-card-chips in v3.css). + '
' + arrChips + '
' + '
'; } @@ -852,76 +863,259 @@ try { const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return r.ok ? r.json() : null; } catch (e) { return null; } } - // ── Grid (paged + infinite scroll) ─────────────────────────────────────-- - async function loadGrid(reset) { - // A reset requested mid-fetch (provider/sort/filter/search change) must - // not be dropped — remember it and re-run once the in-flight load - // returns, otherwise the stale response repopulates the grid. - if (state.loading) { if (reset) state.pendingReset = true; return; } - const grid = document.getElementById('v3-songs-grid'); - if (!grid) return; - // A reset wipes the grid (and any open card menu's DOM); close the menu - // first so its document-level click closer doesn't leak. - if (reset) { if (_closeCardMenu) _closeCardMenu(); state.page = 0; state.total = 0; grid.innerHTML = ''; } - state.loading = true; - const data = await jget('/api/library?' + queryParams({ page: state.page, size: PAGE_SIZE }).toString()); - state.loading = false; - if (state.pendingReset) { state.pendingReset = false; return loadGrid(true); } - if (!data) return; - state.total = data.total || 0; - (data.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; grid.insertAdjacentHTML('beforeend', songCard(s)); }); - wireCards(grid); - const countEl = document.getElementById('v3-songs-count'); - if (countEl) countEl.textContent = state.total + ' song' + (state.total === 1 ? '' : 's'); - const loaded = grid.querySelectorAll('[data-fn]').length; - const sentinel = document.getElementById('v3-songs-sentinel'); - if (sentinel) sentinel.style.display = loaded < state.total ? 'block' : 'none'; - // Auto-fill: if the grid doesn't yet overflow the scroller, keep loading - // (so a short first page still becomes scrollable without user action). - maybeFill(); - } + // ── Grid (windowed / recycled — #636 item 3 stage 2) ───────────────────-- + // Only the visible cards (± OVERSCAN_ROWS) live in the DOM; a sizer element + // sized to the FULL library gives the scrollbar its geometry. state.songs is + // a sparse array indexed by absolute position; ensureWindow() fetches the + // pages a window needs (keyset forward fast-path, else OFFSET random-access), + // and renderWindow() paints the slice the current scrollTop maps to. + // Live count of cards actually in the DOM — bounded under windowing, so it's + // the bounded-DOM invariant the tests assert (NOT a "loaded so far" signal). function loadedCount() { return document.querySelectorAll('#v3-songs-grid [data-fn]').length; } // The scroll listener lives on the SHARED #v3-main container, so guard every - // paging entry point on the Songs screen actually being active — otherwise - // scrolling another screen would keep fetching /api/library into the hidden - // grid after Songs has been visited once. + // render entry point on the Songs screen actually being active — otherwise + // scrolling another screen would keep rendering into the hidden grid after + // Songs has been visited once. function songsActive() { const el = document.getElementById('v3-songs'); return !!el && el.classList.contains('active'); } - function loadNext() { - if (state.loading || state.view !== 'grid' || !songsActive()) return; - if (loadedCount() < state.total) { state.page++; loadGrid(false); } + function _gridEl() { return document.getElementById('v3-songs-grid'); } + function _sizerEl() { return document.getElementById('v3-songs-gridsizer'); } + + // Measure columns + row pitch from the LIVE grid: cols from the computed + // grid-template-columns (tracks resolve to explicit pixel sizes), rowH from a + // rendered card's box + the grid row-gap. Cards are uniform height (aspect- + // square art + truncated text + the fixed-height .v3-card-chips row), so one + // measured card sizes every row. Falls back to a coarse estimate until the + // first card exists, then re-measures. + function measureGeom() { + const grid = _gridEl(); + if (!grid) return state.geom || { cols: 2, rowH: 240, gap: 16 }; + const cs = getComputedStyle(grid); + const tracks = (cs.gridTemplateColumns || '').trim(); + const cols = (tracks && tracks !== 'none') + ? Math.max(1, tracks.split(/\s+/).length) + : (state.geom ? state.geom.cols : 2); + const gap = parseFloat(cs.rowGap) || 0; + let rowH = state.geom && state.geom.rowH; + const card = grid.querySelector('[data-fn]') || grid.querySelector('.v3-card-skel'); + if (card) { const h = card.getBoundingClientRect().height; if (h > 0) rowH = h + gap; } + if (!rowH || rowH <= 0) rowH = 240 + gap; // estimate until a card is measured + state.geom = { cols, rowH, gap }; + return state.geom; } - function maybeFill() { - const main = document.getElementById('v3-main'); - if (!main || state.view !== 'grid' || state.loading || !songsActive()) return; - // Not tall enough to scroll yet, and more remain → pull the next page. - if (main.scrollHeight <= main.clientHeight + 80 && loadedCount() < state.total) loadNext(); + // The sizer's top edge measured in the scroller's content coordinate space + // (accounts for the practice-home block above it, sticky toolbar, etc.). + function _sizerTopInScroller(main, sizer) { + return sizer.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop; } - // Robust infinite scroll: a scroll listener on the real scroll container - // (#v3-main), bound once. Avoids the IntersectionObserver "already in view - // at observe-time" race that stuck the grid on page 0. + function _windowHasHoles(start, end) { + for (let i = start; i < end; i++) if (state.songs[i] === undefined) return true; + return false; + } + + // A placeholder card with the SAME vertical structure (and therefore height) + // as a real card, shown only if a window's fetch hasn't landed yet. No + // [data-fn] → wireCards / repaintAccuracy skip it. + function _skeletonCard() { + return ''; + } + + function _renderCardsRange(start, end) { + let html = ''; + for (let i = start; i < end; i++) { + const s = state.songs[i]; + html += s ? songCard(s) : _skeletonCard(); + } + return html; + } + + // Fetch a single OFFSET page into the sparse store. Uses the stage-1 keyset + // cursor when the previous page is already loaded (cheap forward scroll); + // otherwise OFFSET page= for random access (jumps, restore, non-keyset + // providers). Records the returned next_cursor so a later contiguous page can + // chain off it. Returns a promise that callers AWAIT (so ensureWindow never + // returns with a hole still in flight); concurrent requests for the same page + // share the one promise. An `epoch` captured at launch guards against a reset + // (provider/sort/filter change) landing mid-fetch and writing stale rows into + // the new dataset. + function _loadPage(p) { + if (p < 0 || state.songs[p * PAGE_SIZE] !== undefined) return Promise.resolve(); + if (state.pageProms[p]) return state.pageProms[p]; + const epoch = state.epoch; + const prom = (async () => { + const extra = { size: PAGE_SIZE }; + const prevCursor = state.keysetOk ? state.pageCursors[p - 1] : null; + if (prevCursor) extra.after = prevCursor; else extra.page = p; + const data = await jget('/api/library?' + queryParams(extra).toString()); + if (state.epoch !== epoch || !data) return; // reset mid-fetch → discard stale + state.total = data.total || 0; + if (typeof data.next_cursor !== 'undefined') { + state.pageCursors[p] = data.next_cursor; + if (p === 0) state.keysetOk = !!data.next_cursor; + } + const base = p * PAGE_SIZE; + (data.songs || []).forEach((s, i) => { + state.songs[base + i] = s; + state.songsById[cardKey(s)] = s; + }); + })(); + state.pageProms[p] = prom; + prom.finally(() => { if (state.pageProms[p] === prom) delete state.pageProms[p]; }); + return prom; + } + + // Ensure every absolute index in [start, end) is loaded (fetch — or await an + // in-flight fetch of — the covering pages). Pages resolve in order so the + // keyset fast-path can chain off the previous page's cursor. + async function ensureWindow(start, end) { + if (end <= start) return; + const p0 = Math.floor(start / PAGE_SIZE); + const p1 = Math.floor((end - 1) / PAGE_SIZE); + for (let p = p0; p <= p1; p++) { + if (state.songs[p * PAGE_SIZE] === undefined) await _loadPage(p); + } + } + + let _winRAF = 0; + function requestWindowRender() { + if (_winRAF) return; + _winRAF = requestAnimationFrame(() => { _winRAF = 0; renderWindow(); }); + } + + // Paint the slice of cards the current scrollTop maps to. Sizes the sizer to + // the full library, computes the visible row range (± overscan), fetches any + // missing pages, then swaps the grid's innerHTML to just that slice. A token + // guards against an out-of-order fetch repainting a window the user scrolled + // past. + let _winToken = 0; + async function renderWindow() { + if (state.view !== 'grid' || !songsActive()) return; + const grid = _gridEl(), sizer = _sizerEl(), main = document.getElementById('v3-main'); + if (!grid || !sizer || !main) return; + const { cols, rowH } = measureGeom(); + const total = state.total || 0; + const rows = Math.ceil(total / Math.max(1, cols)); + sizer.style.height = (rows * rowH) + 'px'; + if (total === 0) { + grid.innerHTML = ''; grid.style.top = '0px'; + state.winRange = { start: 0, end: 0 }; + return; + } + const sizerTop = _sizerTopInScroller(main, sizer); + const viewTop = Math.max(0, main.scrollTop - sizerTop); + const viewBottom = viewTop + main.clientHeight; + const firstRow = Math.max(0, Math.floor(viewTop / rowH) - OVERSCAN_ROWS); + const lastRow = Math.min(rows - 1, Math.ceil(viewBottom / rowH) + OVERSCAN_ROWS); + const start = firstRow * cols; + const end = Math.min(total, (lastRow + 1) * cols); + // Re-render when the range changed, a card is missing, OR select mode + // toggled since the window was last painted (so checkboxes/rings on cached + // cards track state — e.g. after leaving Songs in select mode and back). + const same = state.winRange && state.winRange.start === start && state.winRange.end === end + && state.renderedSelectMode === state.selectMode; + if (same && !_windowHasHoles(start, end)) return; + const myToken = ++_winToken; + if (_windowHasHoles(start, end)) { + await ensureWindow(start, end); + if (_winToken !== myToken || state.view !== 'grid') return; // superseded + } + if (_closeCardMenu) _closeCardMenu(); // its DOM is about to be replaced + grid.style.top = (firstRow * rowH) + 'px'; + grid.innerHTML = _renderCardsRange(start, end); + wireCards(grid); + state.winRange = { start, end }; + state.renderedSelectMode = state.selectMode; + if (sm && typeof sm.emit === 'function') { + try { sm.emit('v3:library-window-rendered', { start, end, total }); } catch (e) { /* */ } + } + } + + // Reset/initial load of the grid. Clears the sparse store, fetches page 0 + // (which establishes state.total + whether the keyset fast-path is available), + // then renders the window twice — the first render lays a real card so the + // second can measure the true row height and settle the window size. + async function loadGrid(reset) { + // A reset requested mid-fetch (provider/sort/filter/search change) must + // not be dropped — remember it and re-run once the in-flight load returns. + if (state.loading) { if (reset) state.pendingReset = true; return; } + const grid = _gridEl(); + if (!grid) return; + if (reset) { + if (_closeCardMenu) _closeCardMenu(); + state.epoch++; // invalidate any in-flight page fetch from the old query + state.songs = []; + state.pageCursors = {}; + state.pageProms = {}; + state.keysetOk = false; + state.winRange = null; + state.renderedSelectMode = null; + state.geom = null; + state.total = 0; + grid.innerHTML = ''; + grid.style.top = '0px'; + const sizer = _sizerEl(); + if (sizer) sizer.style.height = '0px'; + } + state.loading = true; + await _loadPage(0); + state.loading = false; + if (state.pendingReset) { state.pendingReset = false; return loadGrid(true); } + const countEl = document.getElementById('v3-songs-count'); + if (countEl) countEl.textContent = state.total + ' song' + (state.total === 1 ? '' : 's'); + // The sentinel no longer drives loading (the sizer reserves full height); + // keep the node for coexistence but it has no visible role. + const sentinel = document.getElementById('v3-songs-sentinel'); + if (sentinel) sentinel.style.display = 'none'; + await renderWindow(); // first paint (rowH from estimate) + await renderWindow(); // re-measure rowH from a real card, settle the window + } + + // A scroll on #v3-main re-renders the window (rAF-coalesced). No more + // near-bottom paging trigger — the visible range alone decides what's shown. function bindScroll() { const main = document.getElementById('v3-main'); if (!main || state.scrollBound) return; state.scrollBound = true; main.addEventListener('scroll', () => { - if (state.view !== 'grid' || state.loading) return; - if (main.scrollTop + main.clientHeight >= main.scrollHeight - 600) loadNext(); + if (state.view !== 'grid') return; + requestWindowRender(); }, { passive: true }); } + // Re-measure + re-render when the scroller's WIDTH changes (column count and + // the aspect-square art height both track width). Height-only changes just + // need a re-render to widen/narrow the visible window. + function bindGridResize() { + if (state.gridResizeBound) return; + const main = document.getElementById('v3-main'); + if (!main || typeof ResizeObserver !== 'function') return; + state.gridResizeBound = true; + let lastW = main.clientWidth; + new ResizeObserver(() => { + if (state.view !== 'grid') return; + const w = main.clientWidth; + if (w !== lastW) { lastW = w; state.geom = null; } // force re-measure + requestWindowRender(); + }).observe(main); + } + // ── 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. + // With the windowed grid the rail seeks DIRECTLY: sort_letters gives the + // per-bucket song counts, so the first card of a letter is at the cumulative + // count of the buckets before it — convert that index to a scrollTop and let + // the scroll handler render+fetch the destination window (O(1), no page- + // through). The rail only offers letters the server reports present for the + // active sort+filter, so a tap always lands on a real card. (A legacy provider + // lacking sort_letters falls back to a bounded forward scan.) function railEl() { return document.getElementById('v3-songs-azrail'); } function railBubbleEl() { return document.getElementById('v3-songs-azbubble'); } function railVisible() { return state.view === 'grid' && !!railSortColumn(); } @@ -948,11 +1142,18 @@ // 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; + // sort_letters counts SONGS per bucket of the active sort column — exactly + // the cumulative the windowed jump needs to seek to a row index. The + // `letters` fallback is a distinct-ARTIST count (legacy provider without + // sort_letters, artist sort only), which can't drive a precise seek — flag + // it so jumpToLetter does a bounded scan instead of trusting the math. + const songCounts = !!(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; + state.railLettersAreSongCounts = songCounts; // 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; } @@ -985,44 +1186,61 @@ 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; + // The absolute index of the first card in a bucket, from the sort_letters + // song-counts: sum the counts of every bucket ordered before it. O(1) — no + // page-through. Returns null when we don't have true song-counts (the legacy + // distinct-artist fallback), so the caller can scan instead. + function _letterStartIndex(letter) { + if (!state.railLettersAreSongCounts) return null; + const letters = state.railLetters || {}; + const desc = state.sort.endsWith('-desc'); + const order = desc ? RAIL_BUCKETS.slice().reverse() : RAIL_BUCKETS; + let idx = 0; + for (const b of order) { if (b === letter) return idx; idx += (letters[b] || 0); } + return idx; + } + + // Fallback for providers without sort_letters: walk the sparse store forward + // (fetching pages as needed, bounded by total) until a card's bucket matches. + async function _scanForLetter(letter, token) { + const total = state.total || 0; + for (let i = 0; i < total; i++) { + if (state.songs[i] === undefined) { + await ensureWindow(i, Math.min(total, i + PAGE_SIZE)); + if (_jumpToken !== token) return null; + } + const s = state.songs[i]; + if (s && songBucket(s) === letter) return i; + } + return null; } let _jumpToken = 0; async function jumpToLetter(letter) { - const grid = document.getElementById('v3-songs-grid'); - if (!grid || state.view !== 'grid' || !letter) return; + const grid = _gridEl(), sizer = _sizerEl(), main = document.getElementById('v3-main'); + if (!grid || !sizer || !main || 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; + const { cols, rowH } = measureGeom(); + let targetIndex = _letterStartIndex(letter); + if (targetIndex == null) { + targetIndex = await _scanForLetter(letter, myToken); + if (_jumpToken !== myToken) return; + if (targetIndex == null) return; // letter not present } - if (_jumpToken !== myToken) return; - const target = grid.querySelector(sel); - if (!target) return; - const main = document.getElementById('v3-main'); + const total = state.total || 0; + if (targetIndex >= total) targetIndex = Math.max(0, total - 1); + const targetRow = Math.floor(targetIndex / Math.max(1, cols)); + // Pre-fetch the destination window so cards are present when the smooth + // scroll arrives (avoids a flash of skeletons at the landing row). + await ensureWindow(targetIndex, Math.min(total, targetIndex + cols * (OVERSCAN_ROWS * 2 + 4))); + if (_jumpToken !== myToken || state.view !== 'grid') return; + const sizerTop = _sizerTopInScroller(main, sizer); 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' }); - } + const top = Math.max(0, sizerTop + targetRow * rowH - pad); + main.scrollTo({ top, behavior: 'smooth' }); + requestWindowRender(); } function bindRailOnce() { @@ -1279,7 +1497,9 @@ // 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'); + // Hide the SIZER (not the inner grid) for non-grid views, so its reserved + // scroll height collapses and the tree/folder content sits at the top. + document.getElementById('v3-songs-gridsizer')?.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 @@ -1354,7 +1574,12 @@ // only on the grid view when not searching/filtering/selecting // (renderLibraryHome + updateLibraryHome). Empty/absent → collapses. '' + - '
' + + // Windowed grid: the sizer reserves the full-library scroll height; + // #v3-songs-grid is absolutely positioned inside it and holds only the + // visible window's cards (.v3-grid-window in v3.css). + '
' + + '
' + + '
' + '' + '' + '' + @@ -1444,6 +1669,7 @@ // before it tries to page deeper. await setView(state.view); bindScroll(); + bindGridResize(); positionToolbar(); bindToolbarReflow(); updateFilterBadge(); @@ -1468,18 +1694,20 @@ if (snap && hashMatch && domReady && chromeOk && viewOk) { if (state.view === 'grid' && _gridDomIntact()) { - if ((snap.page || 0) > state.page || (snap.loadedCount || 0) > loadedCount()) { - await _ensureGridPagesThrough(snap.page || 0); - } - document.getElementById('v3-songs-grid')?.classList.toggle('hidden', false); + // Geometry is stable (the sizer still holds the full height from + // the prior session), so restore is just: restore scrollTop, then + // repaint the window that maps to it. No more page-through. + document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', false); document.getElementById('v3-songs-tree')?.classList.toggle('hidden', true); syncChromeFromState(); + updateLibraryHome(); // select-mode clear on leave re-shows the home block _applyMainScrollTop(snap.scrollTop || 0); + requestWindowRender(); _clearLibraryScrollSnapshot(); return; } if (state.view === 'tree' && _treeDomIntact()) { - document.getElementById('v3-songs-grid')?.classList.toggle('hidden', true); + document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', true); document.getElementById('v3-songs-tree')?.classList.toggle('hidden', false); syncChromeFromState(); _applyMainScrollTop(snap.scrollTop || 0); @@ -1496,10 +1724,14 @@ // instead of silently showing the old results. Unchanged state keeps // the scroll-preserving no-op. if (state.renderedHash !== _libraryStateHash()) { reload(); return; } - document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid'); + document.getElementById('v3-songs-gridsizer')?.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'); { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; } + updateLibraryHome(); // select-mode clear on leave re-shows the home block + // Re-render in case the viewport resized while we were away (column + // count / row height may have changed) or select mode was cleared. + if (state.view === 'grid') requestWindowRender(); return; } @@ -1507,8 +1739,10 @@ if (snap && !hashMatch) _clearLibraryScrollSnapshot(); await render(); if (snapToRestore && snapToRestore.hash === _libraryStateHash()) { - if (state.view === 'grid') await _ensureGridPagesThrough(snapToRestore.page || 0); + // render() built + sized the sizer at scrollTop 0; move to the saved + // position and let the scroll handler repaint that window. _applyMainScrollTop(snapToRestore.scrollTop || 0); + if (state.view === 'grid') requestWindowRender(); } _clearLibraryScrollSnapshot(); } @@ -1554,6 +1788,11 @@ getSort: () => state.sort, getArtist: () => state.artist, getAlbum: () => state.album, + // The grid is windowed: only a slice of cards is in the DOM at any time. + // A plugin that decorates cards should read THIS (not a global + // querySelectorAll that assumes every card is present) and re-run on each + // `v3:library-window-rendered` event rather than once at load. + visibleCards: () => document.querySelectorAll('#v3-songs-grid [data-fn]'), filterParams: () => { const f = state.filters; const p = new URLSearchParams(); diff --git a/static/v3/v3.css b/static/v3/v3.css index b13dd2c..3045fdd 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -1219,3 +1219,26 @@ html.fb-immersive #v3-main > .screen.active { width: 8.5rem; scroll-snap-align: start; } + +/* — Windowed (virtualized) Songs grid (#636 item 3 stage 2) — */ +/* The grid is absolutely positioned inside #v3-songs-gridsizer, whose height is + set to the FULL library (ceil(total/cols)*rowH) so the scrollbar reflects the + whole library while only the visible window's cards are in the DOM. The inline + `top` (set by renderWindow) offsets the window to the first visible row. */ +.v3-grid-window { + position: absolute; + left: 0; + right: 0; + top: 0; +} +/* The arrangement-chip row is rendered on EVERY card (even when empty) at a fixed + single-line height — uniform card height is what makes the window's + absolute-position math exact. Extra chips are clipped rather than wrapping. */ +.v3-card-chips { + height: 1.5rem; + overflow: hidden; + flex-wrap: nowrap; +} +/* Skeleton placeholder shown only if a window's fetch hasn't landed; mirrors a + real card's vertical structure so it occupies an identical row height. */ +.v3-card-skel { pointer-events: none; } diff --git a/tests/browser/v3-grid-virtualization.spec.ts b/tests/browser/v3-grid-virtualization.spec.ts new file mode 100644 index 0000000..449f411 --- /dev/null +++ b/tests/browser/v3-grid-virtualization.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from '@playwright/test'; + +// Pins the bounded-DOM invariant of the windowed v3 Songs grid (#636 item 3 +// stage 2). Before virtualization the grid appended every scrolled page, so for +// a 2000-song library the card-node count grew unbounded (24 → 624 → 2001). +// Now only the visible window (± overscan) is ever in the DOM while a sizer +// element gives the scrollbar the full-library geometry. +// +// Route-mocked (same strategy as v3-tree-select.spec.ts) so the invariant is +// deterministic in CI without a seeded 2000-row library: /api/library serves a +// synthetic page from the page/after param with total 2001, and the keyset +// cursor is mocked as the next absolute offset. + +const TOTAL = 2001; +const PAGE_SIZE = 24; +const COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +// Same bucketing as the seed/server: index % 26 → a first letter, so the A–Z +// rail has real buckets and a jump has somewhere to land. +function songAt(i: number) { + const letter = COLS[i % 26]; + return { + filename: `seed/${String(i).padStart(5, '0')}.sloppak`, + title: `Song ${String(i).padStart(4, '0')}`, + artist: `${letter}Band ${String(i).padStart(4, '0')}`, + album: `${letter} Album`, + format: 'sloppak', + arrangements: [{ index: 0, name: 'Lead' }, { index: 1, name: 'Rhythm' }], + }; +} + +// sort_letters song-counts per bucket for index%26 over [0, TOTAL). +function sortLetters() { + const m: Record = {}; + for (let i = 0; i < TOTAL; i++) { const L = COLS[i % 26]; m[L] = (m[L] || 0) + 1; } + return m; +} + +test.beforeEach(async ({ page }) => { + await page.route('**/api/library?**', async (route) => { + const url = new URL(route.request().url()); + const after = url.searchParams.get('after'); + const size = Number(url.searchParams.get('size') || PAGE_SIZE); + const offset = after != null ? Number(after) : Number(url.searchParams.get('page') || '0') * size; + const songs = []; + for (let i = offset; i < Math.min(TOTAL, offset + size); i++) songs.push(songAt(i)); + const nextOffset = offset + size; + await route.fulfill({ + json: { + songs, total: TOTAL, page: Math.floor(offset / size), size, + next_cursor: nextOffset < TOTAL ? String(nextOffset) : null, + }, + }); + }); + await page.route('**/api/library/stats**', (route) => { + const url = new URL(route.request().url()); + const body: any = { total_songs: TOTAL, total: TOTAL, letters: {} }; + if (url.searchParams.get('sort_letters')) body.sort_letters = sortLetters(); + return route.fulfill({ json: body }); + }); + await page.route('**/api/library/artists**', (route) => route.fulfill({ json: { artists: [], total_artists: 0 } })); + await page.route('**/api/library/providers', (route) => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } })); + await page.route('**/api/library/tuning-names**', (route) => route.fulfill({ json: { tunings: [] } })); + await page.route('**/api/stats/best', (route) => route.fulfill({ json: {} })); + await page.route('**/api/stats/recent**', (route) => route.fulfill({ json: [] })); +}); + +async function openSongs(page) { + await page.goto('/'); + await page.waitForSelector('.screen.active', { timeout: 10000 }); + await page.evaluate(() => { + // @ts-ignore — neutralize playback so a stray click can't navigate away. + window.playSong = () => Promise.resolve(); + // @ts-ignore + window.showScreen('v3-songs'); + }); + await page.waitForSelector('#v3-songs-grid [data-fn]', { state: 'attached', timeout: 10000 }); +} + +test('the grid keeps a bounded number of card nodes while scrolling a 2001-song library', async ({ page }) => { + await openSongs(page); + + // The count reflects the FULL library even though only a window is rendered. + await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs'); + + // The sizer reserves the full scroll height (so the scrollbar is library-wide). + const scrollHeight = await page.evaluate(() => document.getElementById('v3-main')!.scrollHeight); + expect(scrollHeight).toBeGreaterThan(20000); + + // Scroll the whole library; the in-DOM card count must stay bounded throughout. + const CAP = 150; + let maxNodes = await page.locator('#v3-songs-grid [data-fn]').count(); + for (let s = 0; s < 50; s++) { + await page.evaluate(() => { const m = document.getElementById('v3-main')!; m.scrollTop += m.clientHeight * 0.85; }); + await page.waitForTimeout(60); + const n = await page.locator('#v3-songs-grid [data-fn]').count(); + maxNodes = Math.max(maxNodes, n); + expect(n).toBeLessThanOrEqual(CAP); + } + // Sanity: we actually rendered a window (not zero), and stayed well under the + // unbounded 2001 the old append-everything grid would have produced. + expect(maxNodes).toBeGreaterThan(0); + expect(maxNodes).toBeLessThanOrEqual(CAP); + + // The count is still correct after scrolling to the end. + await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs'); +}); + +test('the A–Z rail jumps directly to a letter without loading every page', async ({ page }) => { + await openSongs(page); + await page.waitForSelector('.v3-azrail-letter', { state: 'attached', timeout: 10000 }); + + // Jump to 'M'; the window scrolls to the row holding the first 'M' card. + await page.evaluate(() => { + const b = [...document.querySelectorAll('.v3-azrail-letter')] + .find((x) => x.getAttribute('data-letter') === 'M' && !(x as HTMLButtonElement).disabled) as HTMLElement | undefined; + if (!b) throw new Error('no M rail letter'); b.click(); + }); + + // After the jump+window render, an 'M' card is present near the top of the + // viewport (the jump is O(1) via sort_letters, not a full page-through). + await expect.poll(async () => page.evaluate(() => { + const main = document.getElementById('v3-main')!; + const top = main.getBoundingClientRect().top + (document.getElementById('v3-songs-toolbar')?.offsetHeight || 0); + return [...document.querySelectorAll('#v3-songs-grid [data-fn]')].some((c) => { + const r = c.getBoundingClientRect(); + return c.getAttribute('data-letter') === 'M' && r.top >= top - 4 && r.top < top + 320; + }); + }), { timeout: 5000 }).toBe(true); +}); diff --git a/tests/js/v3_az_rail.test.js b/tests/js/v3_az_rail.test.js index 047e725..524c919 100644 --- a/tests/js/v3_az_rail.test.js +++ b/tests/js/v3_az_rail.test.js @@ -1,11 +1,12 @@ // 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. +// letter (Plex/Radarr/iOS-contacts pattern). With the windowed grid (#636 item 3 +// stage 2) the jump seeks DIRECTLY: the sort_letters song-counts give the first +// card's absolute index (cumulative of prior buckets), which converts to a +// scrollTop — no page-through. The rail only offers letters the server reports +// present for the active sort+filter (so a tap always lands on 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. @@ -65,16 +66,24 @@ test('the rail + drag bubble are rendered in the Songs markup', () => { 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*\(\)/); +test('jumpToLetter seeks directly via sort_letters cumulative (no page-through)', () => { + // The cumulative-count seek: sum the song-counts of buckets ordered before + // the target to get its first row's absolute index. + assert.match(src, /function\s+_letterStartIndex\s*\(letter\)/, + 'jumpToLetter must derive the target index from sort_letters counts'); 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', + /async function\s+jumpToLetter[\s\S]*?_letterStartIndex\(letter\)[\s\S]*?scrollTo/, + 'jumpToLetter must compute the target index then scrollTo (no _loadNextAwait page-through)', ); - // A token guards against overlapping jumps (drag scrubbing) — newest wins. - assert.match(src, /_jumpToken\s*===\s*myToken/); + // It pre-fetches the destination window so cards are ready when the scroll lands. + assert.match(src, /async function\s+jumpToLetter[\s\S]*?ensureWindow\(/, + 'jumpToLetter must pre-fetch the destination window before scrolling'); + // The old forward-paging helper is gone (the seek is O(1)). + assert.doesNotMatch(src, /_loadNextAwait/, + 'the page-through helper must be removed under the windowed grid'); + // A token still guards overlapping jumps (drag scrubbing) — newest wins. + assert.match(src, /_jumpToken\s*!==\s*myToken/); }); test('the rail supports pointer drag-scrub + keyboard arrows', () => { diff --git a/tests/js/v3_songs_scroll.test.js b/tests/js/v3_songs_scroll.test.js index bc1b911..8ba04c8 100644 --- a/tests/js/v3_songs_scroll.test.js +++ b/tests/js/v3_songs_scroll.test.js @@ -36,13 +36,15 @@ function makeStore() { }; } -function saveSnapshot(storage, state, scrollTop, page, loadedCount) { +// Mirror of static/v3/songs.js _saveLibraryScrollSnapshot. Under the windowed +// grid (#636 item 3 stage 2) geometry is stable, so the snapshot is just +// {hash, scrollTop, view} — no page/loadedCount depth bookkeeping (restore sets +// scrollTop and re-renders the window that maps to it). +function saveSnapshot(storage, state, scrollTop) { const snap = { hash: buildLibraryStateHash(state), scrollTop, view: state.view, - page, - loadedCount, }; storage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } @@ -88,19 +90,21 @@ test('buildLibraryStateHash is stable for equivalent filter arrays', () => { assert.strictEqual(buildLibraryStateHash(s1), buildLibraryStateHash(s2)); }); -test('snapshot stores scrollTop and page', () => { +test('snapshot stores scrollTop + view + hash (geometry-stable restore)', () => { const storage = makeStore(); - saveSnapshot(storage, baseState, 1840, 3, 96); + saveSnapshot(storage, baseState, 1840); const snap = readSnapshot(storage); assert.strictEqual(snap.scrollTop, 1840); - assert.strictEqual(snap.page, 3); - assert.strictEqual(snap.loadedCount, 96); + assert.strictEqual(snap.view, 'grid'); assert.strictEqual(snap.hash, buildLibraryStateHash(baseState)); + // Page-depth bookkeeping is gone — the windowed grid restores from scrollTop. + assert.strictEqual(snap.page, undefined); + assert.strictEqual(snap.loadedCount, undefined); }); test('stale snapshot is detected when filters change', () => { const storage = makeStore(); - saveSnapshot(storage, baseState, 500, 1, 48); + saveSnapshot(storage, baseState, 500); const snap = readSnapshot(storage); const changed = buildLibraryStateHash({ ...baseState, q: 'beatles' }); assert.notStrictEqual(snap.hash, changed);