diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e49934..560a099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **v3 library: exact artist/album filters + scroll/page-depth restore** (slopsmith#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers). ### Fixed +- **A song's accuracy badge now updates on its library card right after you play it — no restart needed.** The v3 library (`static/v3/songs.js`) loaded the best-accuracy map (`/api/stats/best`) once into `state.accuracy` at render time and only ever refreshed it on a full re-render; the play→return flow takes the screen-entry fast-path that restores the cached grid DOM without re-fetching, so a just-earned score stayed invisible until the next restart re-ran `render()`. The `stats-recorder` now emits a `stats:recorded` event (carrying `filename`/`arrangement`) once the scored `POST /api/stats` resolves on the server — the correct moment, since `song:stop` fires before the POST completes. `songs.js` listens: if the library is the active screen it re-fetches `/api/stats/best` and patches the affected card/row badge in place; otherwise it marks the filename dirty and `onV3SongsScreenEnter` applies it on return (a failed fetch keeps the entry dirty to retry). Badge markup was factored into a shared `accuracyBadge(filename, variant)` (grid pill + tree-row percentage, both tagged `.fb-acc-badge`) so the in-place `repaintAccuracy` can find and replace them without a full list re-render (scroll/pagination preserved). The old empty `song:stop` "refresh lazily next render" placeholder is replaced. - **Changing Settings → 3D Highway → Fret spacing no longer ejects you to the home screen.** The `highway_3d` plugin's `h3dSetFretSpacing` was the lone 3D-highway setting that called `location.reload()` to apply — and since the SPA boots with `#home` as the active screen (`index.html` `.screen.active`), the reload dropped the user out of Settings onto the homescreen. It now applies live like every other 3D-highway setting: it rebinds the module-scope `_h3dFretUniform` flag (so panels mounted later this session pick up the new mode), recomputes the two `fretX`-derived scalars that were baked at init (`_fretLabelScaleRefW` for fret-label sprite scaling, `FRET_WIDTH_MID` for camera hysteresis), and broadcasts a `fretSpacing` change over the existing `_bgEmitChange` pub-sub so every mounted panel rebuilds its board via `buildBoard()`. Per-frame note geometry already reads `fretX` live and needs no rebuild. No page reload, so the Settings screen stays put. Source-level regression tests in `tests/js/highway_3d_fret_spacing.test.js` now pin the no-reload / live-rebuild behavior. - **v3 library scroll-restore no longer breaks the classic v2 UI or drops off-screen searches** (slopsmith#857). Two regressions in the scroll-restore work above: (1) `playSong` remapped `home`-launched songs to return to the `#v3-songs` screen unconditionally, but `static/app.js` is shared with the v2 UI (served at `/v2` / `SLOPSMITH_UI=v2`) where that screen does not exist — Esc-from-player then called `showScreen('v3-songs')`, which threw on the missing element and stranded the user on a blank screen with playback still running; the remap now applies only when `#v3-songs` is present. (2) The Songs screen-entry fast-path skips reloading to preserve scroll, but the global topbar search routed through it, so once Songs had been visited, searching from another screen navigated there without applying the new query; the screen now tracks the state hash each fetch reflects and refetches when it has drifted, keeping the scroll-preserving no-op only when nothing changed. - **An active custom highway renderer is no longer starved of `draw()` when it hides the canvas** (#819). The per-frame draw gate in `static/highway.js` bailed on `if (!_lastVisible) return`, which conflated two different "hidden" states: a genuine off-screen canvas (`offsetParent === null` — navigate-away / `display:none` splitscreen panel, #246) versus a renderer-set *override-hide* (`setVisible(false)`, where an opaque overlay covers the canvas but the active renderer keeps painting its own surface). The gate now only pauses everything for the off-screen case (and still pauses the default 2D renderer on an override-hide); the **active custom renderer** keeps receiving `draw()` through its own override-hide. The `highway:visibility` event still fires before the gate, so sibling overlay renderers (e.g. 3D Highway's `.h3d-wrap`) still pause. This is the core-side root cause behind the Tab View cursor freezing in single-player (slopsmith#734; worked around plugin-side in slopsmith-plugin-tabview#25). diff --git a/static/v3/songs.js b/static/v3/songs.js index fa2319f..e12c6ea 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -298,16 +298,66 @@ if (window.syncLibrarySong && sid) window.syncLibrarySong(state.provider, sid, { playWhenReady: true }); } - function accuracyBadge(filename) { + // Accuracy badge markup. `variant` is 'grid' (overlay pill on the card art, + // default) or 'tree' (inline percentage in the list row). Both carry the + // .fb-acc-badge class so a post-play refresh (repaintAccuracy) can find and + // replace them in place without re-rendering the whole list. + function accuracyBadge(filename, variant) { const acc = state.accuracy[filename]; if (acc == null) return ''; const pct = Math.round(acc * 100); + if (variant === 'tree') { + const color = acc >= 0.9 ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low'; + return '' + pct + '%'; + } const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low'); const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white'; - return '' + + return '' + '' + pct + '%'; } + // After a song is scored, the badge for that card is stale until the next + // full render(). Refresh state.accuracy from the server and patch the badge + // of any currently-rendered card/row in place (grid + tree). `_dirtyScores` + // tracks filenames scored while the library was off-screen, applied on enter. + const _dirtyScores = new Set(); + + function repaintAccuracy(key) { + const apply = (el, variant) => { + if (el.getAttribute('data-fn') !== key) return; + const old = el.querySelector('.fb-acc-badge'); + const html = accuracyBadge(key, variant); + if (variant === 'grid') { + const art = el.querySelector('[data-v3-play]'); + if (!art) return; + if (old) old.remove(); + if (html) art.insertAdjacentHTML('beforeend', html); + } else if (old) { + if (html) old.outerHTML = html; else old.remove(); + } else if (html) { + // No prior badge in this row — insert before the favorite button + // so it keeps its slot (after the format chip). + const fav = el.querySelector('[data-fav]'); + if (fav) fav.insertAdjacentHTML('beforebegin', html); + else el.insertAdjacentHTML('beforeend', html); + } + }; + document.querySelectorAll('#v3-songs-grid [data-fn]').forEach((el) => apply(el, 'grid')); + document.querySelectorAll('#v3-songs-tree [data-fn]').forEach((el) => apply(el, 'tree')); + } + + async function applyScoreRefresh() { + if (!_dirtyScores.size) return; + const fresh = await jget('/api/stats/best'); + // Fetch failed — keep the entries dirty so the next trigger (or screen + // enter) retries rather than silently dropping the badge update. + if (!fresh) return; + state.accuracy = fresh; + const keys = Array.from(_dirtyScores); + _dirtyScores.clear(); + keys.forEach(repaintAccuracy); + } + // Source format of a song — prefer the server's `format` field, fall back // to the filename extension. Returns '' for unknown. function fmtLabel(song) { @@ -675,7 +725,7 @@ '' + '' + esc(s.title) + '' + (fl ? '' + fl + '' : '') + - (state.accuracy[k] != null ? '' + Math.round(state.accuracy[k] * 100) + '%' : '') + + accuracyBadge(k, 'tree') + '' + ''); }).join('') + '').join('') + '').join(''); wireCards(host); @@ -904,6 +954,11 @@ } async function onV3SongsScreenEnter() { + // Pull in any scores recorded while the library was off-screen (the usual + // play→return flow) before the fast-paths below restore the cached DOM, + // so the just-played song's badge is current. The full render() path + // re-fetches accuracy itself, so this is a no-op cost there. + await applyScoreRefresh(); const snap = _readLibraryScrollSnapshot(); const hashMatch = !!(snap && snap.hash === _libraryStateHash()); const domReady = state.built && !!document.getElementById('v3-songs-grid'); @@ -1015,6 +1070,20 @@ if (bar) bar.remove(); } }); - sm.on('song:stop', () => { /* refresh accuracy lazily next render */ }); + // stats-recorder POSTs the score asynchronously and emits this once the + // server has the new best — that's the correct moment to refresh the + // badge (song:stop fires before the POST resolves, so it's too early). + // If the library is visible right now, repaint immediately; otherwise + // mark it dirty and onV3SongsScreenEnter applies it on return. + sm.on('stats:recorded', (e) => { + const fn = e && e.detail && e.detail.filename; + if (!fn) return; + _dirtyScores.add(fn); + // Only repaint now if the library is the active screen; otherwise + // leave it dirty for onV3SongsScreenEnter (applyScoreRefresh clears + // the set, so repainting against a hidden grid would drop the update). + const active = document.querySelector('.screen.active'); + if (active && active.id === 'v3-songs') applyScoreRefresh(); + }); } })(); diff --git a/static/v3/stats-recorder.js b/static/v3/stats-recorder.js index 8685c6d..e3511c4 100644 --- a/static/v3/stats-recorder.js +++ b/static/v3/stats-recorder.js @@ -101,6 +101,9 @@ if (window.v3Profile && typeof window.v3Profile.refresh === 'function') { window.v3Profile.refresh(); } + // Tell the library the song's best score may have changed so its + // card/list badge refreshes without waiting for a restart. + sm.emit('stats:recorded', { filename: body.filename, arrangement: body.arrangement }); }); } @@ -157,6 +160,7 @@ // treat it as a natural finish for calibration-retry feedback. await notifyProgression(response, body, true); if (window.v3Profile && typeof window.v3Profile.refresh === 'function') window.v3Profile.refresh(); + sm.emit('stats:recorded', { filename: body.filename, arrangement: body.arrangement }); }); });