diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e2e65..b230ad4 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 +- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`. - **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`. diff --git a/static/v3/songs.js b/static/v3/songs.js index aa0d730..06180d3 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -349,11 +349,11 @@ 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'; + const color = acc >= MASTERY_ACCURACY ? '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'; + const color = acc >= MASTERY_ACCURACY ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low'); + const text = acc >= 0.5 && acc < MASTERY_ACCURACY ? 'text-black' : 'text-white'; return '' + '' + pct + '%'; } @@ -404,6 +404,126 @@ const keys = Array.from(_dirtyScores); _dirtyScores.clear(); keys.forEach(repaintAccuracy); + // A new score shifts the repertoire meter + the keep-practicing shelf. + renderLibraryHome(); + } + + // ── Practice-aware library home (repertoire meter + "Keep practicing") ───── + // Both read data we already have: state.accuracy (/api/stats/best = + // {filename: best_accuracy}) and /api/stats/recent. A song is "in your + // repertoire" at the same threshold the green accuracy badge uses (>= 0.9); + // a started song below that is "in progress". This is descriptive + // encouragement — it never gates content, decays, or nags (the goal-gradient + // / endowed-progress idea, kept healthy). + const MASTERY_ACCURACY = 0.9; + + function _repertoireCounts() { + let mastered = 0, learning = 0; + for (const v of Object.values(state.accuracy || {})) { + if (typeof v !== 'number') continue; + if (v >= MASTERY_ACCURACY) mastered++; else learning++; + } + return { mastered, learning }; + } + + // The home block is the unfiltered "front door": shown on the grid view when + // the user isn't running a focused query (search / filter) or selecting. + // Local provider only — the meter's mastered count and the shelf both read + // local practice stats (state.accuracy / /api/stats/recent), so on a remote + // provider they'd mix local numerators with a remote song total and play + // local files while browsing a remote library. Hide it there. + function libHomeVisible() { + return state.view === 'grid' && state.provider === 'local' + && !state.selectMode && !state.q && activeFilterCount() === 0; + } + + let _homeToken = 0; + async function renderLibraryHome() { + const host = document.getElementById('v3-lib-home'); + if (!host) return; + if (!libHomeVisible()) { host.classList.add('hidden'); return; } + // A newer render (view/filter/score change) supersedes this one so a + // slow response can't repaint a home the grid already moved past. + const myToken = ++_homeToken; + // Unfiltered library size for the meter denominator (the grid's + // state.total tracks the active filter; the meter is library-wide) + + // recently-played rows for the shelf, fetched together. + const [stats, recent] = await Promise.all([ + jget('/api/library/stats?provider=' + enc(state.provider)), + jget('/api/stats/recent?limit=24'), + ]); + if (_homeToken !== myToken || !libHomeVisible()) { // changed mid-fetch + if (_homeToken === myToken) host.classList.add('hidden'); + return; + } + const total = (stats && (stats.total_songs ?? stats.total)) || 0; + if (total <= 0) { host.classList.add('hidden'); return; } // empty library + // Shelf = recently-played, not-yet-mastered songs, newest first. Mastery + // is per-SONG (state.accuracy = MAX best across arrangements, what the + // green badge shows) — recents are per-(song,arrangement), so dedupe by + // filename and gate on the song's best, keeping the shelf and its badges + // consistent (no green-badged "keep practicing" card, no dupes). + const acc = state.accuracy || {}; + const seen = new Set(); + const shelf = (Array.isArray(recent) ? recent : []) + .filter((r) => { + if (!r || seen.has(r.filename)) return false; + const best = acc[r.filename]; + if (typeof best !== 'number' || best >= MASTERY_ACCURACY) return false; + seen.add(r.filename); + return true; + }) + .slice(0, 8); + + const { mastered, learning } = _repertoireCounts(); + const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100))); + const meter = + '