diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd9c7c..6e84c53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Library filter: one-click "Not split" + a piano stem pill.** The v3 Filters drawer's + stems section gains a **Not split** shortcut that selects "lacks every instrument stem" + in one tap — the same query Stem Splitter's missing-stems view runs — instead of + cycling five pills to ✕ by hand. The pill row also gains `piano` (the drawer offered + five of the canonical six stems, so a piano-only song wrongly matched a hand-built + "lacks all" filter). "No lyrics" already existed in the Lyrics section. - **Gold tier (career passports)** — an earned badge turns **gold** when Virtuoso verifies an improvised jam in the passport's style (the `gold_improv` artifact relays with the drill snapshot; a genre inherits its diff --git a/static/v3/songs.js b/static/v3/songs.js index a12c663..5b21196 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -1,4317 +1,4334 @@ -/* - * fee[dB]ack v0.3.0 — Songs / Library (#v3-songs), native rebuild. - * - * A vanilla-JS library browser over the existing /api/library* endpoints: - * provider selector (via the `library` capability, not DOM scraping), grid + - * tree views, sort, format filter, a tri-state filter drawer (arrangements / - * stems / lyrics / tunings), search (driven by the topbar), infinite scroll, - * fb song cards with accuracy badges (song_stats), favorite + save-for-later, - * and upload. Reuses window.playSong for playback (design/05: library is an - * active capability domain; everything else stays on the documented globals). - */ -(function () { - 'use strict'; - const sm = window.feedBack; - const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( - { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); - const enc = encodeURIComponent; - // Inverse of `enc` for matching a played song's filename back to a library - // card. The `stats:recorded` event (like `song:loading`) carries the - // filename exactly as it was handed to `playSong` — i.e. encodeURIComponent'd - // (see `playCard`, and the highway WS which decodeURIComponent's it). But - // cards key on the DECODED library filename (`cardKey` → `localFilename`), - // and `/api/stats/best` is server-canonicalized to that same decoded key, so - // an encoded filename matches no card and the post-play badge repaint silently - // no-ops. Decode to land in the card/`state.accuracy` key space. Idempotent - // for already-decoded names (no '%'); on malformed input falls back to the - // original so a real filename containing a literal '%' is never corrupted. - function decFn(fn) { - if (typeof fn !== 'string' || fn.indexOf('%') === -1) return fn || ''; - try { return decodeURIComponent(fn); } catch (_) { return fn; } - } - - const SORTS = [ - ['artist', 'Artist A–Z'], ['artist-desc', 'Artist Z–A'], - ['title', 'Title A–Z'], ['title-desc', 'Title Z–A'], - ['recent', 'Recently Added'], ['year-desc', 'Year (newest)'], - ['year', 'Year (oldest)'], ['tuning', 'Tuning'], - // Mastery = best accuracy across arrangements (song_stats); unscored songs - // sort last either way. Ascending surfaces what needs work; never default. - ['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'], - // Personal difficulty (song_user_meta.user_difficulty, 1-5); unrated - // songs sort last either way. - ['difficulty', 'Difficulty (easiest first)'], ['difficulty-desc', 'Difficulty (hardest first)'], - ]; - const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']]; - 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'; - // Persisted "how I'm looking" prefs (sort / format / view / drawer filters). - // The tester ask: "most users pick one sort and leave it" + remember filters. - // The search query and the artist/album drill-down are navigational, so they - // are deliberately NOT persisted; cold start stays the neutral Artist A–Z. - const PREFS_KEY = 'v3:songs-prefs'; - 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'; - - const state = { - provider: 'local', view: 'grid', sort: 'artist', format: '', q: '', - artist: '', album: '', - grouping: true, // one card per song (multi-chart grouping); persisted - - filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' }, - page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], genres: [], - artistCatalog: [], renderedHash: '', - scrollBound: false, - songsById: {}, selectMode: false, selected: new Set(), - railLetters: null, railLettersAreSongCounts: false, railJumping: false, - // ── Artist page (PR-B) ── - // Non-null while the artist sub-page is showing (the artist's canonical - // or raw name). The gates mirror the two Settings toggles: pages are - // local-only and default ON; the external-links row is opt-in. - artistPage: null, - artistReturnScroll: null, // scrollTop to restore on ← Song Library - artistPagesEnabled: true, - artistLinksEnabled: 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 ─────────────────────────────────────────────────────── - // 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 + - (f.lyrics ? 1 : 0) + (f.tuningMatch === 'playable' ? 1 : f.tunings.length) + (f.mastery ? f.mastery.length : 0) + - (f.match ? f.match.length : 0) + (f.genre ? f.genre.length : 0) + - (state.artist ? 1 : 0) + (state.album ? 1 : 0); - } - - function _getV3MainScroller() { return document.getElementById('v3-main'); } - - function buildLibraryStateHash(st) { - const f = (st && st.filters) || {}; - return JSON.stringify({ - view: st.view || 'grid', - q: st.q || '', - sort: st.sort || 'artist', - provider: st.provider || 'local', - format: st.format || '', - artist: st.artist || '', - album: st.album || '', - filters: { - arr_has: [...(f.arr_has || [])].sort(), - arr_lacks: [...(f.arr_lacks || [])].sort(), - stem_has: [...(f.stem_has || [])].sort(), - stem_lacks: [...(f.stem_lacks || [])].sort(), - lyrics: f.lyrics || '', - tunings: [...(f.tunings || [])].sort(), - mastery: [...(f.mastery || [])].sort(), - match: [...(f.match || [])].sort(), - genre: [...(f.genre || [])].sort(), - }, - }); - } - - function _libraryStateHash() { return buildLibraryStateHash(state); } - - // Restore the persisted view prefs once per page load, before the first - // toolbar build so the selects render with the saved values. Every value is - // validated against its known option list, so a stale/removed setting can - // never wedge the UI — it just falls back to the default. - let _prefsRestored = false; - function applySavedPrefs() { - let saved; - try { saved = JSON.parse(localStorage.getItem(PREFS_KEY)); } catch (_) { return; } - if (!saved || typeof saved !== 'object') return; - if (SORTS.some(([v]) => v === saved.sort)) state.sort = saved.sort; - if (FORMATS.some(([v]) => v === saved.format)) state.format = saved.format; - if (saved.view === 'grid' || saved.view === 'tree' || saved.view === 'folder' || saved.view === 'albums') state.view = saved.view; - if (typeof saved.grouping === 'boolean') state.grouping = saved.grouping; - const f = saved.filters; - if (f && typeof f === 'object') { - const arr = (x) => (Array.isArray(x) ? x.slice() : []); - // mastery + match + genre are session-only facets (deliberately not - // persisted), but the restored object must still CARRY the keys — - // the filter drawer indexes f.mastery/f.match/f.genre unconditionally, - // so dropping them here breaks the drawer for anyone with saved prefs. - state.filters = { - arr_has: arr(f.arr_has), arr_lacks: arr(f.arr_lacks), - stem_has: arr(f.stem_has), stem_lacks: arr(f.stem_lacks), - lyrics: f.lyrics || '', tunings: arr(f.tunings), - mastery: [], match: [], genre: [], - }; - } - } - // Persist the current view prefs (best-effort; storage may be full/disabled). - // Called from reload(), which every sort/format/filter/view change funnels - // through — so this is the single write point. - function saveLibraryPrefs() { - try { - const f = state.filters; - localStorage.setItem(PREFS_KEY, JSON.stringify({ - sort: state.sort, format: state.format, view: state.view, - grouping: state.grouping !== false, - filters: { - arr_has: [...f.arr_has], arr_lacks: [...f.arr_lacks], - stem_has: [...f.stem_has], stem_lacks: [...f.stem_lacks], - lyrics: f.lyrics || '', tunings: [...f.tunings], - }, - })); - } catch (_) { /* best-effort */ } - } - - 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, - }; - try { sessionStorage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } catch (e) { /* quota / private mode */ } - } - - function _readLibraryScrollSnapshot() { - try { - const raw = sessionStorage.getItem(SCROLL_STATE_KEY); - if (!raw) return null; - const snap = JSON.parse(raw); - return (snap && typeof snap === 'object') ? snap : null; - } catch (e) { return null; } - } - - function _clearLibraryScrollSnapshot() { - try { sessionStorage.removeItem(SCROLL_STATE_KEY); } catch (e) { /* */ } - } - - function _applyMainScrollTop(scrollTop) { - const main = _getV3MainScroller(); - if (!main) return; - const top = Math.max(0, Number(scrollTop) || 0); - const apply = () => { main.scrollTop = top; }; - apply(); - requestAnimationFrame(apply); - 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'); - const sizer = document.getElementById('v3-songs-gridsizer'); - return !!grid && !!sizer && state.total > 0 && state.songs[0] !== undefined; - } - - function _treeDomIntact() { - const tree = document.getElementById('v3-songs-tree'); - if (!tree) return false; - return !!(tree.querySelector('[data-fn]') || tree.querySelector('details')); - } - - // ── Multi-chart grouping (P5c, design §7.1) ───────────────────────────── - // The grid queries with group=1 so charts of the same work collapse to ONE - // card — the representative (preferred/auto-pick) chart — with rows carrying - // chart_count + work_key from the materialized work_display read-model - // (P5a/P5b). group must ride BOTH the page fetch and the rail's stats fetch - // so page, total and sort_letters all count works identically — a - // works-vs-charts mismatch would break the rail's cumulative-seek math and - // the sizer geometry. Grouping is default-ON per the design, with a - // persisted toggle in the filter drawer (P5e) — OFF falls back to today's - // one-card-per-chart. Only the local provider implements group=; smart - // collections and remote providers ignore it and stay flat (their rows - // then carry no chart_count, so no ⚑ chips render). - function groupingActive() { return state.grouping !== false; } - - function queryParams(extra, opts) { - const f = state.filters; - const skipArtistAlbum = opts && opts.catalog; - const p = new URLSearchParams(); - p.set('provider', state.provider); - p.set('sort', state.sort); - if (state.format) p.set('format', state.format); - if (state.q) p.set('q', state.q); - if (!skipArtistAlbum && state.artist) p.set('artist', state.artist); - if (!skipArtistAlbum && state.album) p.set('album', state.album); - if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(',')); - if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(',')); - if (f.stem_has.length) p.set('stems_has', f.stem_has.join(',')); - if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(',')); - if (f.lyrics) p.set('has_lyrics', f.lyrics); - // The two modes answer different questions, so only one filters at a - // time: sending both would silently intersect them. - if (f.tunings.length && f.tuningMatch !== 'playable') p.set('tunings', f.tunings.join(',')); - // Which perspective the `tunings` filter + the tuning sort read. - if (libInstrument() !== 'guitar-lead') p.set('instrument', libInstrument()); - // "Playable without retuning": send the player's LIVE tuning and let the - // server do the pitch maths (the pitch tables live in lib/tunings.py — - // duplicating them here is how the two drift apart). - applyPlayableParams(p, f); - if (f.mastery && f.mastery.length) p.set('mastery', f.mastery.join(',')); - if (f.match && f.match.length) p.set('match', f.match.join(',')); - if (f.genre && f.genre.length) p.set('genre', f.genre.join(',')); - Object.entries(extra || {}).forEach(([k, v]) => p.set(k, v)); - return p; - } - - // The active filter set as a smart-collection rule object (raw query-param - // format the backend stores). Mirrors queryParams' filter fields, minus - // provider/page/size. Empty object → nothing worth saving as a collection. - function currentFilterRules() { - const f = state.filters, r = {}; - if (state.q) r.q = state.q; - if (state.format) r.format = state.format; - if (state.artist) r.artist = state.artist; - if (state.album) r.album = state.album; - if (f.arr_has.length) r.arrangements_has = f.arr_has.join(','); - if (f.arr_lacks.length) r.arrangements_lacks = f.arr_lacks.join(','); - if (f.stem_has.length) r.stems_has = f.stem_has.join(','); - if (f.stem_lacks.length) r.stems_lacks = f.stem_lacks.join(','); - if (f.lyrics) r.has_lyrics = f.lyrics; - if (f.tunings.length) r.tunings = f.tunings.join(','); - if (state.sort && state.sort !== 'artist') r.sort = state.sort; - return r; - } - - // Save the current filter set as a smart collection (a saved live query that - // shows up as a source in the picker). #636 item 2. - async function saveCurrentAsCollection() { - const rules = currentFilterRules(); - if (!Object.keys(rules).length) return; - const name = ((await window.uiPrompt({ - title: 'Save as collection', - label: 'A live view of the current filters, in the source picker.', - okLabel: 'Save', - placeholder: 'Collection name', - })) || '').trim(); - if (!name) return; - try { - const res = await fetch('/api/collections', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, rules }), - }); - if (!res.ok) return; - const col = (await res.json()).collection; - closeDrawer(); - if (col && col.id != null) state.provider = 'collection:' + col.id; - await render(); // rebuilds the toolbar (provider picker now lists + selects it) - } catch (e) { /* offline / aborted — leave the drawer as-is */ } - } - - function albumsForArtist(name) { - const a = (state.artistCatalog || []).find((x) => x.name === name); - return a ? (a.albums || []) : []; - } - - function _chromeIntact() { - return !!(document.getElementById('v3-songs-filters') && - document.getElementById('v3-songs-artist') && - document.getElementById('v3-songs-grid')); - } - - function artistSelectHtml() { - const opts = [''] - .concat((state.artistCatalog || []).map((a) => - '')); - return opts.join(''); - } - - function albumSelectHtml() { - if (!state.artist) { - return ''; - } - const albums = albumsForArtist(state.artist); - const opts = [''] - .concat(albums.map((n) => - '')); - return opts.join(''); - } - - function refreshArtistAlbumSelects() { - const artistEl = document.getElementById('v3-songs-artist'); - const albumEl = document.getElementById('v3-songs-album'); - if (artistEl) artistEl.innerHTML = artistSelectHtml(); - if (albumEl) { - albumEl.innerHTML = albumSelectHtml(); - albumEl.disabled = !state.artist; - } - } - - function syncChromeFromState() { - const map = { - 'v3-songs-provider': state.provider, - 'v3-songs-sort': state.sort, - 'v3-songs-format': state.format, - }; - Object.entries(map).forEach(([id, val]) => { - const el = document.getElementById(id); - if (el && el.value !== val) el.value = val; - }); - refreshArtistAlbumSelects(); - const gridBtn = document.getElementById('v3-songs-grid-btn'); - const treeBtn = document.getElementById('v3-songs-tree-btn'); - if (gridBtn) gridBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); - 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(); - } - - async function loadArtistCatalog() { - const artists = []; - let page = 0, total = Infinity; - while (artists.length < total) { - const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }, { catalog: true }).toString()); - if (!data || !Array.isArray(data.artists)) break; - artists.push(...data.artists); - total = (data.total_artists != null) ? data.total_artists : artists.length; - if (!data.artists.length || page > 1000) break; - page++; - } - state.artistCatalog = artists.map((a) => ({ - name: a.name, - albums: (a.albums || []).map((al) => al.name), - })); - if (state.artist && !state.artistCatalog.some((a) => a.name === state.artist)) { - state.artist = ''; - state.album = ''; - } else if (state.album && !albumsForArtist(state.artist).includes(state.album)) { - state.album = ''; - } - return state.artistCatalog; - } - - function resetScrollToTop() { - _clearLibraryScrollSnapshot(); - _applyMainScrollTop(0); - } - - function setArtist(value) { - state.artist = value || ''; - if (state.album && !albumsForArtist(state.artist).includes(state.album)) state.album = ''; - resetScrollToTop(); - refreshArtistAlbumSelects(); - reload(); - } - - function setAlbum(value) { - if (!state.artist) { state.album = ''; return; } - state.album = value || ''; - resetScrollToTop(); - reload(); - } - - async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } } - - // ── Provider-aware song helpers ──────────────────────────────────────── - // Remote library providers (feedBack-plugin-remote-library-*) expose songs - // by provider-owned id with their own art/sync/play flow. Reuse the legacy - // app.js globals (the shared engine) so v3 behaves identically for remote - // providers instead of assuming every row is a local file. All degrade to - // the local path when the helpers/providers aren't present. - function songId(s) { - return (window._librarySongId ? window._librarySongId(s) : (s.filename || '')) || ''; - } - function localFilename(s) { - return window._libraryLocalFilename ? window._libraryLocalFilename(s, state.provider) : (s.filename || ''); - } - // Stable per-card key: the local filename when present (local song, or a - // synced remote one), else the provider song id. - function cardKey(s) { return localFilename(s) || songId(s); } - - function artUrl(song) { - if (window._librarySongArtUrl) return window._librarySongArtUrl(song, state.provider); - const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : ''; - return song.filename ? '/api/song/' + enc(song.filename) + '/art' + v : ''; - } - - // Play a card: local (or already-synced remote) → playSong the local file; - // an unsynced remote song → sync it first, then play when ready. - function playCard(song, arrIdx) { - if (!song) return; - _saveLibraryScrollSnapshot(); - const lf = localFilename(song); - if (lf) { if (window.playSong) window.playSong(enc(lf), arrIdx); return; } - const sid = songId(song); - if (window.syncLibrarySong && sid) window.syncLibrarySong(state.provider, sid, { playWhenReady: true }); - } - - // 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 ''; - // Floor, never round: 100% must mean every note hit. - const pct = Math.floor(acc * 100); - if (variant === 'tree') { - const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low'; - return '' + pct + '%'; - } - 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 + '%'; - } - - // ── Metadata-refresh per-tile state (the "Refresh Metadata" batch) ───────── - // A transient badge painted ONLY while a metadata refresh is running: the - // songs actually being (re)matched animate queued → working → done. Keyed by - // the card's data-fn (= the local filename the enrichment cache keys on). - // Empty for every song outside a refresh, so an idle card is byte-identical - // to before (keeps the windowed grid's height math untouched). Honest state - // transitions, NOT a fake per-song %: a match is binary (design §11). - const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange' - // Cards whose enrichment landed 'failed' (from the grid payload) — tracked so - // the PERSISTENT "no match" badge survives a batch tile clearing (a - // _patchCardEnrich with no flag falls back to this instead of wiping it). - // Populated as cards render (enrichBadge is called per card with the flag). - const _unmatched = new Set(); - function enrichBadge(fn, unmatched) { - if (unmatched !== undefined) { if (unmatched) _unmatched.add(fn); else _unmatched.delete(fn); } - // A live batch tile wins over the resting no-match marker (they never - // coexist — the batch clears its tiles when it finishes). - const st = _metaTile[fn] || (_unmatched.has(fn) ? 'nomatch' : null); - if (!st) return ''; - const M = { - queued: ['bg-black/60 text-fb-textDim', '• Queued', ''], - working: ['bg-fb-primary text-white', '⟳ Matching…', ''], - done: ['bg-fb-good/90 text-black', '✓ Updated', ''], - nochange: ['bg-black/60 text-fb-textDim', '— No match', ''], - // Resting indicator: subtle, so a mostly-unmatched library isn't a - // wall of loud badges. Clickable — a one-click handoff into the - // Fix-metadata popup for this song (see the [data-meta-fix] wiring). - nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'Click to fix the metadata by hand'], - }; - const conf = M[st] || M.queued; - const fixable = st === 'nomatch'; // resting badge → opens Fix-metadata - // top-10 clears the tuning chip (top-2) in both normal and select mode; - // z-20 sits it above the art. Batch states are non-interactive; the - // resting "no match" badge is the handoff into the popup. - const cls = 'v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] + - ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight ' + - (fixable ? 'pointer-events-auto cursor-pointer hover:bg-fb-primary hover:text-white transition-colors' : 'pointer-events-none'); - return '' + conf[1] + ''; - } - - // 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(); - - // Set when a library scan / DLC-folder change happened while this screen was - // off (or showing a stale, e.g. pre-DLC empty, grid). The grid's cached DOM / - // snapshot would otherwise survive a sidebar return, so we force a full - // re-fetch on the next entry. (feedBack — "No DLC until restart".) - let _libraryDirty = false; - - 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); - // A new score shifts the repertoire meter + the keep-practicing shelf. - renderLibraryHome(); - } - - // ── Practice-aware library home (repertoire meter + "Keep practicing") ───── - // The meter reads state.accuracy (/api/stats/best = {filename: best_accuracy}); - // the shelf reads the growth-edge recommender (/api/library/practice-suggestions, - // P3). 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 / the practice-suggestions endpoint), - // 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) + - // the growth-edge "practice next" shelf rows, fetched together. - const [stats, suggestions] = await Promise.all([ - jget('/api/library/stats?provider=' + enc(state.provider)), - jget('/api/library/practice-suggestions?limit=8'), - ]); - 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 = the growth-edge recommender (P3): attempted-but-not-mastered - // songs ordered by difficulty-appropriateness × mastery-proximity, so it - // points at the version worth practicing next rather than just the most - // recent. The server already gates (not-mastered) + aggregates per song, - // so the rows are the shelf as-is; each row's `arrangement` is the one - // closest to mastery (what a click should open). - const shelf = Array.isArray(suggestions) ? suggestions : []; - - const { mastered, learning } = _repertoireCounts(); - // Day-one zero-state (launch polish): no practice data and no real - // growth-edge rows → an invitational meter, never "0 of N". Starter - // rows are the server's no-attempts fallback, so they count as "no - // practice yet" too. - const starterShelf = shelf.length > 0 && !!shelf[0].starter; - const invitational = (mastered + learning) === 0 && (!shelf.length || starterShelf); - let meter; - if (invitational) { - meter = - '
' + - '
' + - 'Repertoire' + - 'grows as you master songs' + - '
' + - '
' + - '
'; - } else { - const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100))); - meter = - '
' + - '
' + - 'Repertoire' + - '' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') + - (learning ? ' · ' + learning + ' in progress' : '') + '' + - '
' + - '
' + - '
'; - } - - let shelfHtml = ''; - if (shelf.length) { - const cards = shelf.map((r) => - '').join(''); - // Starter rows → the invitational "Start here" framing; real - // growth-edge rows → the usual "Keep practicing". Same cards. - const header = starterShelf - ? '

Start here

' + - '
a few approachable songs to kick things off
' - : '

Keep practicing

'; - shelfHtml = - '
' + header + - '
' + cards + '
' + - '
'; - } - - 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; suggestions are - // always local-library rows, so no provider sync is needed). - host.querySelectorAll('.v3-kp-card').forEach((btn) => btn.addEventListener('click', () => { - const fn = btn.getAttribute('data-kp'); - const arr = btn.getAttribute('data-arr'); - if (!fn || !window.playSong) return; - _saveLibraryScrollSnapshot(); - window.playSong(enc(fn), arr === '' ? undefined : Number(arr)); - })); - } - - // Toggle/refresh the home block on view/sort/filter/search changes. - function updateLibraryHome() { - const host = document.getElementById('v3-lib-home'); - if (!host) return; - if (!libHomeVisible()) { host.classList.add('hidden'); return; } - renderLibraryHome(); - } - - // Source format of a song — prefer the server's `format` field, fall back - // to the filename extension. Returns '' for unknown. - function fmtLabel(song) { - let f = (song.format || '').toLowerCase(); - if (!f) { - const fn = (song.filename || '').toLowerCase(); - f = (fn.endsWith('.feedpak') || fn.endsWith('.sloppak')) ? 'sloppak' : ''; - } - return f === 'sloppak' ? 'FEEDPAK' : f === 'loose' ? 'FOLDER' : ''; - } - // Corner badge for art-based cards (sloppak accented, others muted). - function fmtBadge(song) { - const l = fmtLabel(song); - if (!l) return ''; - const c = l === 'FEEDPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim'; - return '' + l + ''; - } - - // Personal-layer badges (P2): a difficulty pip + a tag count, painted from the - // row payload P1 embeds (song.user_difficulty / song.tags). Sits at top-right - // and fades on hover so it yields to the action buttons that share that corner. - // Returns '' when the song has neither, so an un-annotated card is byte-for-byte - // what it was before P2 (keeps the windowed grid's height math untouched). - function personalBadges(song) { - const d = song.user_difficulty; - const tags = song.tags || []; - if (d == null && !tags.length) return ''; - let out = '
'; - if (d != null) out += '◆' + esc(d) + ''; - if (tags.length) out += '🏷' + tags.length + ''; - return out + '
'; - } - - // Clickable arrangement chips — one ').join(''); - } - - // ⚑ multi-chart chip (P5c, design §7.1): the persistent "other versions - // exist" cue on a grouped card. Rendered ONLY when the grouped query says - // this card stands for 2+ charts of one work (chart_count = work_display - // group_size, P5a) — a single-chart card emits nothing, so its markup stays - // byte-identical to an ungrouped card. First in the chip row + shrink-0 so - // the overflow-hidden row clips arrangement chips before it ever clips the - // cue. Clicking it opens the Charts drawer (see wireCards). - function chartsChipHtml(song) { - const n = song.chart_count; - if (!(n >= 2) || !song.work_key) return ''; - return ''; - } - - // ── Tuning-match flags (working-tuning PR 6) ─────────────────────────────── - // Colour each song's tuning chip by whether your CURRENT working tuning covers - // it: green = play it now, amber = needs a retune. Uses the tuner plugin's - // coverage check (async) + the host workingTuning state — BOTH feature-detected, - // so without them the chips render exactly as before. Decoration runs AFTER the - // (sync) window paint so scrolling stays snappy; a token cancels a superseded pass. - let _tuningDecorToken = 0; - // The instrument the current grid was queried/painted for, so a - // working-tuning change can tell a guitar<->bass SWITCH (re-query) from a - // retune within the same instrument (re-colour only). - let _lastRenderInstrument = null; - function _applyChipMatch(chip, stateName) { - chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400'); - chip.classList.add(stateName === 'match' ? 'bg-emerald-500' - : stateName === 'retune' ? 'bg-amber-400' : 'bg-fb-mid'); - if (!chip.dataset.baseTitle) chip.dataset.baseTitle = chip.getAttribute('title') || ''; - chip.setAttribute('title', chip.dataset.baseTitle + (stateName === 'match' - ? ' — matches your tuning' : stateName === 'retune' ? ' — needs a retune' : '')); - } - async function decorateTuningChips(grid) { - if (!grid) return; - const cov = window._tunerAutoOpen && window._tunerAutoOpen.coverageReport; - const hasWT = window.feedBack && window.feedBack.workingTuning - && typeof window.feedBack.workingTuning.get === 'function'; - if (typeof cov !== 'function' || !hasWT) return; // feature-detect → no flags - const token = ++_tuningDecorToken; - const chips = grid.querySelectorAll('[data-tuning-chip][data-tuning-offsets]'); - for (const chip of chips) { - const offs = chip.getAttribute('data-tuning-offsets').split(',').map(Number); - if (!offs.length || offs.some((n) => !isFinite(n))) continue; - // Pass the instrument so coverage uses the right base pitches (bass vs guitar). - const arrangement = chip.dataset.tuningBass === '1' ? 'Bass' : 'Lead'; - let rep = null; - try { rep = await cov({ tuning: offs, stringCount: offs.length, arrangement: arrangement }); } catch (_) { rep = null; } - if (token !== _tuningDecorToken) return; // superseded by a re-paint / tuning change - if (rep) _applyChipMatch(chip, rep.covered ? 'match' : 'retune'); - } - } - - function songCard(song) { - const fav = song.favorite; - const key = cardKey(song); - // §7.1 display-chart switch (P5e): under a chart-intrinsic filter the - // server may attach `display_chart` — the member that MATCHES the - // filter when the representative doesn't. The card SHOWS and PLAYS - // that chart, while the row identity (sort keys, data-fn, the - // accuracy/heart anchor = the preferred chart) stays the rep's. - const shown = song.display_chart ? Object.assign({}, song, song.display_chart) : song; - // In select mode the checkbox occupies top-2 left-2, so shift the - // tuning chip right (left-9) to avoid overlapping it. - // Bass players see the bass chart's tuning (guitar fallback) — the card - // must agree with the facet/filter or the grid contradicts the pills. - const shownTuning = shownTuningName(shown); - const tuningLabel = (typeof window.displayTuningName === 'function') - ? window.displayTuningName(shownTuning) - : (shownTuning || ''); - let tuning = ''; - if (tuningLabel) { - const rawOffsets = (typeof window.parseRawTuningOffsets === 'function') - ? (window.parseRawTuningOffsets(shownTuningOffsets(shown)) - || window.parseRawTuningOffsets(shownTuning)) - : null; - const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets - && typeof window.displayTuningTargets === 'function') - ? window.displayTuningTargets(rawOffsets, { tuningName: tuningLabel }) - : ''; - // Mark a tuning we INFERRED from the guitar chart (this song has no - // bass arrangement) so a bass player isn't shown a borrowed tuning - // as if it were their part's. `~` keeps the chip compact; the title - // spells it out. - const inferred = shown.tuning_inferred === true; - const badgeTitle = (targetNotes - ? ('Custom Tuning: ' + targetNotes) - : tuningLabel) - + (inferred ? ' — from the guitar chart (no bass arrangement)' : ''); - const pos = 'absolute top-2 ' + (state.selectMode ? 'left-9' : 'left-2'); - // Tag the chip with its offsets so decorateTuningChips() can colour it - // green (matches your current tuning) / amber (needs a retune) after paint. - // Also flag a bass-only song (every arrangement is a bass part) so coverage - // scores its bass tuning against the bass base pitches, not guitar — otherwise - // a 4-string bass tuning read as guitar can false-match a guitar player. - const chipArrs = shown.arrangements || []; - // Bass either because the chip is SHOWING the bass chart's tuning - // (a bass player on a song that has one), or because every - // arrangement is a bass part. Checked via libInstrument() rather - // than comparing the two names — they are EQUAL for most songs, so - // a value comparison would flag a guitarist's chip as bass. - const chipIsBass = (libInstrument() === 'bass' && !!shown.bass_tuning_name) - || (chipArrs.length > 0 - && chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || ''))); - const matchAttr = (rawOffsets && rawOffsets.length) - ? ' data-tuning-chip data-tuning-offsets="' + esc(rawOffsets.join(',')) + '"' - + (chipIsBass ? ' data-tuning-bass="1"' : '') : ''; - if (targetNotes) { - tuning = '' - + esc('Custom Tuning') + '
' + esc(targetNotes) + '
'; - } else { - tuning = '' + esc(tuningLabel) + (inferred ? ' ~' : '') + ''; - } - } - // Display-only (pointer-events-none) so a click falls through to the - // card's data-v3-play handler, which owns the toggle — avoids double-toggle. - const checkbox = state.selectMode - ? '' - : ''; - const arrChips = arrChipsHtml(shown); - const chartsChip = chartsChipHtml(song); - // Plugin-contributed card actions placed 'inline' (in the hover action - // row) or 'overlay' (centered over the art). Menu-placed actions live in - // the ⋮ menu (openCardMenu); rendering these here means plugins using - // those placements are no longer silently dropped. No bundled action - // uses them, so for the stock library both strings are empty — the card - // renders exactly as before. - const reg = sm && sm.libraryCardActions; - const acts = (reg && typeof reg.list === 'function') ? reg.list(song) : []; - const actBtn = (a) => - ''; - const inlineBtns = acts.filter((a) => a.placement === 'inline').map(actBtn).join(''); - const overlayActs = acts.filter((a) => a.placement === 'overlay'); - 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(shown) + personalBadges(song) + enrichBadge(key, song.unmatched) + overlay + - '
' + - inlineBtns + - '' + - '' + - '' + - '
' + - '
' + esc(shown.title) + '
' + - // Artist line → the artist page (PR-B, entry point 2). The text - // block sits OUTSIDE the data-v3-play hitbox, so making it a - // button steals no play clicks. Same classes/line-height as the - // plain div (uniform card height is what makes the windowed - // grid's absolute-position math exact); non-local providers and - // the pages-off setting keep the original inert div. - ((state.provider === 'local' && song.artist && state.artistPagesEnabled !== false) - ? '' - : '
' + esc(song.artist) + '
') + - // 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). - '
' + chartsChip + arrChips + '
' + - '
'; - } - - // Per-card action menu, built from the ui.library-card-injection registry - // (core Edit/Retune + any plugin-registered actions). - let _closeCardMenu = null; // tears down the currently-open card menu + its document closer - function openCardMenu(cardEl, song, anchorBtn, pos) { - // Fully close any already-open menu first — removing just the DOM node - // (as before) would orphan its document-level click closer. - if (_closeCardMenu) _closeCardMenu(); - const reg = sm && sm.libraryCardActions; - // Only show actions intended for the overflow menu — actions placed - // 'inline'/'overlay' get their own affordances on the card (see songCard). - // Undefined placement defaults to the menu. - const items = (reg ? reg.list(song) : []).filter((a) => !a.placement || a.placement === 'menu'); - const menu = document.createElement('div'); - // pos (right-click) positions the menu at the pointer via `fixed`; - // the ⋮ button keeps the absolute top-right anchor. - menu.className = 'v3-card-menu z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm ' + (pos ? 'fixed' : 'absolute top-10 right-2'); - // Multi-chart entries (P5d): a grouped grid row carries chart_count + - // work_key (P5a annotation), so the menu knows inline whether this card - // stands for versions. Local library only — the work API is local. - const canCharts = state.provider === 'local' && song.work_key && song.chart_count >= 2; - // Play follows the DISPLAYED chart (see wireCards) — under an intrinsic - // filter that's the matching member, not the representative. - const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song; - const rows = [ - { id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(playTarget.filename)); } }, - ...(canCharts ? [ - { id: '__charts', label: 'Charts (' + song.chart_count + ')…' }, - { id: '__playver', label: 'Play version ▸' }, - ] : []), - // Undo for the drawer's "Split out" (P5e) — a split chart is its - // own singleton card (no ⚑ chip), so this is its only way back. - ...(state.provider === 'local' && song.is_split - ? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []), - { id: '__playlist', label: 'Add to playlist' }, - { id: '__save', label: 'Save for later' }, - // Artist page (PR-B, entry point 1) — local library only (the - // page reads the local DB) and gated on the Settings toggle. - ...(state.provider === 'local' && song.artist && state.artistPagesEnabled !== false - ? [{ id: '__artist', label: 'Go to artist' }] : []), - ...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })), - // Metadata + file actions (R2) — local library only (they all - // address the local DB / filesystem). Both openers (⋮ and - // right-click) share this list, so parity is structural. - ...(state.provider === 'local' && song.filename ? [ - { id: '__fixmatch', label: 'Fix metadata…' }, - { id: '__cover', label: 'Change cover…' }, - { id: '__refreshmeta', label: 'Refresh metadata' }, - { id: '__getinfo', label: 'Get info…' }, - { id: '__remove', label: 'Remove from library', destructive: true }, - ] : []), - ]; - menu.innerHTML = rows.map((r) => - '').join(''); - if (pos) { - // Right-click: position the menu at the pointer (fixed, viewport- - // relative), clamped so it never spills off the right/bottom edge. - document.body.appendChild(menu); - const x = Math.min(pos.x, window.innerWidth - menu.offsetWidth - 8); - const y = Math.min(pos.y, window.innerHeight - menu.offsetHeight - 8); - menu.style.left = Math.max(8, x) + 'px'; - menu.style.top = Math.max(8, y) + 'px'; - } else { - cardEl.appendChild(menu); - } - // Tear down BOTH the menu and its document-level closer together, so a - // menu-item click doesn't leave the closer attached (it would otherwise - // leak, retaining this menu's closures until the next document click). - const closer = (e) => { if (!menu.contains(e.target) && e.target !== anchorBtn) closeMenu(); }; - function closeMenu() { menu.remove(); document.removeEventListener('click', closer); if (_closeCardMenu === closeMenu) _closeCardMenu = null; } - _closeCardMenu = closeMenu; - menu.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async (e) => { - e.stopPropagation(); - const id = b.getAttribute('data-act'); - // 'Play version ▸' swaps THIS menu's rows for the work's charts — - // it must expand in place, so it's the one entry that doesn't close. - if (id === '__playver') { await _expandPlayVersions(menu, song, closeMenu); return; } - closeMenu(); - if (id === '__play') { playCard(playTarget); return; } - if (id === '__charts') { openChartsDrawer(song.work_key, song); return; } - if (id === '__unsplit') { - if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged(); - return; - } - if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; } - if (id === '__save') { if (window.v3Saved) await window.v3Saved.toggle(song.filename); return; } - if (id === '__artist') { openArtistPage(song.artist); return; } - // Per-chart metadata actions follow the DISPLAYED chart (playTarget), - // like Play — under an intrinsic filter that's the matching member, - // not the group representative. (__remove stays on `song`: it needs - // the group's work_key/chart_count and pre-ticks the shown chart.) - if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; } - if (id === '__cover') { - if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album }); - return; - } - if (id === '__refreshmeta') { - // Silent on success (hearing-safe, like the rest of the match - // layer) — the re-match trickles in through the normal pass. - await jsend('POST', '/api/enrichment/refresh/' + enc(playTarget.filename)); - return; - } - if (id === '__getinfo') { openGetInfo(playTarget); return; } - if (id === '__remove') { await removeSongsFlow(song); return; } - if (reg) await reg.run(id, song, { source: 'v3-songs' }); - })); - // Tree rows ride the (ungrouped) artists endpoint, so they don't carry - // chart_count/work_key — resolve the work lazily and slot a - // "Charts (N)…" entry into the still-open menu when versions exist. - // Grid rows already carry both (chart_count defined ⇒ no fetch). - if (state.provider === 'local' && song.chart_count === undefined && song.filename) { - jget('/api/chart/' + enc(song.filename) + '/work').then((w) => { - if (!w || !menu.isConnected) return; - const addEntry = (label, run) => { - const b = document.createElement('button'); - b.className = 'w-full text-left px-3 py-1.5 hover:bg-fb-card/60 text-fb-text'; - b.textContent = label; - b.addEventListener('click', (e) => { e.stopPropagation(); closeMenu(); run(); }); - menu.appendChild(b); - }; - if (w.chart_count >= 2 && w.work_key) { - addEntry('Charts (' + w.chart_count + ')…', () => openChartsDrawer(w.work_key, song)); - } - if (w.is_split) { - addEntry('Rejoin other versions', async () => { - if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged(); - }); - } - }); - } - setTimeout(() => document.addEventListener('click', closer), 0); - } - - // 'Play version ▸' (P5d): swap the ⋮ menu's rows for the work's charts; - // picking one plays that chart directly. A one-off alternate play — the - // keeper/headline doesn't move (stats record to the played chart's own - // filename), which is the design's "casual try ≠ deliberate adopt". - async function _expandPlayVersions(menu, song, closeMenu) { - const data = await jget('/api/work/' + enc(song.work_key) + '/charts'); - if (!data || !Array.isArray(data.charts) || !data.charts.length || !menu.isConnected) return; - menu.innerHTML = data.charts.map((c) => { - const tl = (typeof window.displayTuningName === 'function') - ? window.displayTuningName(c.tuning_name || c.tuning) : (c.tuning_name || ''); - return ''; - }).join(''); - menu.querySelectorAll('[data-ver]').forEach((vb) => vb.addEventListener('click', (e) => { - e.stopPropagation(); - const fn = vb.getAttribute('data-ver'); - closeMenu(); - _saveLibraryScrollSnapshot(); - if (window.playSong) window.playSong(enc(fn)); - })); - } - - // ── Remove from library (R2) ───────────────────────────────────────────-- - // On a single-chart song: confirm + delete, as the Details drawer does. - // On a multi-chart work: "remove the song" is ambiguous — a grouped card - // stands for several files — so an interstitial lists EVERY version for - // select/multi-select and deletes exactly what the user picked, one file - // or the whole set. - async function removeSongsFlow(song) { - let wk = song.work_key, count = song.chart_count; - if (count === undefined && song.filename) { - // Flat-mode grid / tree rows don't carry the group annotation. - const w = await jget('/api/chart/' + enc(song.filename) + '/work'); - if (w) { wk = w.work_key; count = w.chart_count; } - } - if (wk && count >= 2) { - const data = await jget('/api/work/' + enc(wk) + '/charts'); - const charts = (data && data.charts) || []; - if (charts.length >= 2) { openVersionRemoveModal(song, charts); return; } - } - if (!(await _confirmRemove(song.title || song.filename, 1))) return; - await _deleteFiles([song.filename]); - } - - async function _confirmRemove(label, n) { - const what = n === 1 ? '"' + label + '"' : n + ' versions of "' + label + '"'; - if (typeof window.uiConfirm === 'function') { - return window.uiConfirm({ - title: 'Remove from library?', - html: 'Remove ' + esc(what) + ' from your library?' + - '

This permanently deletes the file' + (n === 1 ? '' : 's') + ' from disk. This cannot be undone.

', - confirmText: 'Remove', cancelText: 'Cancel', danger: true, - }); - } - return window.confirm('Remove ' + what + ' from your library? This deletes the file' + (n === 1 ? '' : 's') + ' from disk.'); - } - - async function _deleteFiles(files) { - for (const fn of files) { - try { await fetch('/api/song/' + enc(fn), { method: 'DELETE' }); } catch (_) { /* keep going */ } - } - try { _groupChanged(); } catch (_) { try { reload(); } catch (_) { /* */ } } - } - - // The multi-version interstitial: a centred modal (the Tidy-up idiom) - // listing all charts of the work with checkboxes — the card's own chart - // pre-checked — so "delete" does exactly what the user means, whether - // that's one file or the batch. - function openVersionRemoveModal(song, charts) { - const sel = new Set([song.display_chart ? song.display_chart.filename : song.filename]); - const overlay = document.createElement('div'); - overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; - const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } }; - function done() { overlay.remove(); document.removeEventListener('keydown', onKey); } - document.addEventListener('keydown', onKey); - overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); }); - document.body.appendChild(overlay); - - function render() { - const rows = charts.map((c) => { - const tl = (typeof window.displayTuningName === 'function') - ? window.displayTuningName(c.tuning_name || c.tuning) : (c.tuning_name || ''); - const meta = [tl, (c.arrangements || []).map((a) => a.name).join('/'), c.format] - .filter(Boolean).join(' · '); - return ''; - }).join(''); - const n = sel.size; - overlay.innerHTML = - '
' + - '

Remove versions of “' + esc(song.title || '') + '”

' + - '

This song has ' + charts.length + ' charts. Tick the ones to remove — files are deleted from disk and this cannot be undone.

' + - '
' + rows + '
' + - '
' + - '' + - '' + - '
'; - overlay.querySelectorAll('[data-rm]').forEach((cb) => cb.addEventListener('change', () => { - const fn = cb.getAttribute('data-rm'); - if (cb.checked) sel.add(fn); else sel.delete(fn); - render(); - })); - overlay.querySelector('[data-rm-cancel]')?.addEventListener('click', done); - overlay.querySelector('[data-rm-go]')?.addEventListener('click', async () => { - if (!sel.size) return; - done(); - await _deleteFiles([...sel]); - }); - } - render(); - } - - // ── Get info (R2) ──────────────────────────────────────────────────────-- - // File location + pack contents + the match verdict, from - // GET /api/chart/{fn}/fileinfo. Paths and identity values are rendered - // with .fb-selectable so they stay copyable under the v3 no-select default. - function _fmtBytes(n) { - if (!Number.isFinite(n)) return ''; - const u = ['B', 'KB', 'MB', 'GB']; - let i = 0; - while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } - return (i ? n.toFixed(1) : n) + ' ' + u[i]; - } - - async function openGetInfo(song) { - const info = await jget('/api/chart/' + enc(song.filename) + '/fileinfo'); - if (!info) return; - const overlay = document.createElement('div'); - overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; - const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } }; - function done() { overlay.remove(); document.removeEventListener('keydown', onKey); } - document.addEventListener('keydown', onKey); - overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); }); - - const row = (label, value, selectable) => value - ? '
' + label + '' + - '' + esc(value) + '
' - : ''; - const m = info.manifest || {}; - const ident = m.identity || {}; - const identLine = Object.keys(ident).map((k) => - k + ': ' + (Array.isArray(ident[k]) ? ident[k].join(', ') : ident[k])).join(' · '); - const match = info.match || {}; - const matchLine = match.match_state === 'manual' ? 'Pinned by you' - : match.match_state === 'matched' ? ('Matched (' + (match.match_source || 'auto') + - (match.match_score != null ? ', ' + Math.round(match.match_score * 100) + '%' : '') + ')') - : match.match_state === 'review' ? 'Waiting for review' - : match.match_state === 'failed' ? 'Not matched' - : 'Not scanned yet'; - const contents = [ - (m.arrangements || []).length ? (m.arrangements.length + ' arrangement' + (m.arrangements.length === 1 ? '' : 's') + ' (' + m.arrangements.join(', ') + ')') : '', - (m.stems || []).length ? ('stems: ' + m.stems.join(', ')) : '', - m.has_cover ? 'cover art' : 'no cover art', - m.has_lyrics ? 'lyrics' : '', - ].filter(Boolean).join(' · '); - - overlay.innerHTML = - '
' + - '
' + - '

' + esc(song.title || info.filename) + '

' + - '
' + - '
' + - row('Location', info.path, true) + - row('Folder', info.folder, true) + - row('Format', info.format === 'sloppak' ? 'Feedpak' : info.format) + - row('Size', _fmtBytes(info.size)) + - row('Modified', info.mtime ? new Date(info.mtime * 1000).toLocaleString() : '') + - (info.manifest ? ( - '
' + - row('Contents', contents) + - row('Authors', (m.authors || []).filter(Boolean).join(', ')) + - row('Identity', identLine || 'no identity keys authored', !!identLine) - ) : '') + - '
' + - row('Match', matchLine) + - (match.canon_artist ? row('Canonical', [match.canon_artist, match.canon_title, match.canon_album, match.canon_year].filter(Boolean).join(' — '), true) : '') + - '
'; - document.body.appendChild(overlay); - overlay.querySelector('[data-gi-x]')?.addEventListener('click', done); - } - - // ── Charts drawer (P5d, design §7.1 UX-2/3) ──────────────────────────────── - // The single deep-management surface for a work's charts. A body-appended - // slide-in panel (the filter-drawer idiom; body-appended like the playlist - // picker so it opens from any view) listing every chart of the work as a - // radiogroup — the checked row is the keeper the grid card plays. Clicking - // an unchecked row (or Enter/Space on it) = Set as preferred, one tap; - // "Reset to auto pick" appears when the keeper is your explicit pick. - // Writes go through the work-charts API and the drawer re-renders from the - // response (there's no server-side library event bus — the drawer is its - // own refresh); the grid re-fetches because the representative may have - // flipped. Global mode only — the slot-scoped (curated-album) mode is P6; - // the per-row Split escape hatch is P5e. - let _chartsPrevFocus = null; // focus to restore when the drawer closes - - function _chartsDrawerEls() { - let ov = document.getElementById('v3-charts-overlay'); - let dr = document.getElementById('v3-charts-drawer'); - if (!ov) { - ov = document.createElement('div'); - ov.id = 'v3-charts-overlay'; - ov.className = 'fixed inset-0 bg-black/50 z-40 hidden'; - ov.addEventListener('click', closeChartsDrawer); - document.body.appendChild(ov); - } - if (!dr) { - dr = document.createElement('aside'); - dr.id = 'v3-charts-drawer'; - dr.className = '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'; - dr.setAttribute('role', 'dialog'); - dr.setAttribute('aria-modal', 'true'); - dr.setAttribute('aria-label', 'Charts of this song'); - // a11y: Escape closes; Tab is trapped inside the open drawer - // (aria-modal alone doesn't trap for keyboard users); ArrowUp/Down - // move focus between the chart rows (focus only — selection stays - // on Enter/Space, since a native-radio "arrow = select" would fire - // a preferred write on every keystroke). - dr.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { e.preventDefault(); closeChartsDrawer(); return; } - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - const rows = [...dr.querySelectorAll('[role="radio"]')]; - if (!rows.length) return; - const i = rows.indexOf(document.activeElement); - if (i === -1) return; - e.preventDefault(); - rows[(i + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length].focus(); - return; - } - if (e.key !== 'Tab') return; - const foci = dr.querySelectorAll('button, [tabindex]:not([tabindex="-1"])'); - if (!foci.length) return; - const first = foci[0], last = foci[foci.length - 1]; - if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } - else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } - }); - document.body.appendChild(dr); - } - return { ov: ov, dr: dr }; - } - - // One chart row. `checked` = the current keeper (pref or auto-pick). The - // filename line is deliberate: duplicate charts usually share title/artist, - // so the pack filename is often the only human-readable distinguisher. - function _chartRowHtml(c, data) { - const checked = c.is_representative; - const prefLabel = checked - ? (data.preferred_source === 'user' ? 'Preferred — your pick' : 'Preferred (auto)') - : ''; - const tuningLabel = (typeof window.displayTuningName === 'function') - ? window.displayTuningName(c.tuning_name || c.tuning) - : (c.tuning_name || ''); - const meta = [fmtLabel(c), tuningLabel, - (c.arrangements || []).map((a) => a.name).join('/'), - c.year ? String(c.year) : ''] - .filter(Boolean).join(' · '); - const acc = (typeof c.best_accuracy === 'number') - ? '' + Math.floor(c.best_accuracy * 100) + '%' - : 'not played'; - return ''; - } - - // A preferred/auto flip can change which chart the grid's card stands for — - // re-fetch the grid in place (scroll preserved; renderWindow refills from - // the current scrollTop). The tree lists every chart flat, so it's - // unaffected; rare curate action, so a full re-fetch is fine. - function _groupChanged() { - if (state.view === 'grid' && groupingActive()) loadGrid(true); - } - - function _renderChartsDrawer(data, opts) { - const els = _chartsDrawerEls(); - const dr = els.dr; - const head = data.charts.find((c) => c.is_representative) || data.charts[0]; - // Mastery-anchor heads-up (§7.1): shown once, ambiently, right after a - // switch — the headline may drop because history stays with each chart - // (motor mastery is arrangement-specific). Text only, no toast/sound. - const switchNote = (opts && opts.switched) - ? '
Practice history stays with each chart — your new pick starts from its own stats.
' - : ''; - dr.innerHTML = - '
' + - '
' + - '
' + - '

' + esc(head.title) + '

' + - '
' + esc(head.artist) + ' · ' + data.count + ' chart' + (data.count === 1 ? '' : 's') + '
' + - '
' + - '' + - '
' + - switchNote + - '
' + - data.charts.map((c) => _chartRowHtml(c, data)).join('') + - '
' + - (data.preferred_source === 'user' - ? '' - : '
Auto pick sticks with a chart you\'ve practised; otherwise most complete → newest. Tap a chart to pin your keeper.
') + - '
'; - - dr.querySelector('[data-charts-close]').addEventListener('click', closeChartsDrawer); - dr.querySelector('[data-charts-auto]')?.addEventListener('click', async () => { - const fresh = await jsend('DELETE', '/api/work/' + enc(data.work_key) + '/preferred'); - if (!fresh) return; - _renderChartsDrawer(fresh); - _groupChanged(); - dr.querySelector('[role="radio"][aria-checked="true"]')?.focus(); - }); - dr.querySelectorAll('[data-ch]').forEach((row) => { - const fn = row.getAttribute('data-ch'); - const setPreferred = async () => { - if (row.getAttribute('aria-checked') === 'true') return; // already the keeper - const fresh = await jsend('PUT', '/api/work/' + enc(data.work_key) + '/preferred', { filename: fn }); - if (!fresh) return; - _renderChartsDrawer(fresh, { switched: true }); - _groupChanged(); - dr.querySelector('[role="radio"][aria-checked="true"]')?.focus(); - }; - row.addEventListener('click', setPreferred); - row.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setPreferred(); } - }); - row.querySelector('[data-ch-play]')?.addEventListener('click', (e) => { - e.stopPropagation(); - closeChartsDrawer(); - _saveLibraryScrollSnapshot(); - if (window.playSong) window.playSong(enc(fn)); - }); - row.querySelector('[data-ch-pl]')?.addEventListener('click', (e) => { - e.stopPropagation(); - addFilenamesToPlaylist([fn]); - }); - row.querySelector('[data-ch-split]')?.addEventListener('click', async (e) => { - e.stopPropagation(); - const ok = await jsend('POST', '/api/chart/' + enc(fn) + '/split'); - if (!ok) return; - _groupChanged(); - // Re-read the ORIGINAL work — the split chart is gone from it - // (now its own card in the grid; its ⋮ menu offers the rejoin). - const fresh = await jget('/api/work/' + enc(data.work_key) + '/charts'); - if (fresh && Array.isArray(fresh.charts) && fresh.charts.length) _renderChartsDrawer(fresh); - else closeChartsDrawer(); - }); - }); - } - - async function openChartsDrawer(workKey, _song) { - if (!workKey) return; - const data = await jget('/api/work/' + enc(workKey) + '/charts'); - if (!data || !Array.isArray(data.charts) || !data.charts.length) return; - const els = _chartsDrawerEls(); - _chartsPrevFocus = document.activeElement; - _renderChartsDrawer(data); - els.ov.classList.remove('hidden'); - els.dr.classList.remove('translate-x-full'); - els.dr.querySelector('[role="radio"][aria-checked="true"]')?.focus(); - } - - function closeChartsDrawer() { - document.getElementById('v3-charts-overlay')?.classList.add('hidden'); - document.getElementById('v3-charts-drawer')?.classList.add('translate-x-full'); - if (_chartsPrevFocus && typeof _chartsPrevFocus.focus === 'function' && _chartsPrevFocus.isConnected) { - _chartsPrevFocus.focus(); - } - _chartsPrevFocus = null; - } - - // Global opener — the ⚑ chip routes through this, and other views/plugins - // (P2's details drawer, dashboards) can open the drawer without reaching - // into this module. - window.__fbOpenChartsDrawer = openChartsDrawer; - - function wireCards(scope) { - scope.querySelectorAll('[data-fn]').forEach((el) => { - if (el.dataset.wired) return; // don't double-bind on append/auto-fill - el.dataset.wired = '1'; - const fn = el.getAttribute('data-fn'); - const song = state.songsById[fn] || { filename: fn }; - // §7.1: when a chart-intrinsic filter attached a display_chart, - // the card SHOWS that member — so play actions target it too - // (its arrangement indices match the rendered chips). Identity - // actions (heart/save/playlist/menu registry) stay on the rep row. - const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song; - el.querySelectorAll('[data-v3-play]').forEach((pe) => pe.addEventListener('click', (e) => { - if (state.selectMode) { e.preventDefault(); toggleSelect(fn, el); return; } - playCard(playTarget); // local → play; unsynced remote → sync then play - })); - el.querySelector('[data-menu]')?.addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(el, song, e.currentTarget); }); - // Native right-click opens the same overflow menu at the pointer. - el.addEventListener('contextmenu', (e) => { e.preventDefault(); openCardMenu(el, song, null, { x: e.clientX, y: e.clientY }); }); - el.querySelectorAll('[data-arr]').forEach((ab) => ab.addEventListener('click', (e) => { - e.stopPropagation(); - const idx = ab.getAttribute('data-arr'); - playCard(playTarget, idx === '' ? undefined : Number(idx)); - })); - // ⚑ charts chip → the Charts drawer (P5d). Never a card play — the - // chip row sits outside [data-v3-play]; stopPropagation is - // belt-and-braces. - el.querySelector('[data-charts]')?.addEventListener('click', (e) => { - e.stopPropagation(); - openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song); - }); - // "No match" badge → straight into the Fix-metadata popup for this - // song (the batch → fix handoff). stopPropagation so it doesn't also - // trigger the card's play. Follows the displayed chart, like the menu. - el.querySelector('[data-meta-fix]')?.addEventListener('click', (e) => { - e.stopPropagation(); - if (window.__fbFixMatch) window.__fbFixMatch(playTarget); - }); - // Artist line → the artist page (PR-B). In select mode the grid's - // capture-phase toggle intercepts first, so selection still wins. - el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => { - e.stopPropagation(); - openArtistPage(song.artist); - }); - el.querySelector('[data-fav]')?.addEventListener('click', async (e) => { - e.stopPropagation(); - const btn = e.currentTarget; - try { - const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); - const d = await r.json(); - btn.textContent = d.favorite ? '♥' : '♡'; - btn.setAttribute('aria-pressed', d.favorite ? 'true' : 'false'); - // Swap exactly the idle colour this button was rendered with - // (grid = text-white, tree/List view = text-fb-textDim). The old - // hardcoded text-white toggle never removed the list view's - // text-fb-textDim, so the heart changed glyph but stayed dim - // (never turned red) until a re-search re-rendered the row. - const idle = btn.getAttribute('data-fav-idle') || 'text-white'; - btn.classList.toggle('text-fb-accent', d.favorite); - btn.classList.toggle(idle, !d.favorite); - song.favorite = d.favorite; // keep the model in sync for a re-render/recycle - } catch (err) { /* */ } - }); - el.querySelector('[data-save]')?.addEventListener('click', async (e) => { - e.stopPropagation(); - if (window.v3Saved) { const saved = await window.v3Saved.toggle(fn); e.currentTarget.classList.toggle('text-fb-primary', !!saved); } - }); - // Inline/overlay plugin card actions → run via the shared registry. - el.querySelectorAll('[data-act-card]').forEach((ab) => ab.addEventListener('click', async (e) => { - e.stopPropagation(); - const reg = sm && sm.libraryCardActions; - if (reg) await reg.run(ab.getAttribute('data-act-card'), song, { source: 'v3-songs' }); - })); - }); - } - - // ── Multi-select + batch actions ──────────────────────────────────────-- - function toggleSelect(fn, el) { - if (state.selected.has(fn)) state.selected.delete(fn); else state.selected.add(fn); - const on = state.selected.has(fn); - const cb = el.querySelector('[data-select]'); if (cb) cb.checked = on; - el.querySelector('[data-v3-play]')?.classList.toggle('ring-2', on); - el.querySelector('[data-v3-play]')?.classList.toggle('ring-fb-primary', on); - renderBatchBar(); - } - - // Bulletproof multi-select: in select mode a capture-phase click anywhere - // inside a [data-fn] row toggles the card and STOPS the event, so nothing (a - // per-card handler, a stray/legacy listener, an arrangement chip) can start - // playback. Attached ONCE to each persistent host (grid / tree / artist page) - // — their innerHTML is replaced on re-render but the host element survives, - // so a single bind never double-fires. Group headers / non-song chrome sit - // outside any [data-fn], so closest() is null and their native clicks pass - // through untouched. - function bindSelectGuard(hostEl) { - if (!hostEl) return; - hostEl.addEventListener('click', (e) => { - if (!state.selectMode) return; - const card = e.target.closest('[data-fn]'); - if (!card || !hostEl.contains(card)) return; - e.preventDefault(); - e.stopImmediatePropagation(); - toggleSelect(card.getAttribute('data-fn'), card); - }, true); - } - - function setSelectMode(on) { - state.selectMode = on; - if (!on) state.selected.clear(); - const btn = document.getElementById('v3-songs-select'); - if (btn) btn.className = btnCtrl + (on ? ' bg-fb-primary text-white' : ''); - reload(); // re-render cards with/without checkboxes - renderBatchBar(); - } - - function renderBatchBar() { - let bar = document.getElementById('v3-songs-batch'); - if (!state.selectMode || state.selected.size === 0) { if (bar) bar.remove(); return; } - if (!bar) { - bar = document.createElement('div'); - bar.id = 'v3-songs-batch'; - bar.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-fb-card border border-fb-border/60 rounded-full shadow-xl px-4 py-2'; - document.body.appendChild(bar); - } - bar.innerHTML = - '' + state.selected.size + ' selected' + - '' + - '' + - '' + - ''; - bar.querySelector('[data-batch="clear"]').addEventListener('click', () => { state.selected.clear(); reload(); renderBatchBar(); }); - bar.querySelector('[data-batch="saved"]').addEventListener('click', batchSave); - bar.querySelector('[data-batch="playlist"]').addEventListener('click', batchAddToPlaylist); - bar.querySelector('[data-batch="details"]').addEventListener('click', openBulkEdit); - } - - async function batchSave() { - const lists = (await jget('/api/playlists')) || []; - const saved = lists.find((p) => p.system_key === 'saved_for_later'); - let present = new Set(); - if (saved) { const pl = await jget('/api/playlists/' + saved.id); present = new Set(((pl && pl.songs) || []).map((s) => s.filename)); } - // Additive: only toggle (add) songs not already saved. - for (const fn of state.selected) { - if (!present.has(fn)) { - try { await fetch('/api/saved/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ } - } - } - finishBatch(); - } - - // Modern "Add to playlist" picker - replaces the old run-on numbered prompt - // ("1. Foo 2. Bar ...", which didn't scale past a couple of playlists). Shows a - // checkbox list of playlists with membership PRE-CHECK (a song already in a - // playlist shows checked; a multi-song selection shows the indeterminate box - // when only some are in), an inline "New playlist" row, and a search box once - // the list is long. Toggling a row adds/removes the given songs via the - // existing REST; only rows the user actually TOUCHED are changed. Resolves - // true if any change was applied, else null (cancelled / no-op). Shared by the - // per-card more-menu and the select-mode batch bar. - function openPlaylistPicker(fns) { - return new Promise((resolve) => { (async () => { - const lists = ((await jget('/api/playlists')) || []).filter((p) => !p.system_key); - // Pre-check membership: fetch each playlist's songs once (playlists are - // few, and this is a one-off on open, not a hot path). ALL selected in - // -> checked; SOME -> indeterminate. - const counts = await Promise.all(lists.map(async (p) => { - const pl = await jget('/api/playlists/' + p.id); - const has = new Set(((pl && pl.songs) || []).map((s) => s.filename)); - return fns.reduce((n, fn) => n + (has.has(fn) ? 1 : 0), 0); - })); - const rows = lists.map((p, i) => ({ - id: p.id, name: p.name, count: p.count || 0, - initial: counts[i] === fns.length ? 'all' : (counts[i] > 0 ? 'some' : 'none'), - checked: counts[i] === fns.length, touched: false, - })); - - const overlay = document.createElement('div'); - overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; - let query = ''; - const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(null); } }; - function done(applied) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(applied || null); } - document.addEventListener('keydown', onKey); - overlay.addEventListener('click', (e) => { if (e.target === overlay) done(null); }); - - const boxFor = (r) => r.touched ? (r.checked ? '☑' : '☐') - : (r.initial === 'all' ? '☑' : r.initial === 'some' ? '▣' : '☐'); - function rowHtml(r) { - return ''; - } - function listHtml() { - const q = query.trim().toLowerCase(); - const shown = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows; - if (!shown.length) return '

' + (rows.length ? 'No matches.' : 'No playlists yet - create one above.') + '

'; - return shown.map(rowHtml).join(''); - } - function bindRows() { - overlay.querySelectorAll('[data-pl]').forEach((b) => b.addEventListener('click', () => { - const r = rows.find((x) => String(x.id) === b.getAttribute('data-pl')); - if (!r) return; - r.touched = true; r.checked = !r.checked; - repaintList(); - })); - } - function repaintList() { const el = overlay.querySelector('[data-list]'); if (el) { el.innerHTML = listHtml(); bindRows(); } } - async function createNew() { - const inp = overlay.querySelector('[data-new]'); - const name = ((inp && inp.value) || '').trim(); - if (!name) return; - const created = await jsend('POST', '/api/playlists', { name }); - if (created && created.id) { - rows.unshift({ id: created.id, name: created.name || name, count: 0, initial: 'none', checked: true, touched: true }); - query = ''; paint(); - overlay.querySelector('[data-new]')?.focus(); - } - } - async function apply() { - // Act only where the final state differs from what's already stored. - const acts = rows.filter((r) => r.touched && ((r.checked && r.initial !== 'all') || (!r.checked && r.initial !== 'none'))); - for (const r of acts) { - for (const fn of fns) { - try { - if (r.checked) await fetch('/api/playlists/' + r.id + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); - else await fetch('/api/playlists/' + r.id + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' }); - } catch (e) { /* */ } - } - } - if (window.v3Playlists) { try { window.v3Playlists.refresh(); } catch (e) { /* */ } } - if (acts.length && window.fbNotify) { - try { window.fbNotify.show({ title: 'Playlists updated', message: 'Updated ' + acts.length + ' playlist' + (acts.length === 1 ? '' : 's'), icon: '🎵' }); } catch (e) { /* */ } - } - done(acts.length ? true : null); - } - function paint() { - overlay.innerHTML = - ''; - overlay.querySelector('[data-x]').addEventListener('click', () => done(null)); - overlay.querySelector('[data-done]').addEventListener('click', apply); - overlay.querySelector('[data-create]').addEventListener('click', createNew); - overlay.querySelector('[data-new]').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); createNew(); } }); - const s = overlay.querySelector('[data-search]'); - if (s) s.addEventListener('input', (e) => { query = e.target.value; repaintList(); }); - bindRows(); - } - - document.body.appendChild(overlay); - paint(); - (overlay.querySelector('[data-search]') || overlay.querySelector('[data-new]'))?.focus(); - })(); }); - } - - // Open the picker for the given song filenames. Shared by the select-mode - // batch bar and the per-card more-menu. Resolves truthy if a change was applied. - async function addFilenamesToPlaylist(filenames) { - const fns = Array.from(filenames || []); - if (!fns.length) return null; - return openPlaylistPicker(fns); - } - - async function batchAddToPlaylist() { - const pid = await addFilenamesToPlaylist(state.selected); - // Only tear down the multi-select when the add actually happened. A - // cancelled or failed picker returns null — preserve the selection (and - // skip the reload) so the user can retry, matching the pre-refactor - // behaviour where !ans / !pid returned early before finishBatch(). - if (pid) finishBatch(); - } - - function finishBatch() { - state.selected.clear(); - if (window.v3Playlists) { try { window.v3Playlists.refresh(); window.v3Playlists.refreshSaved(); } catch (e) { /* */ } } - reload(); renderBatchBar(); - } - - // Bulk personal-meta editor (P2) — apply-to-all over the current selection via - // the batch endpoint (one request). Additive + mixed-state safe: difficulty - // defaults to "Leave" (each song keeps its own value); tags ADD/REMOVE rather - // than replace, so a bulk action never silently wipes per-song data. Notes are - // inherently per-song, so they're deliberately not bulk-editable. - function openBulkEdit() { - const fns = [...state.selected]; - if (!fns.length) return; - // Tags present across the selection (from the embedded row payload) → one-tap - // removable chips, no extra fetch. - const present = new Set(); - fns.forEach((fn) => ((state.songsById[fn] && state.songsById[fn].tags) || []).forEach((t) => present.add(t))); - const bulk = { diff: 'keep', add: [], remove: new Set() }; - const norm = (raw) => String(raw || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 60); - - const overlay = document.createElement('div'); - overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; - const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } }; - function done() { overlay.remove(); document.removeEventListener('keydown', onKey); } - document.addEventListener('keydown', onKey); - overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); }); - document.body.appendChild(overlay); - - function render() { - const diffBtn = (val, label) => - ''; - const addChips = bulk.add.map((t) => '' + esc(t) + - '').join(''); - const rmChips = [...present].sort((a, b) => a.localeCompare(b)).map((t) => - '').join(''); - const nothing = bulk.diff === 'keep' && !bulk.add.length && !bulk.remove.size; - overlay.innerHTML = - '
' + - '
' + - '

Edit ' + fns.length + ' songs

' + - '
' + - - '
Difficulty (for you)
' + - '
' + diffBtn('keep', 'Leave') + diffBtn(1, '1') + diffBtn(2, '2') + diffBtn(3, '3') + diffBtn(4, '4') + diffBtn(5, '5') + diffBtn('clear', 'Clear') + '
' + - '
"Leave" keeps each song's own value; a number or Clear applies to all ' + fns.length + '.
' + - - '
Add tags to all
' + - '
' + (addChips || 'None') + '
' + - '
' + - '
' + - - (present.size ? '
Remove tags (present in the selection)
' + rmChips + '
' : '') + - - '
' + - '
' + - '
'; - - overlay.querySelectorAll('[data-bulk-x]').forEach((b) => b.addEventListener('click', done)); - overlay.querySelectorAll('[data-bd]').forEach((b) => b.addEventListener('click', () => { bulk.diff = b.getAttribute('data-bd'); render(); })); - overlay.querySelectorAll('[data-badd-rm]').forEach((b) => b.addEventListener('click', () => { const t = b.getAttribute('data-badd-rm'); const i = bulk.add.indexOf(t); if (i >= 0) bulk.add.splice(i, 1); render(); })); - overlay.querySelectorAll('[data-brm]').forEach((b) => b.addEventListener('click', () => { const t = b.getAttribute('data-brm'); if (bulk.remove.has(t)) bulk.remove.delete(t); else bulk.remove.add(t); render(); })); - const addInput = overlay.querySelector('[data-badd-input]'); - const addTag = () => { const t = norm(addInput && addInput.value); if (t && !bulk.add.includes(t)) { bulk.add.push(t); render(); overlay.querySelector('[data-badd-input]')?.focus(); } }; - overlay.querySelector('[data-badd-btn]')?.addEventListener('click', addTag); - addInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } }); - overlay.querySelector('[data-bulk-apply]')?.addEventListener('click', applyBulk); - } - - async function applyBulk() { - const body = { filenames: fns }; - if (bulk.diff === 'clear') body.set_difficulty = null; - else if (bulk.diff !== 'keep') body.set_difficulty = Number(bulk.diff); - if (bulk.add.length) body.add_tags = bulk.add; - if (bulk.remove.size) body.remove_tags = [...bulk.remove]; - if (!('set_difficulty' in body) && !body.add_tags && !body.remove_tags) { done(); return; } - // jsend returns null on HTTP error / network failure — don't tear down the - // selection or reload as if it worked. Surface the failure and let the user retry. - const res = await jsend('POST', '/api/songs/user-meta/batch', body); - if (!res) { - if (window.fbNotify) { try { window.fbNotify.show({ title: 'Bulk edit failed', message: 'Could not save your changes. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } } - return; - } - done(); - finishBatch(); - } - - render(); - overlay.querySelector('[data-badd-input]')?.focus(); - } - - async function jsend(method, url, body) { - 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 (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 - // 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 _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; - } - - // 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; - } - - 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 ''; - } - - // Signature of the card at absolute index i: real-card vs skeleton, plus the - // select-mode it was built under. A change here is the ONLY reason a recycled - // node must be rebuilt (a hole filled after a fetch, or select mode toggled) — - // otherwise the node is reused as-is across window slides. - function _cardSig(i) { - return (state.songs[i] ? 'r' : 's') + (state.selectMode ? '1' : '0'); - } - - function _buildCardNode(i) { - const s = state.songs[i]; - const tmp = document.createElement('div'); - tmp.innerHTML = s ? songCard(s) : _skeletonCard(); - const node = tmp.firstElementChild; - node.setAttribute('data-idx', String(i)); - node.setAttribute('data-sig', _cardSig(i)); - return node; - } - - // Reconcile the grid's children to exactly cover [start, end) in ascending - // index order, REUSING the card nodes that stay in-window. Sliding the window - // one row now mutates only the row that entered/left instead of tearing down + - // rebuilding (+ re-wiring) the whole ~60-card window every frame — that - // per-slide teardown was the main-thread stall behind the "library skips every - // so many scrolls, up or down" report (the stall buffers held-arrow key-repeats - // that then flush in a burst). wireCards()'s data-wired guard wires only the - // freshly-built nodes. - function _syncWindow(grid, start, end) { - // Pass 1: drop nodes that left the window, are untagged, or whose content - // signature is stale (skeleton→real, or select-mode toggled). What remains - // is a reusable, correctly-rendered subset in ascending DOM order. - for (const el of Array.from(grid.children)) { - const a = el.getAttribute('data-idx'); - const idx = a == null ? NaN : Number(a); - if (!(idx >= start && idx < end) || el.getAttribute('data-sig') !== _cardSig(idx)) { - el.remove(); - } - } - // Pass 2: walk [start, end) in order, reusing survivors and inserting new - // nodes into their correct slot; `ref` tracks the child expected next. - const existing = new Map(); - for (const el of grid.children) existing.set(Number(el.getAttribute('data-idx')), el); - let ref = grid.firstChild; - for (let i = start; i < end; i++) { - let node = existing.get(i); - if (!node) node = _buildCardNode(i); - if (node === ref) { - ref = ref.nextSibling; - } else { - grid.insertBefore(node, ref); - } - } - } - - // 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 }; - if (groupingActive()) extra.group = 1; - 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); - } - } - - // Empty-library dead-end card (launch polish): only for a genuinely empty - // LOCAL library — a search / filter / format narrowing that merely matched - // nothing keeps the plain blank grid (saying "empty" there would lie), and - // remote providers own their own emptiness. The inline grid-column style - // spans the card across the grid without a new Tailwind class. - function _emptyLibraryHtml() { - if (state.q || state.format || activeFilterCount() !== 0 || state.provider !== 'local') return ''; - return '
' + - '
Your library is empty
' + - '
Drop .sloppak files into your library folder, or use Upload above.
' + - '' + - '
'; - } - - 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 = _emptyLibraryHtml(); grid.style.top = '0px'; - state.winRange = { start: 0, end: 0 }; - if (grid.innerHTML) { - // The grid is absolutely positioned inside the sizer — give the - // sizer the card's height so it participates in layout. - sizer.style.height = grid.offsetHeight + 'px'; - grid.querySelector('[data-lib-empty-settings]')?.addEventListener('click', () => { - if (window.showScreen) window.showScreen('settings'); - }); - } - 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'; - _syncWindow(grid, start, end); // recycle in-window nodes; only the entering/leaving row rebuilds - wireCards(grid); - decorateTuningChips(grid); // colour tuning chips by working-tuning match (async, feature-detected) - 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') 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 ───────────────────────────────────────────── - // 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(); } - - 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. - // group must MATCH the grid's page fetches (see groupingActive) so the - // rail's per-letter counts sum to the same works total the sizer uses. - const railParams = { sort_letters: 1 }; - if (groupingActive()) railParams.group = 1; - const stats = await jget('/api/library/stats?' + queryParams(railParams).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; - // 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; } - const desc = state.sort.endsWith('-desc'); - const order = desc ? RAIL_BUCKETS.slice().reverse() : RAIL_BUCKETS; - // Roving tabindex: only the first present letter is in the tab order; - // the rest are reached with the arrow keys (see bindRailOnce). Avoids - // dumping up to 27 tab stops into the page. - let firstPresent = true; - rail.innerHTML = order.map((L) => { - const n = letters[L] || 0; - const present = n > 0; - const tabbable = present && firstPresent; - if (tabbable) firstPresent = false; - const name = L === '#' ? 'non-alphabetical' : L; - return ''; - }).join(''); - rail.classList.remove('hidden'); - bindRailOnce(); - } - - function _setRailActive(letter) { - railEl()?.querySelectorAll('.v3-azrail-letter').forEach((b) => { - b.classList.toggle('is-active', b.getAttribute('data-letter') === letter); - }); - } - function _showBubble(letter) { const b = railBubbleEl(); if (b) { b.textContent = letter; b.classList.remove('hidden'); } } - function _hideBubble() { railBubbleEl()?.classList.add('hidden'); } - - // 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; - // `smooth` animates the scroll (a discrete tap / keyboard jump). A drag-scrub - // passes `false` so each step snaps INSTANTLY: stacked smooth animations over - // the windowed grid lag and settle imprecisely, which is why a drag used to - // land somewhere other than the let-go letter. - async function jumpToLetter(letter, smooth = true) { - const grid = _gridEl(), sizer = _sizerEl(), main = document.getElementById('v3-main'); - if (!grid || !sizer || !main || state.view !== 'grid' || !letter) return; - _setRailActive(letter); - const myToken = ++_jumpToken; // a newer jump supersedes this one - 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 - } - 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 - const top = Math.max(0, sizerTop + targetRow * rowH - pad); - main.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' }); - requestWindowRender(); - } - - function bindRailOnce() { - const rail = railEl(); - if (!rail || rail._bound) return; - rail._bound = true; - let dragging = false, lastDrag = null, railBtns = []; - // Resolve the letter button under a viewport-Y from the per-drag cached - // list (avoids a querySelectorAll per pointermove), clamping past the - // top/bottom so a scrub off the ends still seeks the first/last letter. - const letterAtY = (y) => { - if (!railBtns.length) return null; - for (const el of railBtns) { const r = el.getBoundingClientRect(); if (y >= r.top && y <= r.bottom) return el; } - return y < railBtns[0].getBoundingClientRect().top ? railBtns[0] : railBtns[railBtns.length - 1]; - }; - // Seek to the letter under `y`. `smooth` on the initial press (a tap); - // instant during the scrub so the grid tracks the finger and RELEASE - // lands exactly on the let-go letter. - const seekToY = (y, smooth) => { - const el = letterAtY(y); - if (!el || el.disabled) return; - const L = el.getAttribute('data-letter'); - _showBubble(L); - if (L !== lastDrag) { lastDrag = L; jumpToLetter(L, smooth); } - }; - rail.addEventListener('pointerdown', (e) => { - if (e.button !== 0 || e.isPrimary === false) return; // primary tap only; ignore right/middle-click + secondary touches - const btn = e.target.closest('.v3-azrail-letter'); - if (!btn) return; - railBtns = [...rail.querySelectorAll('.v3-azrail-letter')]; - dragging = true; lastDrag = null; - // Capture so a vertical scrub keeps seeking even if the pointer drifts - // off the thin rail horizontally. - try { rail.setPointerCapture(e.pointerId); } catch (_) { /* */ } - // Drive the jump from here, NOT from the click event: pointer capture - // retargets the follow-up click to the rail (never a letter), so a - // captured tap's click can't resolve a letter and used to no-op - // ("clicked O, nothing happened"). preventDefault() suppresses the - // text-selection / focus-scroll default; we re-focus below for kbd. - e.preventDefault(); - try { btn.focus({ preventScroll: true }); } catch (_) { /* */ } - seekToY(e.clientY, true); // jump on press → a tap lands immediately - }); - rail.addEventListener('pointermove', (e) => { - if (!dragging) return; - seekToY(e.clientY, false); // instant tracking during the scrub - }); - const end = () => { dragging = false; railBtns = []; _hideBubble(); }; - rail.addEventListener('pointerup', end); - rail.addEventListener('pointercancel', end); - // Keyboard activation only. A pointer-driven click (detail >= 1) is - // retargeted to the rail by pointer capture and can't resolve a letter, - // so the pointer path owns taps; act here only on Enter/Space, whose - // synthesized click has detail === 0. - rail.addEventListener('click', (e) => { - if (e.detail !== 0) return; - const btn = e.target.closest('.v3-azrail-letter'); - if (!btn || btn.disabled) return; - jumpToLetter(btn.getAttribute('data-letter')); - }); - 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 - // rows, so its height is measured (and re-measured on resize) instead of - // hard-coded. - function positionToolbar() { - const topbar = document.getElementById('v3-topbar'); - const bar = document.getElementById('v3-songs-toolbar'); - if (!topbar || !bar) return; - bar.style.top = topbar.offsetHeight + 'px'; - } - function bindToolbarReflow() { - if (state.resizeBound) return; - const topbar = document.getElementById('v3-topbar'); - if (!topbar) return; - state.resizeBound = true; - // Observe the topbar itself: its height changes with viewport width AND - // when the song search is toggled in/out on screen changes. ResizeObserver - // fires once on observe(), so this also fixes up the initial position - // regardless of render() vs syncActive() ordering. - if (typeof ResizeObserver === 'function') { - new ResizeObserver(positionToolbar).observe(topbar); - } else { - window.addEventListener('resize', positionToolbar, { passive: true }); - } - } - - // ── Tree ──────────────────────────────────────────────────────────────── - // ── Albums (album-condensed browse; consumes /api/library/albums) ───────── - // Album cards -> click -> a track list (reusing /api/library?artist=&album=) - // with Play-album (feeds the play-queue). Respects the active drawer filters. - async function loadAlbums() { - const host = document.getElementById('v3-songs-albums'); - if (!host) return; - host.innerHTML = '

Loading…

'; - const data = await jget('/api/library/albums?' + queryParams().toString()); - const albums = (data && data.albums) || []; - if (!albums.length) { host.innerHTML = '

No albums match.

'; return; } - host.innerHTML = - '
' + - albums.map((a, i) => - '').join('') + '
'; - host.querySelectorAll('[data-album]').forEach((b) => b.addEventListener('click', () => { - const a = albums[Number(b.getAttribute('data-album'))]; - if (a) openAlbum(a); - })); - } - // `opts` (PR-B): the artist page reuses this album detail inside its own - // host with its own back label/target — { host, backLabel, onBack, - // ignoreFilters }. Call sites without opts are byte-for-byte the original - // albums-view flow. - async function openAlbum(a, opts) { - const host = (opts && opts.host) || document.getElementById('v3-songs-albums'); - if (!host) return; - const backLabel = (opts && opts.backLabel) || '← Albums'; - const onBack = (opts && opts.onBack) || (() => loadAlbums()); - host.innerHTML = '

Loading…

'; - // Normally honour the active drawer filters (like the album grid) but pin - // THIS album's artist/album and force track order — so the track list and - // Play-album never include songs the user filtered out. When opened FROM - // an artist page (ignoreFilters), drop the global filters entirely: the - // artist page is the artist's whole shelf, so its album view must show - // every track to match the page's counts — scoped only to artist+album. - const p = (opts && opts.ignoreFilters) - ? new URLSearchParams({ provider: state.provider, artist: a.artist, album: a.album, size: '300', sort: 'track' }) - : queryParams({ artist: a.artist, album: a.album, size: '300', sort: 'track' }, { catalog: true }); - const data = await jget('/api/library?' + p.toString()); - const songs = (data && data.songs) || []; - host.innerHTML = - '' + - '
' + - '

' + esc(a.album) + '

' + - '

' + esc(a.artist) + ' · ' + songs.length + ' track' + (songs.length === 1 ? '' : 's') + '

' + - (songs.length ? '' : '') + - '
' + - ''; - host.querySelector('[data-albums-back]')?.addEventListener('click', () => onBack()); - host.querySelector('[data-album-playall]')?.addEventListener('click', () => { - const files = songs.map((s) => s.filename).filter(Boolean); - if (!files.length) return; - if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: a.album }); - else if (typeof window.playSong === 'function') window.playSong(enc(files[0])); - }); - host.querySelectorAll('[data-album-track]').forEach((b) => b.addEventListener('click', () => { - const s = songs[Number(b.getAttribute('data-album-track'))]; - if (s && window.playSong) window.playSong(enc(s.filename)); - })); - } - - // One list-row of a song — shared by the tree view and the artist page's - // song list, so wireCards() gives both the same play/chips/fav/save/⋮ - // behaviour from one markup source. - function treeSongRowHtml(s) { - const k = cardKey(s); const fl = fmtLabel(s); const chips = arrChipsHtml(s); const sel = state.selected.has(k); - // Display-only checkbox (pointer-events-none); the row's - // capture-phase select handler (render()) owns the toggle. - const checkbox = state.selectMode - ? '' - : ''; - return ( - '
' + - checkbox + - '' + - '' + esc(s.title) + '' + - (chips ? '' : '') + - (fl ? '' + fl + '' : '') + - accuracyBadge(k, 'tree') + - // Same fav / save-for-later / overflow-menu cluster as the grid - // card. Always shown (like the arrangement chips), not hover- - // revealed. wireCards() binds all three for any [data-fn]. - '
' + - '' + - '' + - '' + - '
' + - '
'); - } - - // ── Artist page (PR-B, artist-pages launch charrette) ────────────────────── - // An in-place sub-render like openAlbum(): the artist "in your library" — a - // shelf plus your relationship to it, never a discography browser (locked - // position 1). Renders 100% from the local /page payload; the external - // links row is the one decorated extra, gated on the opt-in Settings toggle - // AND a MusicBrainz match, fetched lazily and cached server-side. Every - // count obeys the DENOMINATOR LAW (locked position 2): songs YOU OWN. - - function _artistHostEl() { return document.getElementById('v3-songs-artistpage'); } - - // ── Instrument-aware tuning (the bass-player tuning-filter report) ──────── - // A song's bass chart is often in a different tuning from its guitar chart, - // so the tuning facet/filter/sort and the card chip must speak for the - // instrument the player actually plays. The host's working-tuning - // capability already holds the live selection (seeded from /api/settings on - // boot, updated when the player switches) — read it rather than adding - // another settings fetch. `state.settingsInstrument` is the fallback for - // hosts where the capability isn't mounted. - // Three perspectives, matching `active_instrument_profile`: lead and rhythm - // guitar charts can be tuned differently too, so a rhythm player hits the - // same bug a bassist did. The PROFILE is the only three-valued source (the - // working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm), - // so it wins; the capability is the live fallback for hosts where the - // profile hasn't loaded. - const PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass']; - function libInstrument() { - if (PERSPECTIVES.indexOf(state.settingsProfile) >= 0) return state.settingsProfile; - try { - const wt = window.feedBack && window.feedBack.workingTuning; - if (wt && typeof wt.get === 'function') { - const cur = wt.get(); - if (cur && cur.instrument === 'bass') return 'bass'; - } - } catch (_) { /* capability absent/erroring — fall through to settings */ } - return state.settingsInstrument === 'bass' ? 'bass' : 'guitar-lead'; - } - - // "Playable without retuning" mode reads the player's CURRENT tuning from - // the working-tuning capability (the live session state the tuner writes), - // not a separate setting. No capability => we cannot know the current - // tuning, so the mode is unavailable rather than guessed. - function currentWorkingTuning() { - try { - const wt = window.feedBack && window.feedBack.workingTuning; - if (!wt || typeof wt.get !== 'function') return null; - const cur = wt.get(); - if (!cur || !Array.isArray(cur.offsets) || !cur.offsets.length) return null; - return cur; - } catch (_) { return null; } - } - - function playableAvailable() { return !!currentWorkingTuning(); } - - function applyPlayableParams(p, f) { - if (f.tuningMatch !== 'playable') return; - const cur = currentWorkingTuning(); - if (!cur) return; - p.set('tuning_match', 'playable'); - p.set('playable_offsets', cur.offsets.join(',')); - p.set('playable_instrument', cur.instrument === 'bass' ? 'bass' : 'guitar'); - p.set('playable_string_count', String(cur.stringCount || cur.offsets.length)); - } - - // Short human label for the perspective, for the facet/sort headers. - function libInstrumentLabel() { - const p = libInstrument(); - return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead'; - } - - // The column a row's tuning lives in for the active perspective. - function perspectiveTuningField() { - const p = libInstrument(); - return p === 'bass' ? 'bass_tuning_name' - : p === 'guitar-rhythm' ? 'rhythm_tuning_name' : ''; - } - - // The tuning a card should SHOW: bass players see the bass chart's tuning, - // falling back to the song (guitar-derived) tuning when the song has no - // bass arrangement — the common case, so the fallback is not an edge path. - function shownTuningName(song) { - const f = perspectiveTuningField(); - if (f && song[f]) return song[f]; - return song.tuning_name || song.tuning; - } - - function shownTuningOffsets(song) { - const f = perspectiveTuningField(); - if (f && song[f]) { - return song[f.replace('_name', '_offsets')] || song.tuning_offsets; - } - return song.tuning_offsets; - } - - // Sync the two Settings gates into module state (fire-and-forget — the - // cached flags gate entry-point rendering; openArtistPage re-checks). - function refreshArtistPageGates() { - return jget('/api/settings').then((cfg) => { - if (!cfg) return; - state.artistPagesEnabled = cfg.artist_pages_enabled !== false; - state.artistLinksEnabled = cfg.artist_external_links === true; - // Fallback instrument for hosts without the working-tuning capability. - state.settingsInstrument = cfg.instrument === 'bass' ? 'bass' : 'guitar'; - // The three-valued perspective source (lead / rhythm / bass). - state.settingsProfile = cfg.active_instrument_profile || ''; - }); - } - - // 2×2 mosaic of the artist's OWN album art — the playlist-cover grammar - // (#626 playlistCoverHtml) adapted to the page payload's art_urls. Never a - // broken-image tile: no art → a quiet glyph. - function artistMosaicHtml(arts) { - const box = 'w-32 h-32 sm:w-40 sm:h-40 shrink-0 rounded-xl overflow-hidden bg-fb-card'; - const img = (u) => ''; - if (!arts || !arts.length) return '
🎤
'; - if (arts.length < 4) return '
' + img(arts[0]) + '
'; - return '
' + arts.slice(0, 4).map(img).join('') + '
'; - } - - function _linkDomain(u) { - try { return new URL(u).hostname.replace(/^www\./, ''); } catch (_) { return ''; } - } - - // Toggle the browse hosts (grid/tree/albums/folder + home + rail) so the - // artist page can own the scroller, and back again on close. - function _setBrowseHostsHidden(hidden) { - if (hidden) { - ['v3-songs-gridsizer', 'v3-songs-tree', 'v3-songs-albums', 'lib-folder-tree', - 'v3-lib-home', 'v3-songs-azrail', 'v3-songs-azbubble'] - .forEach((id) => document.getElementById(id)?.classList.add('hidden')); - const fc = document.getElementById('lib-folder-controls'); - if (fc) fc.style.display = 'none'; - } else { - document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid'); - document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree'); - document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums'); - 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'; - refreshRail(); - updateLibraryHome(); - } - } - - // The one exported opener — every entry point (card ⋮ / right-click "Go to - // artist", the grid card's artist line, the Details drawer link, a - // similar-artist chip) funnels through here. - async function openArtistPage(artistName) { - const host = _artistHostEl(); - if (!host || !artistName) return; - if (state.provider !== 'local' || state.artistPagesEnabled === false) return; - const main = _getV3MainScroller(); - // Remember where browsing left off ONCE — chip-hopping between artist - // pages keeps the original return point. - if (!state.artistPage) state.artistReturnScroll = main ? main.scrollTop : 0; - state.artistPage = artistName; - _setBrowseHostsHidden(true); - host.classList.remove('hidden'); - host.innerHTML = '

Loading…

'; - _applyMainScrollTop(0); - const page = await jget('/api/artist/' + enc(artistName) + '/page'); - if (state.artistPage !== artistName) return; // superseded - if (!page) { closeArtistPage(); return; } - await renderArtistPage(page); - } - - function closeArtistPage() { - const host = _artistHostEl(); - if (host) { host.classList.add('hidden'); host.innerHTML = ''; } - if (!state.artistPage) return; - state.artistPage = null; - _setBrowseHostsHidden(false); - const top = state.artistReturnScroll; - state.artistReturnScroll = null; - _applyMainScrollTop(top || 0); - if (state.view === 'grid') requestWindowRender(); - } - - // reload() (any toolbar-driven change) leaves the sub-page without the - // scroll restore — the new state describes a fresh browse from the top. - function _dropArtistPageSilently() { - if (!state.artistPage) return; - state.artistPage = null; - state.artistReturnScroll = null; - const host = _artistHostEl(); - if (host) { host.classList.add('hidden'); host.innerHTML = ''; } - } - - async function renderArtistPage(page) { - const host = _artistHostEl(); - if (!host) return; - const me = state.artistPage; - const name = page.artist || me || ''; - // Songs list: page through /api/library with the artist filter (locked - // position 6 — query_page, keyset-safe; never the DISTINCT+OFFSET - // query_artists path). Unfiltered on purpose: the page is the artist's - // whole shelf, not the grid's current filter view. - const songs = []; - let p = 0, total = Infinity; - while (songs.length < total) { - const q = new URLSearchParams({ - provider: 'local', artist: name, sort: 'artist', - size: '100', page: String(p), - }); - const data = await jget('/api/library?' + q.toString()); - if (!data || !Array.isArray(data.songs)) break; - songs.push(...data.songs); - total = (data.total != null) ? data.total : songs.length; - if (!data.songs.length || p > 50) break; // safety: no progress / runaway - p++; - } - if (state.artistPage !== me || !host.isConnected) return; // superseded mid-fetch - songs.forEach((s) => { state.songsById[cardKey(s)] = s; }); - - const aliasLine = (page.variants || []).length - ? '
also shown as: ' + - page.variants.map((v) => esc(v.name) + ' ×' + v.count).join(' · ') + '
' - : ''; - // Provenance pill — only when the artist is actually matched (drawer/ - // Get-info grammar: say where the tidy names come from, ≤2 taps away). - const pill = page.mb_artist_id - ? '
Matched · MusicBrainz
' - : ''; - // Stats strip. DENOMINATOR LAW: every number is songs in YOUR library; - // the mastered segment is omitted entirely until one exists — - // invitational, never "0 mastered" (launch blind-spot #3). - const bits = [ - page.song_count + ' song' + (page.song_count === 1 ? '' : 's'), - page.album_count + ' album' + (page.album_count === 1 ? '' : 's'), - ]; - if (page.mastered_count > 0) bits.push(page.mastered_count + ' mastered'); - - const albumsHtml = (page.albums || []).length - ? '

Albums

' + - '
' + - page.albums.map((al, i) => - '').join('') + - '
' - : ''; - - const songsHtml = songs.length - ? '

Songs

' + - '
' + songs.map(treeSongRowHtml).join('') + '
' - : '

No songs by this artist are in your library.

'; - - // Similar in your library (locked position 3): genre co-occurrence over - // artists you already OWN — never an acquisition funnel. Empty → the - // whole module hides (never "Similar: none"). - const similarHtml = (page.similar || []).length - ? '

Similar in your library

' + - '
' + - page.similar.map((s) => - '').join('') + - '
' - : ''; - - host.innerHTML = - '' + - '
' + - artistMosaicHtml(page.art_urls) + - '
' + - '

' + esc(name) + '

' + - aliasLine + pill + - '

' + bits.join(' · ') + '

' + - '
' + - (songs.length - ? '' + - '' - : '') + - '' + - '
' + - '
' + - albumsHtml + - songsHtml + - similarHtml + - // External links land here (lazy fetch) — hidden until they exist. - '
'; - - host.querySelector('[data-ap-back]')?.addEventListener('click', closeArtistPage); - // Play all / Shuffle → the shared playQueue (same path as Play-album). - const startQueue = (shuffle) => { - let files = songs.map((s) => s.filename).filter(Boolean); - if (!files.length) return; - if (shuffle) { - files = files.slice(); - for (let i = files.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - const t = files[i]; files[i] = files[j]; files[j] = t; - } - } - _saveLibraryScrollSnapshot(); - if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: name }); - else if (typeof window.playSong === 'function') window.playSong(enc(files[0])); - }; - host.querySelector('[data-ap-playall]')?.addEventListener('click', () => startQueue(false)); - host.querySelector('[data-ap-shuffle]')?.addEventListener('click', () => startQueue(true)); - // Save as smart playlist (locked position 12): a rules-based - // collection over the existing machinery — a LIVING query that - // regenerates, never a completable checklist. - host.querySelector('[data-ap-smart]')?.addEventListener('click', async (e) => { - const btn = e.currentTarget; - const res = await jsend('POST', '/api/collections', { name: name, rules: { artist: name } }); - if (res && res.ok) { - btn.textContent = '✓ Saved'; - btn.disabled = true; - if (window.fbNotify) { - try { window.fbNotify.show({ title: 'Smart playlist saved', message: '“' + name + '” is now a source in the library picker', icon: '🎵' }); } catch (_) { /* */ } - } - } - }); - // Album cells reuse the album detail in place; back returns HERE. - host.querySelectorAll('[data-ap-album]').forEach((b) => b.addEventListener('click', () => { - const al = (page.albums || [])[Number(b.getAttribute('data-ap-album'))]; - if (!al) return; - openAlbum({ artist: name, album: al.name }, - { host: host, backLabel: '← ' + name, onBack: () => openArtistPage(name), ignoreFilters: true }); - })); - // Similar chips → that artist's page (the return point stays the - // original browse position — see openArtistPage). - host.querySelectorAll('[data-ap-similar]').forEach((b) => b.addEventListener('click', () => { - openArtistPage(b.getAttribute('data-ap-similar')); - })); - wireCards(host); - decorateTuningChips(host); // feature-detected; no-op without the capability - _fillArtistLinks(host, name, page); - } - - // External links row (locked position 4): whitelisted MB url-rels, opt-in - // via Settings, always the external browser, domain visible. Renders ONLY - // when the toggle is on AND the fetch yields links — otherwise the section - // simply never appears (empty modules hide). - async function _fillArtistLinks(host, name, page) { - if (!state.artistLinksEnabled || !page.mb_artist_id) return; - const slot = host.querySelector('[data-ap-links]'); - if (!slot) return; - const data = await jget('/api/artist/' + enc(name) + '/links'); - // slot.isConnected covers every superseded case — navigating away, a - // reload, or hopping to another artist all replace this DOM. - if (!data || !slot.isConnected) return; - const links = data.links || {}; - const items = []; - const push = (label, url) => { if (url) items.push({ label: label, url: url }); }; - push('Official site', links.official); - push('Tour dates', links.tour); - push('Videos', links.video); - (Array.isArray(links.social) ? links.social : []).forEach((u) => push('Social', u)); - push('Wikipedia', links.wikipedia); - if (!items.length) return; - slot.innerHTML = - '
' + - '
On the web · opens your browser
' + - '
' + - items.map((it) => - '' + - esc(it.label) + ' ↗ ' + esc(_linkDomain(it.url)) + '').join('') + - '
'; - } - - // Global opener — the drawer link, plugins, and other views reach the page - // without touching this module's internals. - window.__fbOpenArtistPage = openArtistPage; - - async function loadTree() { - const host = document.getElementById('v3-songs-tree'); - if (!host) return; - // The list view always groups artist -> album (query_artists has no - // free sort) — when the picked sort is something else, say so instead - // of silently ignoring it ("why does the sort do nothing here?"). - const _treeSortNote = railSortColumn() === 'artist' ? '' - : '

List view groups by artist — the selected sort applies to the card grid.

'; - // Capture expanded groups BEFORE the "Loading…" wipe below, so a reload - // (e.g. toggling select mode) restores them instead of collapsing all. - const openArtists = new Set( - [...host.querySelectorAll('details[open]')].map((d) => d.getAttribute('data-artist'))); - host.innerHTML = '

Loading…

'; - // Page through ALL artists — the endpoint clamps size to 100, so a - // single request would silently truncate libraries with >100 artists. - const artists = []; - let page = 0, total = Infinity; - while (artists.length < total) { - const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }).toString()); - if (!data || !Array.isArray(data.artists)) break; - artists.push(...data.artists); - total = (data.total_artists != null) ? data.total_artists : artists.length; - if (!data.artists.length || page > 1000) break; // safety: no progress / runaway guard - page++; - } - if (!artists.length) { host.innerHTML = '

Nothing here.

'; return; } - artists.forEach((a) => (a.albums || []).forEach((al) => (al.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; }))); - host.innerHTML = _treeSortNote + artists.map((a) => - '
' + - '' + esc(a.name) + '' + esc(a.song_count) + '' + - '
' + (a.albums || []).map((al) => - '
' + esc(al.name || 'Unknown') + '
' + - (al.songs || []).map(treeSongRowHtml).join('') + '
').join('') + '
').join(''); - wireCards(host); - } - - // ── Filter drawer ─────────────────────────────────────────────────────-- - function triState(list_has, list_lacks, value) { - if (list_has.includes(value)) return 'has'; - if (list_lacks.includes(value)) return 'lacks'; - return 'any'; - } - function cycleTri(hasArr, lacksArr, value) { - const s = triState(hasArr, lacksArr, value); - const rm = (a) => { const i = a.indexOf(value); if (i >= 0) a.splice(i, 1); }; - rm(hasArr); rm(lacksArr); - if (s === 'any') hasArr.push(value); - else if (s === 'has') lacksArr.push(value); - // 'lacks' → cycles back to any (already removed) - } - function triPill(group, value, label, st, title) { - const cls = st === 'has' ? 'bg-fb-good/30 text-fb-good border-fb-good/40' - : st === 'lacks' ? 'bg-fb-low/30 text-fb-low border-fb-low/40' - : 'bg-gray-800/50 text-fb-textDim border-gray-700'; - const mark = st === 'has' ? '✓ ' : st === 'lacks' ? '✕ ' : ''; - const tip = title ? ' title="' + esc(title) + '"' : ''; - return ''; - } - function renderDrawer() { - const d = document.getElementById('v3-songs-drawer'); - if (!d) return; - const f = state.filters; - d.innerHTML = - '
' + - '

Filters

' + - '
' + - section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) + - section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) + - section('Lyrics', ['', '1', '0'].map((v) => '').join('')) + - // Progress (mastery bands) — multi-select; server filters via song_stats. - section('Progress', [['mastered', 'Mastered'], ['in_progress', 'In progress'], ['not_started', 'Not started']].map((it) => '').join('')) + - // Match (P8) — the song's metadata-match lifecycle state, a triage - // facet for the enrichment layer. Session-only, like Progress. - section('Match', [['review', 'To review'], ['matched', 'Matched'], ['unmatched', 'Unmatched'], ['pending', 'Not scanned']].map((it) => '').join('')) + - // Genre facet — dynamic list from /api/library/genres (primary genre). - (state.genres && state.genres.length ? section('Genre', state.genres.map((g) => '').join('')) : '') + - // The facet header NAMES the perspective. Silent instrument-following - // is the original bug in a new place: the user must be able to tell - // which instrument these tunings describe. - section('Tuning (' + libInstrumentLabel() + ')', - // MODE toggle. Exact match answers "which tuning is this - // labelled"; Playable answers "will this cost me a retune" — - // which is what a player actually wants. Both are offered; - // exact stays the default so nothing changes unasked. - '
' - + [['exact', 'Exact tuning'], ['playable', 'Playable without retuning']].map((m) => { - const on = (f.tuningMatch || 'exact') === m[0]; - const dis = m[0] === 'playable' && !playableAvailable(); - return ''; - }).join('') - + '
' - + (f.tuningMatch === 'playable' - ? '
Charts you can play in your current tuning, no retune. Songs whose lowest string sits below yours are excluded.
' - : '') - + ((state.tuningNames || []).map((t) => { - // Filter on the server's grouping key (raw offsets for customs) - // so two "Custom Tuning" entries are distinct; show their target - // notes in the label so they're distinguishable. - const val = t.key || t.name; - let label = t.name; - if (t.name === 'Custom Tuning' && t.offsets - && typeof window.parseRawTuningOffsets === 'function' - && typeof window.displayTuningTargets === 'function') { - const offs = window.parseRawTuningOffsets(t.offsets); - const notes = offs ? window.displayTuningTargets(offs, { tuningName: t.name }) : ''; - if (notes) label = 'Custom · ' + notes; - } - // Be honest about the fallback: when some of a row's songs have - // no bass chart and are borrowing the guitar tuning, say so - // rather than presenting a borrowed tuning as a measured one. - const inf = t.inferred_count || 0; - const title = inf - ? inf + ' of ' + t.count + ' inferred from the guitar chart (no bass arrangement)' - : ''; - const countLabel = inf ? t.count + ', ' + inf + ' inferred' : String(t.count); - return triPill('tuning', val, label + ' (' + countLabel + ')', - f.tunings.includes(val) ? 'has' : 'any', title); - }).join('') || 'No tunings')) + - // Multi-chart grouping toggle (P5e) — a VIEW mode, not a filter - // (never counted in the badge, never saved into collection rules). - // Local provider only: it's the one that implements group=. - (state.provider === 'local' - ? section('Grouping', '') - : '') + - // Collections always replay against the LOCAL library, so only offer - // "save" when browsing local with a non-empty filter set. - (state.provider === 'local' && Object.keys(currentFilterRules()).length - ? '
' - : '') + - // Artist canonicalization (P4) — local library only (aliases apply to - // the local catalog). Opens the Tidy-up modal to merge "ACDC"/"AC/DC". - (state.provider === 'local' - ? '
' - : '') + - '
' + - '
'; - - d.querySelectorAll('[data-tri]').forEach((b) => b.addEventListener('click', () => { - const g = b.getAttribute('data-tri'), v = b.getAttribute('data-val'); - if (g === 'arr') cycleTri(f.arr_has, f.arr_lacks, v); - else if (g === 'stem') cycleTri(f.stem_has, f.stem_lacks, v); - else if (g === 'tuning') { const i = f.tunings.indexOf(v); if (i >= 0) f.tunings.splice(i, 1); else f.tunings.push(v); } - renderDrawer(); - })); - d.querySelectorAll('[data-tuning-match]').forEach((b) => b.addEventListener('click', () => { - if (b.disabled) return; - f.tuningMatch = b.getAttribute('data-tuning-match'); - renderDrawer(); - })); - d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); })); - d.querySelectorAll('[data-mastery]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-mastery'); const i = f.mastery.indexOf(v); if (i >= 0) f.mastery.splice(i, 1); else f.mastery.push(v); renderDrawer(); })); - d.querySelector('[data-grouping]')?.addEventListener('click', () => { - state.grouping = !(state.grouping !== false); - renderDrawer(); - reload(); // re-fetches grid + rail with/without group=1, saves prefs - }); - d.querySelectorAll('[data-match]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-match'); const i = f.match.indexOf(v); if (i >= 0) f.match.splice(i, 1); else f.match.push(v); renderDrawer(); })); - d.querySelectorAll('[data-genre]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-genre'); const i = f.genre.indexOf(v); if (i >= 0) f.genre.splice(i, 1); else f.genre.push(v); renderDrawer(); })); - d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection); - d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp); - d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer); - d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => { - state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' }; - state.artist = ''; - state.album = ''; - renderDrawer(); - await loadArtistCatalog(); - refreshArtistAlbumSelects(); - reload(); - }); - d.querySelector('[data-drawer-apply]')?.addEventListener('click', async () => { - closeDrawer(); - await loadArtistCatalog(); - refreshArtistAlbumSelects(); - reload(); - }); - } - function section(label, inner) { - return '
' + label + '
' + inner + '
'; - } - function openDrawer() { renderDrawer(); document.getElementById('v3-songs-drawer')?.classList.remove('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.remove('hidden'); } - function closeDrawer() { document.getElementById('v3-songs-drawer')?.classList.add('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.add('hidden'); updateFilterBadge(); } - function updateFilterBadge() { const b = document.getElementById('v3-songs-filter-count'); if (b) { const n = activeFilterCount(); b.textContent = n; b.classList.toggle('hidden', n === 0); } } - - // ── Song Details drawer (P2) ───────────────────────────────────────────-- - // Evolves the legacy edit-metadata modal into a v3 slide-in drawer that - // unifies catalog identity (writes back into the feedpak FILE), the personal - // practice layer (difficulty / tags / notes — local, never shared), the like - // heart, and remove-from-library. Reuses the filter-drawer slide idiom - // (fixed-right panel + overlay + translate-x-full) but is built on demand and - // body-appended, so it opens from any card context. The Charts drawer (P5d) - // reuses this pattern. - const DIFF_LABELS = ['', 'Very easy', 'Easy', 'Medium', 'Hard', 'Very hard']; - let _detailsEls = null; // {overlay, drawer, opener, onKey} while open - - function closeDetails() { - if (!_detailsEls) return; - const { overlay, drawer, opener, onKey } = _detailsEls; - _detailsEls = null; - document.removeEventListener('keydown', onKey); - drawer.classList.add('translate-x-full'); - overlay.classList.add('opacity-0'); - setTimeout(() => { drawer.remove(); overlay.remove(); }, 200); - if (opener && document.body.contains(opener)) { try { opener.focus({ preventScroll: true }); } catch (_) { /* */ } } - } - - async function openDetails(song) { - if (!song || !song.filename) return; - closeDetails(); - const fn = song.filename; - const opener = document.activeElement; - // Notes aren't in the grid row payload; difficulty/tags are, but re-read - // for authority (another tab / a bulk edit may have changed them). - let meta = { user_difficulty: (song.user_difficulty != null ? song.user_difficulty : null), notes: '', tags: song.tags || [] }; - try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ } - let vocab = []; - try { const r = await fetch('/api/tags'); if (r.ok) vocab = (await r.json()).tags || []; } catch (_) { /* */ } - // Match provenance (launch polish): the drawer names what this chart - // matched, so a silently-wrong first match is visible where the - // metadata lives. 404 (no row yet) / offline → no line. - let enrich = null; - try { const r = await fetch('/api/enrichment/song/' + enc(fn)); if (r.ok) enrich = await r.json(); } catch (_) { /* offline → no provenance line */ } - if (_detailsEls) closeDetails(); // a concurrent open resolved first - - const st = { - t: song.title || '', a: song.artist || '', al: song.album || '', y: song.year != null ? String(song.year) : '', - diff: (meta.user_difficulty != null ? meta.user_difficulty : null), - notes: meta.notes || '', tags: (meta.tags || []).slice(), - fav: !!song.favorite, artDataUrl: null, - gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys - enrich: enrich, // match provenance for the Identity section - }; - - const overlay = document.createElement('div'); - overlay.className = 'fixed inset-0 bg-black/50 z-[60] opacity-0 transition-opacity duration-200'; - const drawer = document.createElement('aside'); - drawer.id = 'v3-song-details-drawer'; - drawer.className = 'fixed top-0 right-0 h-full w-full sm:w-[26rem] bg-fb-sidebar border-l border-fb-border/50 z-[61] transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll'; - drawer.setAttribute('role', 'dialog'); - drawer.setAttribute('aria-modal', 'true'); - drawer.setAttribute('aria-label', 'Song details'); - document.body.appendChild(overlay); - document.body.appendChild(drawer); - - const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeDetails(); } }; - document.addEventListener('keydown', onKey); - overlay.addEventListener('click', closeDetails); - _detailsEls = { overlay, drawer, opener, onKey }; - - const render = () => { drawer.innerHTML = detailsHtml(song, st, vocab); wireDetails(drawer, song, st, vocab, render); }; - render(); - requestAnimationFrame(() => { drawer.classList.remove('translate-x-full'); overlay.classList.remove('opacity-0'); }); - if (window._trapFocusInModal) { try { window._trapFocusInModal(drawer); } catch (_) { /* */ } } - const first = drawer.querySelector('#det-title'); - if (first) { try { first.focus({ preventScroll: true }); const n = first.value.length; first.setSelectionRange(n, n); } catch (_) { /* */ } } - } - - // Gap-fill (R4a) block inside the drawer's Identity section: preview → - // per-key confirm → written. Adds ABSENT keys only; the server re-checks - // under its io lock, so this UI can never replace an author-set value. - const GAP_KEY_LABELS = { album: 'Album', year: 'Year', genres: 'Genres', mbid: 'MusicBrainz ID', isrc: 'ISRC' }; - function gapFillHtml(st) { - const g = st.gap; - if (!g) return ''; - if (g.loading) return '
Checking the file…
'; - if (g.written) { - const names = Object.keys(g.written).map((k) => GAP_KEY_LABELS[k] || k).join(', '); - return '
✓ Added to file: ' + esc(names) + '
'; - } - if (!g.eligible) { - const why = { - 'not-sloppak': 'Only feedpak songs can be written to.', - 'no-match': 'No confirmed match yet — nothing verified to write.', - 'review': 'This song’s match is waiting for review — confirm it first.', - 'nothing-missing': 'Nothing missing — the file already has all of this.', - }[g.reason] || 'Could not check the file. Try again.'; - return '
' + esc(why) + '
'; - } - const rows = (g.missing || []).map((m) => { - const val = Array.isArray(m.value) ? m.value.join(', ') : String(m.value); - return ''; - }).join(''); - return '
' + - '
Write to file
' + rows + - '
Only adds what’s missing — nothing already in the file is changed. A backup (.bak) is kept beside the file.
' + - '
' + - '
'; - } - - // Match-provenance line under the Identity fields (launch polish): names - // the canonical identity this chart matched — the invisible-first-wrong- - // match fix — with the same Fix-match escape hatch the card menu offers. - // Only for settled matches; pending/review/failed rows stay silent here - // (the review chip / match facet own those states). - function provenanceHtml(st) { - const e = st.enrich; - if (!e || (e.match_state !== 'matched' && e.match_state !== 'manual')) return ''; - const who = [e.canon_artist, e.canon_title].filter(Boolean).join(' — '); - if (!who) return ''; - const src = e.match_state === 'manual' ? 'your pick' : 'MusicBrainz'; - return '
' + - 'Matched: ' + esc(who) + ' (' + esc(src) + ')' + - '
'; - } - - function detailsHtml(song, st, vocab) { - const art = st.artDataUrl || artUrl(song); - const diffBtns = [1, 2, 3, 4, 5].map((n) => - '').join(''); - const applied = new Set(st.tags); - const tagChips = st.tags.length - ? st.tags.map((t) => '' + esc(t) + - '').join('') - : 'No tags yet'; - const suggest = (vocab || []).filter((v) => !applied.has(v.tag)).slice(0, 8).map((v) => - '').join(''); - const field = (id, label, val) => - '
' + - '
'; - return '
' + - '

Song details

' + - '
' + - - '
' + - '
' + - '' + - '
Change
' + - '
' + - '
' + esc(song.title || song.filename) + '
' + - '
' + - 'a like, not a rating
' + - - // Identity — writes back into the feedpak FILE - '
Identity
' + - 'From pack
' + - field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + - // Artist page (PR-B, entry point 3) — a small jump-off next to the - // Artist field; local library + pages-toggle gated like the others. - ((state.provider === 'local' && (st.a || song.artist) && state.artistPagesEnabled !== false) - ? '' - : '') + - field('det-album', 'Album', st.al) + - '
' + - provenanceHtml(st) + - '
' + gapFillHtml(st) + '
' + - - // Personal practice layer — local, never shared - '
Your practice · stays on this device
' + - '
' + - '
' + - '
' + diffBtns + '' + esc(st.diff ? DIFF_LABELS[st.diff] : 'Not set') + '
' + - '
' + - '
' + tagChips + '
' + - '
' + - '
' + - (suggest ? '
' + suggest + '
' : '') + '
' + - '
' + - '
' + - - '
' + - '
' + - '
' + - '
'; - } - - function wireDetails(drawer, song, st, vocab, render) { - const $ = (s) => drawer.querySelector(s); - drawer.querySelectorAll('[data-det-close]').forEach((b) => b.addEventListener('click', closeDetails)); - // Catalog inputs update state without a re-render (keep caret / focus). - const bindInput = (id, key) => { const el = $('#' + id); if (el) el.addEventListener('input', () => { st[key] = el.value; }); }; - bindInput('det-title', 't'); bindInput('det-artist', 'a'); bindInput('det-album', 'al'); bindInput('det-year', 'y'); - const notes = $('#det-notes'); if (notes) notes.addEventListener('input', () => { st.notes = notes.value; }); - - drawer.querySelectorAll('[data-diff]').forEach((b) => b.addEventListener('click', () => { - const n = Number(b.getAttribute('data-diff')); - st.diff = (st.diff === n ? null : n); // click the active one to unset - render(); - })); - $('[data-diff-clear]')?.addEventListener('click', () => { st.diff = null; render(); }); - - drawer.querySelectorAll('[data-tag-rm]').forEach((b) => b.addEventListener('click', () => { - const t = b.getAttribute('data-tag-rm'); const i = st.tags.indexOf(t); if (i >= 0) st.tags.splice(i, 1); render(); - })); - drawer.querySelectorAll('[data-tag-add]').forEach((b) => b.addEventListener('click', () => { if (addDetailTag(st, b.getAttribute('data-tag-add'))) render(); })); - const addFromInput = () => { const el = $('#det-tag-input'); if (!el) return; if (addDetailTag(st, el.value)) { render(); drawer.querySelector('#det-tag-input')?.focus(); } }; - $('[data-tag-addbtn]')?.addEventListener('click', addFromInput); - $('#det-tag-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addFromInput(); } }); - - const artWrap = $('[data-det-art]'); const artFile = $('#det-art-file'); - if (artWrap && artFile) { - // Art click opens the cover PICKER (PR-C) — the old direct file - // dialog lives on inside it as the Upload tile. The picker applies - // immediately (its own routes + refresh), so it bypasses the - // drawer's Save; the file-input path below stays as the fallback - // when image-picker.js isn't loaded. - artWrap.addEventListener('click', () => { - if (window.__fbOpenImagePicker) { - window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album }); - } else { - artFile.click(); - } - }); - artFile.addEventListener('change', () => { - const f = artFile.files && artFile.files[0]; if (!f) return; - const rd = new FileReader(); - rd.onload = (e) => { st.artDataUrl = e.target.result; const img = $('#det-art-preview'); if (img) { img.src = st.artDataUrl; img.style.visibility = 'visible'; } }; - rd.readAsDataURL(f); - }); - } - - $('[data-det-fav]')?.addEventListener('click', async (e) => { - try { - const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: song.filename }) }); - const d = await r.json(); st.fav = !!d.favorite; - const btn = e.currentTarget; btn.textContent = st.fav ? '♥ Liked' : '♡ Like'; - btn.classList.toggle('text-fb-accent', st.fav); btn.classList.toggle('text-fb-textDim', !st.fav); - _patchCardFav(song.filename, st.fav); - } catch (_) { /* */ } - }); - - $('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st)); - $('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song)); - // Fix match → the exact flow the card ⋮ menu uses (match-review.js). - // The drawer closes first: the match modal sits below the drawer's - // z-index, and the fix supersedes the edit anyway. - $('[data-det-fixmatch]')?.addEventListener('click', () => { - closeDetails(); - if (window.__fbFixMatch) window.__fbFixMatch(song); - }); - // "View artist page →" — uses the field's CURRENT text (an in-progress - // rename still lands on the right page once saved; unsaved text simply - // canonicalizes server-side), falling back to the row's artist. - $('[data-det-artist-page]')?.addEventListener('click', () => { - const a = (st.a || '').trim() || song.artist || ''; - if (!a) return; - closeDetails(); - openArtistPage(a); - }); - - // Gap-fill (R4a): user-initiated write of CONFIRMED missing info into - // the pack file. The server recomputes proposals under its io lock, so - // a key that gained an author value since the preview is skipped. - $('[data-gapfill-check]')?.addEventListener('click', async () => { - st.gap = { loading: true }; render(); - let d = null; - try { const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill'); if (r.ok) d = await r.json(); } catch (_) { /* offline */ } - st.gap = d || { eligible: false, reason: 'error' }; - st.gapSel = new Set(((d && d.missing) || []).map((m) => m.key)); - render(); - }); - drawer.querySelectorAll('[data-gapfill-key]').forEach((cb) => cb.addEventListener('change', () => { - const k = cb.getAttribute('data-gapfill-key'); - if (!st.gapSel) st.gapSel = new Set(); - if (cb.checked) st.gapSel.add(k); else st.gapSel.delete(k); - })); - $('[data-gapfill-cancel]')?.addEventListener('click', () => { st.gap = null; render(); }); - $('[data-gapfill-write]')?.addEventListener('click', async () => { - const keys = st.gapSel ? Array.from(st.gapSel) : []; - if (!keys.length) return; - let d = null, ok = false; - try { - const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys }) }); - ok = r.ok; d = await r.json(); - } catch (_) { /* offline */ } - if (!ok || !d || !d.written) { - if (window.fbNotify) { try { window.fbNotify.show({ title: 'Write failed', message: 'Could not write to the file. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } } - st.gap = null; render(); return; - } - // Reflect what landed in the open drawer (and keep saveDetails' - // changed-detection honest by updating both sides), then refresh - // the grid quietly. - if (d.written.album != null) { st.al = String(d.written.album); song.album = st.al; } - if (d.written.year != null) { st.y = String(d.written.year); song.year = d.written.year; } - st.gap = { written: d.written }; render(); - try { reload(); } catch (_) { /* not on the songs grid */ } - }); - } - - // Normalize + append a tag to the drawer's working set (mirrors the server's - // _normalize_tag). Returns whether it was actually added (new + non-blank). - function addDetailTag(st, raw) { - const t = String(raw || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 60); - if (!t || st.tags.includes(t)) return false; - st.tags.push(t); st.tags.sort((a, b) => a.localeCompare(b)); return true; - } - - // Keep a rendered card's heart in sync when the drawer toggles the like (the - // heart is instant, not part of Save — matches the on-card heart). - function _patchCardFav(fn, fav) { - const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn; - document.querySelectorAll('[data-fn="' + sel + '"] [data-fav]').forEach((btn) => { - btn.textContent = fav ? '♥' : '♡'; - btn.setAttribute('aria-pressed', fav ? 'true' : 'false'); - // Swap exactly the idle colour this heart was rendered with (grid = - // text-white, tree/List view = text-fb-textDim) — mirrors wireCards. - // A hardcoded text-white toggle would leave List-View rows' text-fb-textDim - // in place, so the heart changed glyph but stayed dim (never turned red). - const idle = btn.getAttribute('data-fav-idle') || 'text-white'; - btn.classList.toggle('text-fb-accent', fav); btn.classList.toggle(idle, !fav); - }); - } - - async function saveDetails(song, st) { - const fn = song.filename; - const catChanged = st.t.trim() !== (song.title || '') || st.a.trim() !== (song.artist || '') - || st.al.trim() !== (song.album || '') || String(st.y).trim() !== (song.year != null ? String(song.year) : ''); - const ops = []; - if (catChanged) ops.push(fetch('/api/song/' + enc(fn) + '/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: st.t.trim(), artist: st.a.trim(), album: st.al.trim(), year: String(st.y).trim() }) })); - ops.push(fetch('/api/song/' + enc(fn) + '/user-meta', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_difficulty: st.diff, notes: st.notes, tags: st.tags }) })); - if (st.artDataUrl) ops.push(fetch('/api/song/' + enc(fn) + '/art/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: st.artDataUrl }) })); - // Fire the writes concurrently but know each outcome — a failed write must not - // look like a save. allSettled keeps partial results; treat a rejection OR a - // non-ok HTTP response as failure and keep the drawer open so the user can retry. - const results = await Promise.allSettled(ops); - const failed = results.some((r) => r.status === 'rejected' || (r.value && r.value.ok === false)); - if (failed) { - if (window.fbNotify) { try { window.fbNotify.show({ title: 'Save failed', message: 'Could not save your changes. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } } - return; - } - closeDetails(); - try { reload(); } catch (_) { /* not on the songs grid */ } - } - - async function removeFromLibrary(song) { - const title = song.title || song.filename; - let ok; - if (window._confirmDialog) { - ok = await window._confirmDialog({ - title: 'Remove from library?', - body: '

Remove ' + esc(title) + ' from your library?

' + - '

This permanently deletes the file from disk. This cannot be undone.

', - confirmText: 'Remove', cancelText: 'Cancel', danger: true, - }); - } else { ok = window.confirm('Remove "' + title + '" from your library? This deletes the file from disk.'); } - if (!ok) return; - try { const r = await fetch('/api/song/' + enc(song.filename), { method: 'DELETE' }); if (!r.ok) return; } catch (_) { return; } - closeDetails(); - try { reload(); } catch (_) { /* */ } - } - - // Expose the opener so the core `edit-metadata` card action opens this drawer - // (it falls back to the legacy modal where this isn't defined). - window.__fbOpenSongDetails = openDetails; - - // ── Artist Tidy-up (P4) ────────────────────────────────────────────────-- - // Merge messy artist variants ("ACDC" + "AC/DC") into one canonical name. - // Aliases apply AT DISPLAY only (no file rewrite); the dropdown/tree/grid pick - // up the change on the next load. Self-contained centered modal. - function openArtistTidyUp() { - const st = { raw: [], aliases: [], sel: new Set(), query: '', mergeInto: '', mergeTouched: false, busy: false }; - - const overlay = document.createElement('div'); - overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; - const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } }; - function done() { overlay.remove(); document.removeEventListener('keydown', onKey); } - document.addEventListener('keydown', onKey); - overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); }); - document.body.appendChild(overlay); - - async function refresh() { - const [raw, aliases] = await Promise.all([jget('/api/artists/raw'), jget('/api/artist-aliases')]); - st.raw = (raw && raw.artists) || []; - st.aliases = (aliases && aliases.aliases) || []; - render(); - } - - // Default canonical = the highest-count selected variant (the one most of - // your library already uses), unless the user typed their own. - function defaultCanonical() { - let best = '', bestC = -1; - st.raw.forEach((a) => { if (st.sel.has(a.name) && a.count > bestC) { bestC = a.count; best = a.name; } }); - return best; - } - - function render() { - if (!st.mergeTouched) st.mergeInto = defaultCanonical(); - const q = st.query.trim().toLowerCase(); - const list = st.raw.filter((a) => !q || (a.name || '').toLowerCase().includes(q)); - const rows = list.map((a) => { - const checked = st.sel.has(a.name) ? ' checked' : ''; - const mapped = (a.canonical && a.canonical.toLowerCase() !== (a.name || '').toLowerCase()) - ? '→ ' + esc(a.canonical) + '' : ''; - return ''; - }).join('') || '
No artists
'; - - const canReady = st.mergeInto.trim() && st.sel.size && !st.busy; - const mergeBar = st.sel.size - ? '
' + - '
' + st.sel.size + ' selected — merge into:
' + - '
' + - '
' - : ''; - - const aliasRows = st.aliases.length - ? st.aliases.map((al) => '
' + esc(al.raw_name) + ' ' + esc(al.canonical_name) + '' + - '
').join('') - : '
No merges yet
'; - - overlay.innerHTML = - '
' + - '

Tidy up artists

' + - '
' + - '
' + - '
' + rows + '
' + - '
' + mergeBar + '
' + - '
Current merges
' + aliasRows + '
' + - '
' + - '
'; - - overlay.querySelectorAll('[data-tidy-x]').forEach((b) => b.addEventListener('click', done)); - const search = overlay.querySelector('[data-tidy-search]'); - if (search) search.addEventListener('input', () => { st.query = search.value; render(); const s = overlay.querySelector('[data-tidy-search]'); if (s) { s.focus(); const n = s.value.length; try { s.setSelectionRange(n, n); } catch (_) { /* */ } } }); - overlay.querySelectorAll('[data-tidy-sel]').forEach((cb) => cb.addEventListener('change', () => { - const n = cb.getAttribute('data-tidy-sel'); - if (cb.checked) st.sel.add(n); else st.sel.delete(n); - render(); - })); - const canon = overlay.querySelector('[data-tidy-canon]'); - if (canon) canon.addEventListener('input', () => { - st.mergeInto = canon.value; st.mergeTouched = true; - const btn = overlay.querySelector('[data-tidy-merge]'); - if (btn) btn.disabled = !(st.mergeInto.trim() && st.sel.size && !st.busy); - }); - overlay.querySelector('[data-tidy-merge]')?.addEventListener('click', doMerge); - overlay.querySelectorAll('[data-tidy-unmerge]').forEach((b) => b.addEventListener('click', () => doUnmerge(b.getAttribute('data-tidy-unmerge')))); - } - - async function doMerge() { - const canonical = st.mergeInto.trim(); - if (!canonical || !st.sel.size || st.busy) return; - st.busy = true; - await jsend('POST', '/api/artist-aliases/merge', { raw_names: [...st.sel], canonical_name: canonical }); - st.sel.clear(); st.mergeTouched = false; st.busy = false; - await afterChange(); - } - - async function doUnmerge(raw) { - if (!raw) return; - try { await fetch('/api/artist-aliases/' + enc(raw), { method: 'DELETE' }); } catch (_) { /* */ } - await afterChange(); - } - - async function afterChange() { - await refresh(); - // Reflect canonical names in the toolbar dropdown + grid immediately. - try { await loadArtistCatalog(); refreshArtistAlbumSelects(); reload(); } catch (_) { /* not on the songs grid */ } - } - - refresh(); - } - - // The host loads the Folder Library plugin's screen.js at startup (defining - // window.folderLibrary). If it isn't present yet, inject it once; the - // plugin's IIFEs are idempotent so a redundant evaluation is a no-op. The - // promise is memoised so concurrent folder-view switches don't double-inject. - let _flLoadPromise = null; - function _ensureFolderLibrary() { - if (window.folderLibrary) return Promise.resolve(); - if (_flLoadPromise) return _flLoadPromise; - _flLoadPromise = new Promise((resolve, reject) => { - const s = document.createElement('script'); - s.src = '/api/plugins/folder_library/screen.js'; - s.onload = () => resolve(); - s.onerror = () => { _flLoadPromise = null; reject(new Error('Failed to load Folder Library')); }; - document.head.appendChild(s); - }); - return _flLoadPromise; - } - - function reload() { - _clearLibraryScrollSnapshot(); - // Any toolbar-driven change backs out of the artist sub-page — the new - // state describes a fresh browse, and the host toggles below re-show - // the picked view (mirrors how openAlbum's detail yields to a reload). - _dropArtistPageSilently(); - // Record the state this fetch reflects so a later sidebar return can - // tell whether the grid is stale (e.g. an off-screen search changed - // state.q) and needs a refresh rather than a scroll-preserving no-op. - state.renderedHash = _libraryStateHash(); - saveLibraryPrefs(); - 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. - // 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'); - document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums'); - // Refresh the A–Z jump rail (shows only for the grid + alphabetical - // sorts; hides itself otherwise). Independent of the grid load. - refreshRail(); - // Refresh the practice-aware home (repertoire meter + keep-practicing - // shelf); hides itself when searching/filtering/selecting or off-grid. - updateLibraryHome(); - { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; } - if (state.view === 'folder') { - _applyMainScrollTop(0); - return _ensureFolderLibrary().then(() => window.folderLibrary?.load()); - } - const loaded = state.view === 'grid' ? loadGrid(true) : state.view === 'albums' ? loadAlbums() : loadTree(); - _applyMainScrollTop(0); - return loaded; - } - - // ── Chrome ────────────────────────────────────────────────────────────-- - async function loadProviders() { - try { - const lp = sm && sm.libraryProviders; - // refresh() re-fetches /api/library/providers so REMOTE providers - // (registered by feedBack-plugin-remote-library-*) appear — list() - // returns only the capability's initial local-only snapshot. - const fn = lp && (typeof lp.refresh === 'function' ? lp.refresh : (typeof lp.list === 'function' ? lp.list : null)); - if (fn) { - const snap = await fn.call(lp); - if (snap && Array.isArray(snap.providers)) { - state.provider = snap.current || (snap.providers[0] && snap.providers[0].id) || 'local'; - return snap.providers; - } - } - } catch (e) { /* */ } - const data = await jget('/api/library/providers'); - return (data && data.providers) || [{ id: 'local', label: 'My Library' }]; - } - - async function render() { - const root = document.getElementById('v3-songs'); - if (!root) return; - // A full re-render (filter/scan refresh) replaces the DOM the card menu - // anchored to; dismiss any open menu first so it can't float orphaned. - if (_closeCardMenu) _closeCardMenu(); - // Restore last-used sort/format/view/filters once, before building the - // toolbar so its selects reflect the saved choice (default: Artist A–Z). - if (!_prefsRestored) { applySavedPrefs(); _prefsRestored = true; } - const providers = await loadProviders(); - const [, tn] = await Promise.all([ - (async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(), - jget('/api/library/tuning-names?provider=' + enc(state.provider) - + '&instrument=' + enc(libInstrument())), - loadArtistCatalog(), - // Artist-page gates (PR-B) ride the initial fetch batch so the - // first card paint already knows whether artist lines are links. - refreshArtistPageGates(), - ]); - state.tuningNames = (tn && tn.tunings) || []; - _lastRenderInstrument = libInstrument(); - try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; } - - const opt = (arr, sel) => arr.map(([v, l]) => '').join(''); - const provOpts = providers.map((p) => '').join(''); - const ctrl = btnCtrl; - - root.innerHTML = - '
' + - '
' + - '
' + - // The match-review chip is ambient tool-state (design §11): only - // rendered when matches are waiting, silent otherwise. Populated + - // shown by match-review.js (window.__fbMatchReviewChip), which - // also owns the drawer the click opens. - '

' + - '' + - // Batch progress for the Refresh Metadata button (shown only while a - // pass runs). A real songs-processed ratio, not a fake per-song %. - '
' + - '
' + - (providers.length > 1 ? '' : '') + - '' + - '' + - '
' + - // Name the perspective on the SORT too, not just the filter: tuning - // sort orders by musical distance from standard, and for a bass - // player that distance is measured on the bass tuning. Unlabelled, - // the grid silently reorders with no visible cause. - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
' + - // Practice-aware library home: a repertoire progress meter + a - // "Keep practicing" shelf of started-but-not-mastered songs. Shown - // 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). - '
' + - '
' + - '
' + - '' + - '' + - // Artist page host (PR-B) — an openAlbum-style in-place sub-render; - // populated + shown by openArtistPage, cleared on close/reload. - '' + - '' + - '' + - '
' + - // A–Z jump rail (grid + alphabetical sorts only; populated by - // refreshRail). The bubble shows the current letter while dragging. - '' + - '' + - // Filter drawer + overlay - '' + - '' + - '
'; - - // Wire toolbar. - const byId = (id) => document.getElementById(id); - byId('v3-songs-provider')?.addEventListener('change', async (e) => { - state.provider = e.target.value; - state.artist = ''; - state.album = ''; - try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ } - _updateMetaBtnVisibility(); // enrichment is local-only - await loadArtistCatalog(); - refreshArtistAlbumSelects(); - reload(); - }); - byId('v3-songs-artist')?.addEventListener('change', (e) => setArtist(e.target.value)); - byId('v3-songs-album')?.addEventListener('change', (e) => setAlbum(e.target.value)); - byId('v3-songs-sort').addEventListener('change', (e) => { state.sort = e.target.value; reload(); }); - byId('v3-songs-format').addEventListener('change', async (e) => { - state.format = e.target.value; - await loadArtistCatalog(); - refreshArtistAlbumSelects(); - reload(); - }); - byId('v3-songs-filters').addEventListener('click', openDrawer); - byId('v3-songs-overlay').addEventListener('click', closeDrawer); - // Match-review chip: feature-detected (match-review.js owns the - // drawer + the count; absent → the chip just stays hidden). - byId('v3-songs-match-review')?.addEventListener('click', () => { if (window.__fbOpenMatchReview) window.__fbOpenMatchReview(); }); - if (window.__fbMatchReviewChip) window.__fbMatchReviewChip(); - byId('v3-songs-upload').addEventListener('click', () => { - const legacy = document.getElementById('upload-songs-file'); - // Upload targets the LOCAL library + scan; watchUploadScan refreshes - // the grid for the local provider. Uploading while browsing a remote - // provider won't surface the new local songs — switching the grid to - // local on upload is a P23 remote-provider follow-up. - if (legacy) { legacy.click(); watchUploadScan(); } - }); - byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode)); - byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary); - // Refresh Metadata: local-only, so hide it for remote providers. The - // button doubles as its own Stop while a pass runs (see onMetaBtnClick). - byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick); - byId('v3-songs-unmatched')?.addEventListener('click', toggleUnmatchedFilter); - _updateMetaBtnVisibility(); - // Reflect a scan already in progress (Settings button or a background - // pass) on the Refresh button, so its state isn't just tied to clicks here. - (async () => { - try { - const r = await fetch('/api/scan-status'); - const sd = r.ok ? await r.json() : null; - if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); } - } catch (e) { /* */ } - })(); - // Reflect an enrichment pass already running (Settings "Match now" or a - // post-scan background pass) on the Metadata button + bar. - (async () => { - try { - const r = await fetch('/api/enrichment/status'); - const es = r.ok ? await r.json() : null; - if (es && es.running) { _setMetaState(es); _watchEnrich({ announce: false }); } - } catch (e) { /* */ } - })(); - - // Capture-phase select-mode guard on each persistent list host. Without - // it, clicking a card/row (or its arrangement chip) in select mode falls - // through to the per-card play handler and starts playback instead of - // selecting ("checkbox click opens the song / access-denied"). The artist - // page renders the same [data-fn] song rows into its own host, so it - // needs the guard too — otherwise a row click there plays instead of - // toggling when select mode is already on. - bindSelectGuard(byId('v3-songs-grid')); - bindSelectGuard(byId('v3-songs-tree')); - bindSelectGuard(byId('v3-songs-artistpage')); - const setView = async (v) => { - state.view = v; - byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); - byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); - byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); - { const ab = byId('v3-songs-albums-btn'); if (ab) ab.className = 'px-3 py-2 text-sm ' + (v === 'albums' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); } - if (v === 'folder') await _ensureFolderLibrary(); - return reload(); - }; - byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid')); - byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree')); - byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder')); - byId('v3-songs-albums-btn')?.addEventListener('click', () => setView('albums')); - // Await the initial load so a caller awaiting render() (the scroll - // restore on screen re-entry) sees a populated grid + real state.total - // before it tries to page deeper. - await setView(state.view); - bindScroll(); - bindGridResize(); - positionToolbar(); - bindToolbarReflow(); - updateFilterBadge(); - state.built = true; - } - - async function onV3SongsScreenEnter() { - // A library scan / DLC-folder change marked the grid stale — re-fetch - // from scratch instead of restoring a cached (possibly empty, pre-DLC) - // snapshot. Must win over every fast-path below. - if (_libraryDirty) { _libraryDirty = false; await reload(); return; } - // Keep the entry-point gates current (a Settings visit may have - // toggled artist pages / external links). Fire-and-forget. - refreshArtistPageGates(); - // An open artist sub-page survives a screen bounce as-is — its DOM is - // self-contained. A torn-down/hidden host means the state is stale; - // clear it and fall through to the normal restore paths. - if (state.artistPage) { - const ah = document.getElementById('v3-songs-artistpage'); - if (ah && !ah.classList.contains('hidden') && ah.childElementCount) return; - state.artistPage = null; - state.artistReturnScroll = null; - } - // 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'); - const chromeOk = _chromeIntact(); - const viewOk = state.view === (snap && snap.view ? snap.view : state.view); - - if (snap && hashMatch && domReady && chromeOk && viewOk) { - if (state.view === 'grid' && _gridDomIntact()) { - // 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-gridsizer')?.classList.toggle('hidden', true); - document.getElementById('v3-songs-tree')?.classList.toggle('hidden', false); - syncChromeFromState(); - _applyMainScrollTop(snap.scrollTop || 0); - _clearLibraryScrollSnapshot(); - return; - } - } - - // Sidebar return without a player snapshot — keep grid, refresh chrome. - if (!snap && domReady && chromeOk && state.built) { - syncChromeFromState(); - // If state drifted while we were away (notably an off-screen topbar - // search updating state.q), the persisted grid is stale — refetch - // 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-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; - } - - const snapToRestore = hashMatch ? snap : null; - if (snap && !hashMatch) _clearLibraryScrollSnapshot(); - await render(); - if (snapToRestore && snapToRestore.hash === _libraryStateHash()) { - // 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(); - } - - // After an upload click-through (which reuses the legacy uploader + - // background scan), poll /api/scan-status and reload the v3 grid once the - // scan we triggered finishes — the legacy uploader only refreshes the - // legacy screens, so without this newly-uploaded songs wouldn't appear in - // v3 until a manual refresh. Bounded so a no-op upload can't poll forever. - let _uploadScanTimer = null; - // ── Library refresh (rescan) from the Songs toolbar ─────────────────────── - // The tester ask: a media-server-style "I dropped files in my folder, hit - // refresh" button, with live progress. Reuses the SAME machinery the Settings - // Rescan buttons drive (/api/rescan + /api/scan-status) and emits - // library:changed so the grid reloads. A scan already running (Settings or a - // background pass) is reflected on the button too. - let _refreshPoll = null; - function _setRefreshState(sd) { - const btn = document.getElementById('v3-songs-refresh'); - if (!btn) return; - if (sd && sd.running) { - // Determinate count only exists in the 'scanning' stage; 'listing' is - // indeterminate (total 0), so show a plain "Scanning…" then. - const det = (sd.stage === 'scanning' && sd.total) ? ' ' + sd.done + '/' + sd.total : ''; - btn.textContent = '⟳ Scanning' + det + '…'; - btn.disabled = true; - btn.classList.add('opacity-70'); - const pct = sd.total ? Math.round((sd.done / sd.total) * 100) + '% · ' : ''; - btn.title = 'Scanning new/changed songs… ' + pct + (sd.current || ''); - } else { - btn.textContent = '⟳ Refresh'; - btn.disabled = false; - btn.classList.remove('opacity-70'); - btn.title = 'Refresh library (scan for new songs)'; - } - } - // Show a completion toast (reuses the shared fbNotify surface), suppressed - // while in a song. Honest + never-punishing copy: shows the "N added / M - // removed" delta from the scan when there is one, else "up to date". - function _scanCompleteToast(sd) { - if (document.querySelector('.screen.active') && document.querySelector('.screen.active').id === 'player') return; - if (!window.fbNotify) return; - const added = (sd && sd.added) || 0, removed = (sd && sd.removed) || 0; - let msg; - if (sd && sd.error) msg = 'Scan finished with an error'; - else if (added || removed) { - const parts = []; - if (added) parts.push(added + ' song' + (added === 1 ? '' : 's') + ' added'); - if (removed) parts.push(removed + ' removed'); - msg = parts.join(' · '); - } else msg = 'Your library is up to date'; - try { window.fbNotify.show({ title: 'Library scan complete', message: msg, icon: '🔄', accent: '#22C55E' }); } catch (e) { /* */ } - } - // Poll scan-status until the scan finishes, driving the button state. On - // completion, emit library:changed (grid reloads via the listener below) and, - // for a user-initiated refresh (announce), show the toast. announce:false is - // used when we only attached to a scan we didn't start. - function _watchScan(opts) { - if (_refreshPoll) return; - const announce = !opts || opts.announce !== false; - let sawRunning = false, ticks = 0; - _refreshPoll = setInterval(async () => { - ticks++; - let sd = null; - try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ } - _setRefreshState(sd); - if (sd && sd.running) sawRunning = true; - // A user-initiated refresh that never saw a running scan = nothing to - // do (already up to date); give prompt feedback instead of waiting. - const noopDone = announce && !sawRunning && ticks >= 3; - if ((sawRunning && sd && !sd.running) || noopDone || ticks >= 180) { - clearInterval(_refreshPoll); _refreshPoll = null; - _setRefreshState(null); - const hasDelta = sd && (((sd.added || 0) > 0) || ((sd.removed || 0) > 0)); - if (sawRunning && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'rescan', added: (sd && sd.added) || 0, removed: (sd && sd.removed) || 0 }); } catch (e) { /* */ } } - // A user-initiated refresh always confirms; a scan we only attached - // to (background / Settings) toasts just when it changed something, - // so a periodic no-op pass stays silent. - if (announce || hasDelta) _scanCompleteToast(sd); - } - }, 1000); - } - async function refreshLibrary() { - if (_refreshPoll) return; // a scan is already in progress - try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* */ } - _watchScan({ announce: true }); - } - - function watchUploadScan() { - if (_uploadScanTimer) clearInterval(_uploadScanTimer); - let sawRunning = false, ticks = 0; - _uploadScanTimer = setInterval(async () => { - ticks++; - let sd = null; - try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ } - if (sd && sd.running) sawRunning = true; - if ((sawRunning && sd && !sd.running) || ticks >= 90) { - clearInterval(_uploadScanTimer); _uploadScanTimer = null; - if (sawRunning) reload(); - } - }, 1000); - } - - // ── Refresh Metadata (batch enrichment) from the Songs toolbar ───────────── - // The metadata counterpart to ⟳ Refresh (which scans FILES): matches - // titles/artist/album/artwork against MusicBrainz for the songs that still - // need it — the ambient background matcher, run on demand (a media-server's - // "Refresh Metadata" vs "Scan Files"). Mirrors the scan machinery: a 1 Hz - // poll of /api/enrichment/status drives the button + batch bar, while - // /api/enrichment/states drives per-tile badges on the visible window. - // Enrichment is local-only, so the button hides for remote providers. - let _metaPoll = null; - let _metaRunning = false; - - function _updateMetaBtnVisibility() { - const local = state.provider === 'local'; // enrichment + its filter are local-only - const btn = document.getElementById('v3-songs-refresh-meta'); - if (btn) btn.style.display = local ? '' : 'none'; - const um = document.getElementById('v3-songs-unmatched'); - if (um) um.style.display = local ? '' : 'none'; - } - - // Quick "Show unmatched" — the same filter as the drawer's Match → Unmatched, - // one click from the toolbar so the no-match pile is reachable right after a - // batch. Toggles the button + re-queries the grid. - function toggleUnmatchedFilter() { - const m = state.filters.match || (state.filters.match = []); - const i = m.indexOf('unmatched'); - const on = i < 0; - if (on) m.push('unmatched'); else m.splice(i, 1); - const btn = document.getElementById('v3-songs-unmatched'); - if (btn) { btn.classList.toggle('bg-fb-primary', on); btn.classList.toggle('text-white', on); } - reload(); - } - - // The local filenames the grid is currently SHOWING (data-fn is the local - // filename the enrichment cache keys on). The grid is windowed, so this is - // the visible slice only — exactly what the per-tile poll should cover. - function _visibleLocalFilenames() { - const grid = document.getElementById('v3-songs-grid'); - if (!grid) return []; - return [...grid.querySelectorAll('[data-fn]')] - .map((el) => el.getAttribute('data-fn')).filter(Boolean); - } - - // Set/clear one card's live badge (recycled cards re-derive from _metaTile on - // the next paint, so update the map too — mirrors _patchCardFav). - function _patchCardEnrich(fn, st) { - if (st) _metaTile[fn] = st; else delete _metaTile[fn]; - const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn; - document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => { - const el = play.querySelector('.v3-meta-tile'); - const html = enrichBadge(fn); - if (!html) { if (el) el.remove(); return; } - if (el) el.outerHTML = html; else play.insertAdjacentHTML('beforeend', html); - }); - } - - function _clearMetaTiles() { - Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; }); - document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove()); - // The persistent "No match" badge derives from _unmatched (not _metaTile), - // yet shares the .v3-meta-tile class — so the blanket remove above strips it. - // Repaint the resting indicator on any rendered card so a metadata rescan's - // tile-clear doesn't silently drop it until the next scroll/re-render. - _unmatched.forEach((fn) => { - const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn; - document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => { - if (play.querySelector('.v3-meta-tile')) return; - const html = enrichBadge(fn); - if (html) play.insertAdjacentHTML('beforeend', html); - }); - }); - } - - - // Drive the button (which doubles as Stop) + the batch bar from a status body. - function _setMetaState(es) { - const btn = document.getElementById('v3-songs-refresh-meta'); - const prog = document.getElementById('v3-meta-progress'); - const fill = document.getElementById('v3-meta-progress-fill'); - const label = document.getElementById('v3-meta-progress-label'); - if (!btn) return; - const running = !!(es && es.running); - _metaRunning = running; - if (running) { - const total = (es && es.total) || 0, done = (es && es.matched) || 0; - const cancelling = !!(es && es.cancelling); - btn.textContent = cancelling ? 'Stopping…' : ('⏹ Stop' + (total ? ' · ' + done + '/' + total : '')); - btn.disabled = cancelling; - btn.classList.toggle('opacity-70', cancelling); - btn.title = cancelling ? 'Stopping after the current song…' : 'Stop refreshing metadata'; - if (prog) { - prog.classList.remove('hidden'); prog.classList.add('flex'); - if (label) label.textContent = total ? ('Matching metadata ' + done + '/' + total) : 'Matching metadata…'; - // Real songs-processed ratio; a tiny sliver while the queue size - // is still being computed (phase 1) so the bar isn't dead-empty. - if (fill) fill.style.width = (total ? Math.round((done / total) * 100) : 6) + '%'; - } - } else { - btn.textContent = '🏷 Metadata'; - btn.disabled = false; - btn.classList.remove('opacity-70'); - btn.title = 'Refresh metadata for the songs shown (re-match titles, artwork & more)'; - if (prog) { prog.classList.add('hidden'); prog.classList.remove('flex'); } - } - } - - // Completion toast — reuse the shared fbNotify surface (visual-only, so - // hearing-safe for free). Honest + never-punishing copy, in-game suppressed. - function _metaCompleteToast(es) { - const active = document.querySelector('.screen.active'); - if (active && active.id === 'player') return; - if (!window.fbNotify) return; - const matched = (es && es.matched) || 0; - const msg = matched - ? (matched + ' song' + (matched === 1 ? '' : 's') + ' matched') - : 'Your library metadata is up to date'; - try { window.fbNotify.show({ title: 'Metadata refresh complete', message: msg, icon: '🏷️', accent: '#22C55E' }); } catch (e) { /* */ } - } - - // Poll enrichment status (button + bar) AND the visible window's per-song - // states (tile badges) until the pass finishes. announce:false = we only - // attached to a pass we didn't start (no toast unless it actually changed - // something). - function _watchEnrich(opts) { - if (_metaPoll) return; - const announce = !opts || opts.announce !== false; - let sawRunning = false, ticks = 0, lastStatus = null; - _metaPoll = setInterval(async () => { - ticks++; - let es = null; - try { const r = await fetch('/api/enrichment/status'); if (r.ok) es = await r.json(); } catch (e) { /* */ } - if (es) { lastStatus = es; _setMetaState(es); if (es.running) sawRunning = true; } - // Per-tile badges: only songs we're tracking (seeded 'queued'). A - // tile flips to 'working' when it's the current song, then to - // 'done' (matched) / 'nochange' (failed) once it leaves unscanned. - if (Object.keys(_metaTile).length) { - const fns = _visibleLocalFilenames(); - try { - const r = await fetch('/api/enrichment/states', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filenames: fns }), - }); - if (r.ok) { - const j = await r.json(); - const states = j.states || {}, current = j.current; - fns.forEach((fn) => { - if (!(fn in _metaTile)) return; - if (fn === current) { _patchCardEnrich(fn, 'working'); return; } - const s = states[fn]; - if (s && s !== 'unscanned' && s !== 'pending') { - _patchCardEnrich(fn, s === 'failed' ? 'nochange' : 'done'); - } - }); - } - } catch (e) { /* */ } - } - // Cap at 20 min (a ~1000-song trickle at ≤1/s is ~17 min); a - // user-initiated no-op that never saw a running pass ends quickly. - const noopDone = announce && !sawRunning && ticks >= 3; - if ((sawRunning && es && !es.running) || noopDone || ticks >= 1200) { - clearInterval(_metaPoll); _metaPoll = null; - _setMetaState(null); - const changed = sawRunning && lastStatus && (lastStatus.matched || 0) > 0; - if (announce || changed) _metaCompleteToast(lastStatus); - // Let the final 'done' badges register, then clear + (if anything - // matched) reload so new canonical titles/art show. - setTimeout(() => { - _clearMetaTiles(); - if (changed && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'enrich', matched: lastStatus.matched }); } catch (e) { /* */ } } - }, 1600); - } - }, 1000); - } - - // Force a fresh re-match of the songs currently SHOWN (the visible grid - // window) — a media-server-style per-view "Refresh Metadata". Resets those - // songs and re-fetches, so it's visible even on an already-matched library. - // Manual pins are skipped server-side; scoped to the visible set so it's - // fast + can't blow the whole rate budget. - async function refreshMetadata() { - if (_metaRunning || _metaPoll) return; // already running - const fns = _visibleLocalFilenames(); - _clearMetaTiles(); - if (!fns.length) { _metaCompleteToast({ matched: 0 }); return; } - let queued = []; - try { - const r = await fetch('/api/enrichment/rematch', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filenames: fns }), - }); - if (r.ok) queued = (await r.json()).queued || []; - } catch (e) { /* offline → nothing queued */ } - // Badge exactly what the server queued (everything visible except your - // manual pins). Nothing queued = all visible songs are pinned/unknown. - queued.forEach((fn) => _patchCardEnrich(fn, 'queued')); - if (!queued.length) { _metaCompleteToast({ matched: 0 }); return; } - _watchEnrich({ announce: true }); - } - - async function stopMetadata() { - try { await fetch('/api/enrichment/cancel', { method: 'POST' }); } catch (e) { /* */ } - _setMetaState({ running: true, cancelling: true }); // optimistic; the poll confirms - } - - // The Metadata button toggles role: kick a refresh when idle, Stop when a - // pass is running. - function onMetaBtnClick() { - if (_metaRunning) stopMetadata(); else refreshMetadata(); - } - - // Topbar search drives this screen. - async function search(q) { - state.q = q || ''; - if (songsActive()) { - await loadArtistCatalog(); - refreshArtistAlbumSelects(); - reload(); - } else if (window.showScreen) { - window.showScreen('v3-songs'); - } - } - - window.v3Songs = { - render: render, - reload: reload, - search: search, - setQuery: (q) => { state.q = q || ''; }, - 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(); - if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(',')); - if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(',')); - if (f.stem_has.length) p.set('stems_has', f.stem_has.join(',')); - if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(',')); - if (f.lyrics) p.set('has_lyrics', f.lyrics); - if (f.tunings.length) p.set('tunings', f.tunings.join(',')); - return p.toString(); - }, - _scrollHelpers: { - SCROLL_STATE_KEY, - buildLibraryStateHash, - readSnapshot: _readLibraryScrollSnapshot, - clearSnapshot: _clearLibraryScrollSnapshot, - }, - }; - - if (sm && typeof sm.on === 'function') { - sm.on('screen:changed', (e) => { - const id = e && e.detail && e.detail.id; - if (id === 'v3-songs') { onV3SongsScreenEnter(); return; } - // Leaving Songs: tear down select mode + the body-mounted batch bar, - // so an active multi-selection doesn't leave a floating bar (and - // stale selection) visible on unrelated screens. - if (state.selectMode || state.selected.size) { - state.selectMode = false; - state.selected.clear(); - const bar = document.getElementById('v3-songs-batch'); - if (bar) bar.remove(); - } - }); - // 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) => { - // Decode to the library-card key space — the event carries the - // encodeURIComponent'd filename, but cards (data-fn) and - // state.accuracy key on the decoded library filename. Without this - // the repaint below (and applyScoreRefresh's repaintAccuracy) match - // no card, so a just-earned score stays invisible until a full - // render() (restart / search / re-enter), which is this bug. - const fn = decFn(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(); - }); - // A library scan (rescan / full rescan from Settings, or a DLC-folder - // change) can add or remove songs while this grid is cached — the - // Settings rescan only refreshed the classic library, so the v3 grid - // stayed on its pre-scan (e.g. empty, pre-DLC) state until an app - // restart. Reload now if we're showing; otherwise mark dirty so the next - // entry re-fetches instead of restoring the stale snapshot. - sm.on('library:changed', () => { - const active = document.querySelector('.screen.active'); - if (active && active.id === 'v3-songs') { _libraryDirty = false; reload(); } - else _libraryDirty = true; - }); - // Your live tuning changed (retune / instrument swap / reset) → re-colour the - // visible tuning chips against the new tuning. Cheap: re-decorates in place, - // no re-fetch or re-paint. No-op off the Songs grid or without the capability. - sm.on('working-tuning-changed', () => { - // A guitar<->bass SWITCH changes which tuning the facet, the filter, - // the sort and the card chip speak for, so the grid must re-query — - // re-colouring chips would leave the guitar tuning on screen and a - // guitar-keyed filter applied. A retune within one instrument still - // takes the cheap in-place path below. - const inst = libInstrument(); - if (inst !== _lastRenderInstrument) { - _lastRenderInstrument = inst; - // A tuning selection keyed to the old instrument means nothing - // for the new one; clearing avoids an empty grid the user can't - // explain (the pills are re-rendered from the new facet). - state.filters.tunings = []; - const active = document.querySelector('.screen.active'); - if (active && active.id === 'v3-songs') reload(); - else _libraryDirty = true; - return; - } - if (typeof songsActive === 'function' && !songsActive()) return; - if (state.view !== 'grid') return; - decorateTuningChips(_gridEl()); - }); - } -})(); +/* + * fee[dB]ack v0.3.0 — Songs / Library (#v3-songs), native rebuild. + * + * A vanilla-JS library browser over the existing /api/library* endpoints: + * provider selector (via the `library` capability, not DOM scraping), grid + + * tree views, sort, format filter, a tri-state filter drawer (arrangements / + * stems / lyrics / tunings), search (driven by the topbar), infinite scroll, + * fb song cards with accuracy badges (song_stats), favorite + save-for-later, + * and upload. Reuses window.playSong for playback (design/05: library is an + * active capability domain; everything else stays on the documented globals). + */ +(function () { + 'use strict'; + const sm = window.feedBack; + const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const enc = encodeURIComponent; + // Inverse of `enc` for matching a played song's filename back to a library + // card. The `stats:recorded` event (like `song:loading`) carries the + // filename exactly as it was handed to `playSong` — i.e. encodeURIComponent'd + // (see `playCard`, and the highway WS which decodeURIComponent's it). But + // cards key on the DECODED library filename (`cardKey` → `localFilename`), + // and `/api/stats/best` is server-canonicalized to that same decoded key, so + // an encoded filename matches no card and the post-play badge repaint silently + // no-ops. Decode to land in the card/`state.accuracy` key space. Idempotent + // for already-decoded names (no '%'); on malformed input falls back to the + // original so a real filename containing a literal '%' is never corrupted. + function decFn(fn) { + if (typeof fn !== 'string' || fn.indexOf('%') === -1) return fn || ''; + try { return decodeURIComponent(fn); } catch (_) { return fn; } + } + + const SORTS = [ + ['artist', 'Artist A–Z'], ['artist-desc', 'Artist Z–A'], + ['title', 'Title A–Z'], ['title-desc', 'Title Z–A'], + ['recent', 'Recently Added'], ['year-desc', 'Year (newest)'], + ['year', 'Year (oldest)'], ['tuning', 'Tuning'], + // Mastery = best accuracy across arrangements (song_stats); unscored songs + // sort last either way. Ascending surfaces what needs work; never default. + ['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'], + // Personal difficulty (song_user_meta.user_difficulty, 1-5); unrated + // songs sort last either way. + ['difficulty', 'Difficulty (easiest first)'], ['difficulty-desc', 'Difficulty (hardest first)'], + ]; + const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']]; + const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals']; + const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'piano', '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'; + // Persisted "how I'm looking" prefs (sort / format / view / drawer filters). + // The tester ask: "most users pick one sort and leave it" + remember filters. + // The search query and the artist/album drill-down are navigational, so they + // are deliberately NOT persisted; cold start stays the neutral Artist A–Z. + const PREFS_KEY = 'v3:songs-prefs'; + 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'; + + const state = { + provider: 'local', view: 'grid', sort: 'artist', format: '', q: '', + artist: '', album: '', + grouping: true, // one card per song (multi-chart grouping); persisted + + filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' }, + page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], genres: [], + artistCatalog: [], renderedHash: '', + scrollBound: false, + songsById: {}, selectMode: false, selected: new Set(), + railLetters: null, railLettersAreSongCounts: false, railJumping: false, + // ── Artist page (PR-B) ── + // Non-null while the artist sub-page is showing (the artist's canonical + // or raw name). The gates mirror the two Settings toggles: pages are + // local-only and default ON; the external-links row is opt-in. + artistPage: null, + artistReturnScroll: null, // scrollTop to restore on ← Song Library + artistPagesEnabled: true, + artistLinksEnabled: 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 ─────────────────────────────────────────────────────── + // 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 + + (f.lyrics ? 1 : 0) + (f.tuningMatch === 'playable' ? 1 : f.tunings.length) + (f.mastery ? f.mastery.length : 0) + + (f.match ? f.match.length : 0) + (f.genre ? f.genre.length : 0) + + (state.artist ? 1 : 0) + (state.album ? 1 : 0); + } + + function _getV3MainScroller() { return document.getElementById('v3-main'); } + + function buildLibraryStateHash(st) { + const f = (st && st.filters) || {}; + return JSON.stringify({ + view: st.view || 'grid', + q: st.q || '', + sort: st.sort || 'artist', + provider: st.provider || 'local', + format: st.format || '', + artist: st.artist || '', + album: st.album || '', + filters: { + arr_has: [...(f.arr_has || [])].sort(), + arr_lacks: [...(f.arr_lacks || [])].sort(), + stem_has: [...(f.stem_has || [])].sort(), + stem_lacks: [...(f.stem_lacks || [])].sort(), + lyrics: f.lyrics || '', + tunings: [...(f.tunings || [])].sort(), + mastery: [...(f.mastery || [])].sort(), + match: [...(f.match || [])].sort(), + genre: [...(f.genre || [])].sort(), + }, + }); + } + + function _libraryStateHash() { return buildLibraryStateHash(state); } + + // Restore the persisted view prefs once per page load, before the first + // toolbar build so the selects render with the saved values. Every value is + // validated against its known option list, so a stale/removed setting can + // never wedge the UI — it just falls back to the default. + let _prefsRestored = false; + function applySavedPrefs() { + let saved; + try { saved = JSON.parse(localStorage.getItem(PREFS_KEY)); } catch (_) { return; } + if (!saved || typeof saved !== 'object') return; + if (SORTS.some(([v]) => v === saved.sort)) state.sort = saved.sort; + if (FORMATS.some(([v]) => v === saved.format)) state.format = saved.format; + if (saved.view === 'grid' || saved.view === 'tree' || saved.view === 'folder' || saved.view === 'albums') state.view = saved.view; + if (typeof saved.grouping === 'boolean') state.grouping = saved.grouping; + const f = saved.filters; + if (f && typeof f === 'object') { + const arr = (x) => (Array.isArray(x) ? x.slice() : []); + // mastery + match + genre are session-only facets (deliberately not + // persisted), but the restored object must still CARRY the keys — + // the filter drawer indexes f.mastery/f.match/f.genre unconditionally, + // so dropping them here breaks the drawer for anyone with saved prefs. + state.filters = { + arr_has: arr(f.arr_has), arr_lacks: arr(f.arr_lacks), + stem_has: arr(f.stem_has), stem_lacks: arr(f.stem_lacks), + lyrics: f.lyrics || '', tunings: arr(f.tunings), + mastery: [], match: [], genre: [], + }; + } + } + // Persist the current view prefs (best-effort; storage may be full/disabled). + // Called from reload(), which every sort/format/filter/view change funnels + // through — so this is the single write point. + function saveLibraryPrefs() { + try { + const f = state.filters; + localStorage.setItem(PREFS_KEY, JSON.stringify({ + sort: state.sort, format: state.format, view: state.view, + grouping: state.grouping !== false, + filters: { + arr_has: [...f.arr_has], arr_lacks: [...f.arr_lacks], + stem_has: [...f.stem_has], stem_lacks: [...f.stem_lacks], + lyrics: f.lyrics || '', tunings: [...f.tunings], + }, + })); + } catch (_) { /* best-effort */ } + } + + 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, + }; + try { sessionStorage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } catch (e) { /* quota / private mode */ } + } + + function _readLibraryScrollSnapshot() { + try { + const raw = sessionStorage.getItem(SCROLL_STATE_KEY); + if (!raw) return null; + const snap = JSON.parse(raw); + return (snap && typeof snap === 'object') ? snap : null; + } catch (e) { return null; } + } + + function _clearLibraryScrollSnapshot() { + try { sessionStorage.removeItem(SCROLL_STATE_KEY); } catch (e) { /* */ } + } + + function _applyMainScrollTop(scrollTop) { + const main = _getV3MainScroller(); + if (!main) return; + const top = Math.max(0, Number(scrollTop) || 0); + const apply = () => { main.scrollTop = top; }; + apply(); + requestAnimationFrame(apply); + 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'); + const sizer = document.getElementById('v3-songs-gridsizer'); + return !!grid && !!sizer && state.total > 0 && state.songs[0] !== undefined; + } + + function _treeDomIntact() { + const tree = document.getElementById('v3-songs-tree'); + if (!tree) return false; + return !!(tree.querySelector('[data-fn]') || tree.querySelector('details')); + } + + // ── Multi-chart grouping (P5c, design §7.1) ───────────────────────────── + // The grid queries with group=1 so charts of the same work collapse to ONE + // card — the representative (preferred/auto-pick) chart — with rows carrying + // chart_count + work_key from the materialized work_display read-model + // (P5a/P5b). group must ride BOTH the page fetch and the rail's stats fetch + // so page, total and sort_letters all count works identically — a + // works-vs-charts mismatch would break the rail's cumulative-seek math and + // the sizer geometry. Grouping is default-ON per the design, with a + // persisted toggle in the filter drawer (P5e) — OFF falls back to today's + // one-card-per-chart. Only the local provider implements group=; smart + // collections and remote providers ignore it and stay flat (their rows + // then carry no chart_count, so no ⚑ chips render). + function groupingActive() { return state.grouping !== false; } + + function queryParams(extra, opts) { + const f = state.filters; + const skipArtistAlbum = opts && opts.catalog; + const p = new URLSearchParams(); + p.set('provider', state.provider); + p.set('sort', state.sort); + if (state.format) p.set('format', state.format); + if (state.q) p.set('q', state.q); + if (!skipArtistAlbum && state.artist) p.set('artist', state.artist); + if (!skipArtistAlbum && state.album) p.set('album', state.album); + if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(',')); + if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(',')); + if (f.stem_has.length) p.set('stems_has', f.stem_has.join(',')); + if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(',')); + if (f.lyrics) p.set('has_lyrics', f.lyrics); + // The two modes answer different questions, so only one filters at a + // time: sending both would silently intersect them. + if (f.tunings.length && f.tuningMatch !== 'playable') p.set('tunings', f.tunings.join(',')); + // Which perspective the `tunings` filter + the tuning sort read. + if (libInstrument() !== 'guitar-lead') p.set('instrument', libInstrument()); + // "Playable without retuning": send the player's LIVE tuning and let the + // server do the pitch maths (the pitch tables live in lib/tunings.py — + // duplicating them here is how the two drift apart). + applyPlayableParams(p, f); + if (f.mastery && f.mastery.length) p.set('mastery', f.mastery.join(',')); + if (f.match && f.match.length) p.set('match', f.match.join(',')); + if (f.genre && f.genre.length) p.set('genre', f.genre.join(',')); + Object.entries(extra || {}).forEach(([k, v]) => p.set(k, v)); + return p; + } + + // The active filter set as a smart-collection rule object (raw query-param + // format the backend stores). Mirrors queryParams' filter fields, minus + // provider/page/size. Empty object → nothing worth saving as a collection. + function currentFilterRules() { + const f = state.filters, r = {}; + if (state.q) r.q = state.q; + if (state.format) r.format = state.format; + if (state.artist) r.artist = state.artist; + if (state.album) r.album = state.album; + if (f.arr_has.length) r.arrangements_has = f.arr_has.join(','); + if (f.arr_lacks.length) r.arrangements_lacks = f.arr_lacks.join(','); + if (f.stem_has.length) r.stems_has = f.stem_has.join(','); + if (f.stem_lacks.length) r.stems_lacks = f.stem_lacks.join(','); + if (f.lyrics) r.has_lyrics = f.lyrics; + if (f.tunings.length) r.tunings = f.tunings.join(','); + if (state.sort && state.sort !== 'artist') r.sort = state.sort; + return r; + } + + // Save the current filter set as a smart collection (a saved live query that + // shows up as a source in the picker). #636 item 2. + async function saveCurrentAsCollection() { + const rules = currentFilterRules(); + if (!Object.keys(rules).length) return; + const name = ((await window.uiPrompt({ + title: 'Save as collection', + label: 'A live view of the current filters, in the source picker.', + okLabel: 'Save', + placeholder: 'Collection name', + })) || '').trim(); + if (!name) return; + try { + const res = await fetch('/api/collections', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, rules }), + }); + if (!res.ok) return; + const col = (await res.json()).collection; + closeDrawer(); + if (col && col.id != null) state.provider = 'collection:' + col.id; + await render(); // rebuilds the toolbar (provider picker now lists + selects it) + } catch (e) { /* offline / aborted — leave the drawer as-is */ } + } + + function albumsForArtist(name) { + const a = (state.artistCatalog || []).find((x) => x.name === name); + return a ? (a.albums || []) : []; + } + + function _chromeIntact() { + return !!(document.getElementById('v3-songs-filters') && + document.getElementById('v3-songs-artist') && + document.getElementById('v3-songs-grid')); + } + + function artistSelectHtml() { + const opts = [''] + .concat((state.artistCatalog || []).map((a) => + '')); + return opts.join(''); + } + + function albumSelectHtml() { + if (!state.artist) { + return ''; + } + const albums = albumsForArtist(state.artist); + const opts = [''] + .concat(albums.map((n) => + '')); + return opts.join(''); + } + + function refreshArtistAlbumSelects() { + const artistEl = document.getElementById('v3-songs-artist'); + const albumEl = document.getElementById('v3-songs-album'); + if (artistEl) artistEl.innerHTML = artistSelectHtml(); + if (albumEl) { + albumEl.innerHTML = albumSelectHtml(); + albumEl.disabled = !state.artist; + } + } + + function syncChromeFromState() { + const map = { + 'v3-songs-provider': state.provider, + 'v3-songs-sort': state.sort, + 'v3-songs-format': state.format, + }; + Object.entries(map).forEach(([id, val]) => { + const el = document.getElementById(id); + if (el && el.value !== val) el.value = val; + }); + refreshArtistAlbumSelects(); + const gridBtn = document.getElementById('v3-songs-grid-btn'); + const treeBtn = document.getElementById('v3-songs-tree-btn'); + if (gridBtn) gridBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); + 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(); + } + + async function loadArtistCatalog() { + const artists = []; + let page = 0, total = Infinity; + while (artists.length < total) { + const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }, { catalog: true }).toString()); + if (!data || !Array.isArray(data.artists)) break; + artists.push(...data.artists); + total = (data.total_artists != null) ? data.total_artists : artists.length; + if (!data.artists.length || page > 1000) break; + page++; + } + state.artistCatalog = artists.map((a) => ({ + name: a.name, + albums: (a.albums || []).map((al) => al.name), + })); + if (state.artist && !state.artistCatalog.some((a) => a.name === state.artist)) { + state.artist = ''; + state.album = ''; + } else if (state.album && !albumsForArtist(state.artist).includes(state.album)) { + state.album = ''; + } + return state.artistCatalog; + } + + function resetScrollToTop() { + _clearLibraryScrollSnapshot(); + _applyMainScrollTop(0); + } + + function setArtist(value) { + state.artist = value || ''; + if (state.album && !albumsForArtist(state.artist).includes(state.album)) state.album = ''; + resetScrollToTop(); + refreshArtistAlbumSelects(); + reload(); + } + + function setAlbum(value) { + if (!state.artist) { state.album = ''; return; } + state.album = value || ''; + resetScrollToTop(); + reload(); + } + + async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } } + + // ── Provider-aware song helpers ──────────────────────────────────────── + // Remote library providers (feedBack-plugin-remote-library-*) expose songs + // by provider-owned id with their own art/sync/play flow. Reuse the legacy + // app.js globals (the shared engine) so v3 behaves identically for remote + // providers instead of assuming every row is a local file. All degrade to + // the local path when the helpers/providers aren't present. + function songId(s) { + return (window._librarySongId ? window._librarySongId(s) : (s.filename || '')) || ''; + } + function localFilename(s) { + return window._libraryLocalFilename ? window._libraryLocalFilename(s, state.provider) : (s.filename || ''); + } + // Stable per-card key: the local filename when present (local song, or a + // synced remote one), else the provider song id. + function cardKey(s) { return localFilename(s) || songId(s); } + + function artUrl(song) { + if (window._librarySongArtUrl) return window._librarySongArtUrl(song, state.provider); + const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : ''; + return song.filename ? '/api/song/' + enc(song.filename) + '/art' + v : ''; + } + + // Play a card: local (or already-synced remote) → playSong the local file; + // an unsynced remote song → sync it first, then play when ready. + function playCard(song, arrIdx) { + if (!song) return; + _saveLibraryScrollSnapshot(); + const lf = localFilename(song); + if (lf) { if (window.playSong) window.playSong(enc(lf), arrIdx); return; } + const sid = songId(song); + if (window.syncLibrarySong && sid) window.syncLibrarySong(state.provider, sid, { playWhenReady: true }); + } + + // 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 ''; + // Floor, never round: 100% must mean every note hit. + const pct = Math.floor(acc * 100); + if (variant === 'tree') { + const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low'; + return '' + pct + '%'; + } + 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 + '%'; + } + + // ── Metadata-refresh per-tile state (the "Refresh Metadata" batch) ───────── + // A transient badge painted ONLY while a metadata refresh is running: the + // songs actually being (re)matched animate queued → working → done. Keyed by + // the card's data-fn (= the local filename the enrichment cache keys on). + // Empty for every song outside a refresh, so an idle card is byte-identical + // to before (keeps the windowed grid's height math untouched). Honest state + // transitions, NOT a fake per-song %: a match is binary (design §11). + const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange' + // Cards whose enrichment landed 'failed' (from the grid payload) — tracked so + // the PERSISTENT "no match" badge survives a batch tile clearing (a + // _patchCardEnrich with no flag falls back to this instead of wiping it). + // Populated as cards render (enrichBadge is called per card with the flag). + const _unmatched = new Set(); + function enrichBadge(fn, unmatched) { + if (unmatched !== undefined) { if (unmatched) _unmatched.add(fn); else _unmatched.delete(fn); } + // A live batch tile wins over the resting no-match marker (they never + // coexist — the batch clears its tiles when it finishes). + const st = _metaTile[fn] || (_unmatched.has(fn) ? 'nomatch' : null); + if (!st) return ''; + const M = { + queued: ['bg-black/60 text-fb-textDim', '• Queued', ''], + working: ['bg-fb-primary text-white', '⟳ Matching…', ''], + done: ['bg-fb-good/90 text-black', '✓ Updated', ''], + nochange: ['bg-black/60 text-fb-textDim', '— No match', ''], + // Resting indicator: subtle, so a mostly-unmatched library isn't a + // wall of loud badges. Clickable — a one-click handoff into the + // Fix-metadata popup for this song (see the [data-meta-fix] wiring). + nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'Click to fix the metadata by hand'], + }; + const conf = M[st] || M.queued; + const fixable = st === 'nomatch'; // resting badge → opens Fix-metadata + // top-10 clears the tuning chip (top-2) in both normal and select mode; + // z-20 sits it above the art. Batch states are non-interactive; the + // resting "no match" badge is the handoff into the popup. + const cls = 'v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] + + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight ' + + (fixable ? 'pointer-events-auto cursor-pointer hover:bg-fb-primary hover:text-white transition-colors' : 'pointer-events-none'); + return '' + conf[1] + ''; + } + + // 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(); + + // Set when a library scan / DLC-folder change happened while this screen was + // off (or showing a stale, e.g. pre-DLC empty, grid). The grid's cached DOM / + // snapshot would otherwise survive a sidebar return, so we force a full + // re-fetch on the next entry. (feedBack — "No DLC until restart".) + let _libraryDirty = false; + + 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); + // A new score shifts the repertoire meter + the keep-practicing shelf. + renderLibraryHome(); + } + + // ── Practice-aware library home (repertoire meter + "Keep practicing") ───── + // The meter reads state.accuracy (/api/stats/best = {filename: best_accuracy}); + // the shelf reads the growth-edge recommender (/api/library/practice-suggestions, + // P3). 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 / the practice-suggestions endpoint), + // 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) + + // the growth-edge "practice next" shelf rows, fetched together. + const [stats, suggestions] = await Promise.all([ + jget('/api/library/stats?provider=' + enc(state.provider)), + jget('/api/library/practice-suggestions?limit=8'), + ]); + 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 = the growth-edge recommender (P3): attempted-but-not-mastered + // songs ordered by difficulty-appropriateness × mastery-proximity, so it + // points at the version worth practicing next rather than just the most + // recent. The server already gates (not-mastered) + aggregates per song, + // so the rows are the shelf as-is; each row's `arrangement` is the one + // closest to mastery (what a click should open). + const shelf = Array.isArray(suggestions) ? suggestions : []; + + const { mastered, learning } = _repertoireCounts(); + // Day-one zero-state (launch polish): no practice data and no real + // growth-edge rows → an invitational meter, never "0 of N". Starter + // rows are the server's no-attempts fallback, so they count as "no + // practice yet" too. + const starterShelf = shelf.length > 0 && !!shelf[0].starter; + const invitational = (mastered + learning) === 0 && (!shelf.length || starterShelf); + let meter; + if (invitational) { + meter = + '
' + + '
' + + 'Repertoire' + + 'grows as you master songs' + + '
' + + '
' + + '
'; + } else { + const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100))); + meter = + '
' + + '
' + + 'Repertoire' + + '' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') + + (learning ? ' · ' + learning + ' in progress' : '') + '' + + '
' + + '
' + + '
'; + } + + let shelfHtml = ''; + if (shelf.length) { + const cards = shelf.map((r) => + '').join(''); + // Starter rows → the invitational "Start here" framing; real + // growth-edge rows → the usual "Keep practicing". Same cards. + const header = starterShelf + ? '

Start here

' + + '
a few approachable songs to kick things off
' + : '

Keep practicing

'; + shelfHtml = + '
' + header + + '
' + cards + '
' + + '
'; + } + + 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; suggestions are + // always local-library rows, so no provider sync is needed). + host.querySelectorAll('.v3-kp-card').forEach((btn) => btn.addEventListener('click', () => { + const fn = btn.getAttribute('data-kp'); + const arr = btn.getAttribute('data-arr'); + if (!fn || !window.playSong) return; + _saveLibraryScrollSnapshot(); + window.playSong(enc(fn), arr === '' ? undefined : Number(arr)); + })); + } + + // Toggle/refresh the home block on view/sort/filter/search changes. + function updateLibraryHome() { + const host = document.getElementById('v3-lib-home'); + if (!host) return; + if (!libHomeVisible()) { host.classList.add('hidden'); return; } + renderLibraryHome(); + } + + // Source format of a song — prefer the server's `format` field, fall back + // to the filename extension. Returns '' for unknown. + function fmtLabel(song) { + let f = (song.format || '').toLowerCase(); + if (!f) { + const fn = (song.filename || '').toLowerCase(); + f = (fn.endsWith('.feedpak') || fn.endsWith('.sloppak')) ? 'sloppak' : ''; + } + return f === 'sloppak' ? 'FEEDPAK' : f === 'loose' ? 'FOLDER' : ''; + } + // Corner badge for art-based cards (sloppak accented, others muted). + function fmtBadge(song) { + const l = fmtLabel(song); + if (!l) return ''; + const c = l === 'FEEDPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim'; + return '' + l + ''; + } + + // Personal-layer badges (P2): a difficulty pip + a tag count, painted from the + // row payload P1 embeds (song.user_difficulty / song.tags). Sits at top-right + // and fades on hover so it yields to the action buttons that share that corner. + // Returns '' when the song has neither, so an un-annotated card is byte-for-byte + // what it was before P2 (keeps the windowed grid's height math untouched). + function personalBadges(song) { + const d = song.user_difficulty; + const tags = song.tags || []; + if (d == null && !tags.length) return ''; + let out = '
'; + if (d != null) out += '◆' + esc(d) + ''; + if (tags.length) out += '🏷' + tags.length + ''; + return out + '
'; + } + + // Clickable arrangement chips — one ').join(''); + } + + // ⚑ multi-chart chip (P5c, design §7.1): the persistent "other versions + // exist" cue on a grouped card. Rendered ONLY when the grouped query says + // this card stands for 2+ charts of one work (chart_count = work_display + // group_size, P5a) — a single-chart card emits nothing, so its markup stays + // byte-identical to an ungrouped card. First in the chip row + shrink-0 so + // the overflow-hidden row clips arrangement chips before it ever clips the + // cue. Clicking it opens the Charts drawer (see wireCards). + function chartsChipHtml(song) { + const n = song.chart_count; + if (!(n >= 2) || !song.work_key) return ''; + return ''; + } + + // ── Tuning-match flags (working-tuning PR 6) ─────────────────────────────── + // Colour each song's tuning chip by whether your CURRENT working tuning covers + // it: green = play it now, amber = needs a retune. Uses the tuner plugin's + // coverage check (async) + the host workingTuning state — BOTH feature-detected, + // so without them the chips render exactly as before. Decoration runs AFTER the + // (sync) window paint so scrolling stays snappy; a token cancels a superseded pass. + let _tuningDecorToken = 0; + // The instrument the current grid was queried/painted for, so a + // working-tuning change can tell a guitar<->bass SWITCH (re-query) from a + // retune within the same instrument (re-colour only). + let _lastRenderInstrument = null; + function _applyChipMatch(chip, stateName) { + chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400'); + chip.classList.add(stateName === 'match' ? 'bg-emerald-500' + : stateName === 'retune' ? 'bg-amber-400' : 'bg-fb-mid'); + if (!chip.dataset.baseTitle) chip.dataset.baseTitle = chip.getAttribute('title') || ''; + chip.setAttribute('title', chip.dataset.baseTitle + (stateName === 'match' + ? ' — matches your tuning' : stateName === 'retune' ? ' — needs a retune' : '')); + } + async function decorateTuningChips(grid) { + if (!grid) return; + const cov = window._tunerAutoOpen && window._tunerAutoOpen.coverageReport; + const hasWT = window.feedBack && window.feedBack.workingTuning + && typeof window.feedBack.workingTuning.get === 'function'; + if (typeof cov !== 'function' || !hasWT) return; // feature-detect → no flags + const token = ++_tuningDecorToken; + const chips = grid.querySelectorAll('[data-tuning-chip][data-tuning-offsets]'); + for (const chip of chips) { + const offs = chip.getAttribute('data-tuning-offsets').split(',').map(Number); + if (!offs.length || offs.some((n) => !isFinite(n))) continue; + // Pass the instrument so coverage uses the right base pitches (bass vs guitar). + const arrangement = chip.dataset.tuningBass === '1' ? 'Bass' : 'Lead'; + let rep = null; + try { rep = await cov({ tuning: offs, stringCount: offs.length, arrangement: arrangement }); } catch (_) { rep = null; } + if (token !== _tuningDecorToken) return; // superseded by a re-paint / tuning change + if (rep) _applyChipMatch(chip, rep.covered ? 'match' : 'retune'); + } + } + + function songCard(song) { + const fav = song.favorite; + const key = cardKey(song); + // §7.1 display-chart switch (P5e): under a chart-intrinsic filter the + // server may attach `display_chart` — the member that MATCHES the + // filter when the representative doesn't. The card SHOWS and PLAYS + // that chart, while the row identity (sort keys, data-fn, the + // accuracy/heart anchor = the preferred chart) stays the rep's. + const shown = song.display_chart ? Object.assign({}, song, song.display_chart) : song; + // In select mode the checkbox occupies top-2 left-2, so shift the + // tuning chip right (left-9) to avoid overlapping it. + // Bass players see the bass chart's tuning (guitar fallback) — the card + // must agree with the facet/filter or the grid contradicts the pills. + const shownTuning = shownTuningName(shown); + const tuningLabel = (typeof window.displayTuningName === 'function') + ? window.displayTuningName(shownTuning) + : (shownTuning || ''); + let tuning = ''; + if (tuningLabel) { + const rawOffsets = (typeof window.parseRawTuningOffsets === 'function') + ? (window.parseRawTuningOffsets(shownTuningOffsets(shown)) + || window.parseRawTuningOffsets(shownTuning)) + : null; + const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets + && typeof window.displayTuningTargets === 'function') + ? window.displayTuningTargets(rawOffsets, { tuningName: tuningLabel }) + : ''; + // Mark a tuning we INFERRED from the guitar chart (this song has no + // bass arrangement) so a bass player isn't shown a borrowed tuning + // as if it were their part's. `~` keeps the chip compact; the title + // spells it out. + const inferred = shown.tuning_inferred === true; + const badgeTitle = (targetNotes + ? ('Custom Tuning: ' + targetNotes) + : tuningLabel) + + (inferred ? ' — from the guitar chart (no bass arrangement)' : ''); + const pos = 'absolute top-2 ' + (state.selectMode ? 'left-9' : 'left-2'); + // Tag the chip with its offsets so decorateTuningChips() can colour it + // green (matches your current tuning) / amber (needs a retune) after paint. + // Also flag a bass-only song (every arrangement is a bass part) so coverage + // scores its bass tuning against the bass base pitches, not guitar — otherwise + // a 4-string bass tuning read as guitar can false-match a guitar player. + const chipArrs = shown.arrangements || []; + // Bass either because the chip is SHOWING the bass chart's tuning + // (a bass player on a song that has one), or because every + // arrangement is a bass part. Checked via libInstrument() rather + // than comparing the two names — they are EQUAL for most songs, so + // a value comparison would flag a guitarist's chip as bass. + const chipIsBass = (libInstrument() === 'bass' && !!shown.bass_tuning_name) + || (chipArrs.length > 0 + && chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || ''))); + const matchAttr = (rawOffsets && rawOffsets.length) + ? ' data-tuning-chip data-tuning-offsets="' + esc(rawOffsets.join(',')) + '"' + + (chipIsBass ? ' data-tuning-bass="1"' : '') : ''; + if (targetNotes) { + tuning = '' + + esc('Custom Tuning') + '
' + esc(targetNotes) + '
'; + } else { + tuning = '' + esc(tuningLabel) + (inferred ? ' ~' : '') + ''; + } + } + // Display-only (pointer-events-none) so a click falls through to the + // card's data-v3-play handler, which owns the toggle — avoids double-toggle. + const checkbox = state.selectMode + ? '' + : ''; + const arrChips = arrChipsHtml(shown); + const chartsChip = chartsChipHtml(song); + // Plugin-contributed card actions placed 'inline' (in the hover action + // row) or 'overlay' (centered over the art). Menu-placed actions live in + // the ⋮ menu (openCardMenu); rendering these here means plugins using + // those placements are no longer silently dropped. No bundled action + // uses them, so for the stock library both strings are empty — the card + // renders exactly as before. + const reg = sm && sm.libraryCardActions; + const acts = (reg && typeof reg.list === 'function') ? reg.list(song) : []; + const actBtn = (a) => + ''; + const inlineBtns = acts.filter((a) => a.placement === 'inline').map(actBtn).join(''); + const overlayActs = acts.filter((a) => a.placement === 'overlay'); + 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(shown) + personalBadges(song) + enrichBadge(key, song.unmatched) + overlay + + '
' + + inlineBtns + + '' + + '' + + '' + + '
' + + '
' + esc(shown.title) + '
' + + // Artist line → the artist page (PR-B, entry point 2). The text + // block sits OUTSIDE the data-v3-play hitbox, so making it a + // button steals no play clicks. Same classes/line-height as the + // plain div (uniform card height is what makes the windowed + // grid's absolute-position math exact); non-local providers and + // the pages-off setting keep the original inert div. + ((state.provider === 'local' && song.artist && state.artistPagesEnabled !== false) + ? '' + : '
' + esc(song.artist) + '
') + + // 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). + '
' + chartsChip + arrChips + '
' + + '
'; + } + + // Per-card action menu, built from the ui.library-card-injection registry + // (core Edit/Retune + any plugin-registered actions). + let _closeCardMenu = null; // tears down the currently-open card menu + its document closer + function openCardMenu(cardEl, song, anchorBtn, pos) { + // Fully close any already-open menu first — removing just the DOM node + // (as before) would orphan its document-level click closer. + if (_closeCardMenu) _closeCardMenu(); + const reg = sm && sm.libraryCardActions; + // Only show actions intended for the overflow menu — actions placed + // 'inline'/'overlay' get their own affordances on the card (see songCard). + // Undefined placement defaults to the menu. + const items = (reg ? reg.list(song) : []).filter((a) => !a.placement || a.placement === 'menu'); + const menu = document.createElement('div'); + // pos (right-click) positions the menu at the pointer via `fixed`; + // the ⋮ button keeps the absolute top-right anchor. + menu.className = 'v3-card-menu z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm ' + (pos ? 'fixed' : 'absolute top-10 right-2'); + // Multi-chart entries (P5d): a grouped grid row carries chart_count + + // work_key (P5a annotation), so the menu knows inline whether this card + // stands for versions. Local library only — the work API is local. + const canCharts = state.provider === 'local' && song.work_key && song.chart_count >= 2; + // Play follows the DISPLAYED chart (see wireCards) — under an intrinsic + // filter that's the matching member, not the representative. + const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song; + const rows = [ + { id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(playTarget.filename)); } }, + ...(canCharts ? [ + { id: '__charts', label: 'Charts (' + song.chart_count + ')…' }, + { id: '__playver', label: 'Play version ▸' }, + ] : []), + // Undo for the drawer's "Split out" (P5e) — a split chart is its + // own singleton card (no ⚑ chip), so this is its only way back. + ...(state.provider === 'local' && song.is_split + ? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []), + { id: '__playlist', label: 'Add to playlist' }, + { id: '__save', label: 'Save for later' }, + // Artist page (PR-B, entry point 1) — local library only (the + // page reads the local DB) and gated on the Settings toggle. + ...(state.provider === 'local' && song.artist && state.artistPagesEnabled !== false + ? [{ id: '__artist', label: 'Go to artist' }] : []), + ...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })), + // Metadata + file actions (R2) — local library only (they all + // address the local DB / filesystem). Both openers (⋮ and + // right-click) share this list, so parity is structural. + ...(state.provider === 'local' && song.filename ? [ + { id: '__fixmatch', label: 'Fix metadata…' }, + { id: '__cover', label: 'Change cover…' }, + { id: '__refreshmeta', label: 'Refresh metadata' }, + { id: '__getinfo', label: 'Get info…' }, + { id: '__remove', label: 'Remove from library', destructive: true }, + ] : []), + ]; + menu.innerHTML = rows.map((r) => + '').join(''); + if (pos) { + // Right-click: position the menu at the pointer (fixed, viewport- + // relative), clamped so it never spills off the right/bottom edge. + document.body.appendChild(menu); + const x = Math.min(pos.x, window.innerWidth - menu.offsetWidth - 8); + const y = Math.min(pos.y, window.innerHeight - menu.offsetHeight - 8); + menu.style.left = Math.max(8, x) + 'px'; + menu.style.top = Math.max(8, y) + 'px'; + } else { + cardEl.appendChild(menu); + } + // Tear down BOTH the menu and its document-level closer together, so a + // menu-item click doesn't leave the closer attached (it would otherwise + // leak, retaining this menu's closures until the next document click). + const closer = (e) => { if (!menu.contains(e.target) && e.target !== anchorBtn) closeMenu(); }; + function closeMenu() { menu.remove(); document.removeEventListener('click', closer); if (_closeCardMenu === closeMenu) _closeCardMenu = null; } + _closeCardMenu = closeMenu; + menu.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = b.getAttribute('data-act'); + // 'Play version ▸' swaps THIS menu's rows for the work's charts — + // it must expand in place, so it's the one entry that doesn't close. + if (id === '__playver') { await _expandPlayVersions(menu, song, closeMenu); return; } + closeMenu(); + if (id === '__play') { playCard(playTarget); return; } + if (id === '__charts') { openChartsDrawer(song.work_key, song); return; } + if (id === '__unsplit') { + if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged(); + return; + } + if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; } + if (id === '__save') { if (window.v3Saved) await window.v3Saved.toggle(song.filename); return; } + if (id === '__artist') { openArtistPage(song.artist); return; } + // Per-chart metadata actions follow the DISPLAYED chart (playTarget), + // like Play — under an intrinsic filter that's the matching member, + // not the group representative. (__remove stays on `song`: it needs + // the group's work_key/chart_count and pre-ticks the shown chart.) + if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; } + if (id === '__cover') { + if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album }); + return; + } + if (id === '__refreshmeta') { + // Silent on success (hearing-safe, like the rest of the match + // layer) — the re-match trickles in through the normal pass. + await jsend('POST', '/api/enrichment/refresh/' + enc(playTarget.filename)); + return; + } + if (id === '__getinfo') { openGetInfo(playTarget); return; } + if (id === '__remove') { await removeSongsFlow(song); return; } + if (reg) await reg.run(id, song, { source: 'v3-songs' }); + })); + // Tree rows ride the (ungrouped) artists endpoint, so they don't carry + // chart_count/work_key — resolve the work lazily and slot a + // "Charts (N)…" entry into the still-open menu when versions exist. + // Grid rows already carry both (chart_count defined ⇒ no fetch). + if (state.provider === 'local' && song.chart_count === undefined && song.filename) { + jget('/api/chart/' + enc(song.filename) + '/work').then((w) => { + if (!w || !menu.isConnected) return; + const addEntry = (label, run) => { + const b = document.createElement('button'); + b.className = 'w-full text-left px-3 py-1.5 hover:bg-fb-card/60 text-fb-text'; + b.textContent = label; + b.addEventListener('click', (e) => { e.stopPropagation(); closeMenu(); run(); }); + menu.appendChild(b); + }; + if (w.chart_count >= 2 && w.work_key) { + addEntry('Charts (' + w.chart_count + ')…', () => openChartsDrawer(w.work_key, song)); + } + if (w.is_split) { + addEntry('Rejoin other versions', async () => { + if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged(); + }); + } + }); + } + setTimeout(() => document.addEventListener('click', closer), 0); + } + + // 'Play version ▸' (P5d): swap the ⋮ menu's rows for the work's charts; + // picking one plays that chart directly. A one-off alternate play — the + // keeper/headline doesn't move (stats record to the played chart's own + // filename), which is the design's "casual try ≠ deliberate adopt". + async function _expandPlayVersions(menu, song, closeMenu) { + const data = await jget('/api/work/' + enc(song.work_key) + '/charts'); + if (!data || !Array.isArray(data.charts) || !data.charts.length || !menu.isConnected) return; + menu.innerHTML = data.charts.map((c) => { + const tl = (typeof window.displayTuningName === 'function') + ? window.displayTuningName(c.tuning_name || c.tuning) : (c.tuning_name || ''); + return ''; + }).join(''); + menu.querySelectorAll('[data-ver]').forEach((vb) => vb.addEventListener('click', (e) => { + e.stopPropagation(); + const fn = vb.getAttribute('data-ver'); + closeMenu(); + _saveLibraryScrollSnapshot(); + if (window.playSong) window.playSong(enc(fn)); + })); + } + + // ── Remove from library (R2) ───────────────────────────────────────────-- + // On a single-chart song: confirm + delete, as the Details drawer does. + // On a multi-chart work: "remove the song" is ambiguous — a grouped card + // stands for several files — so an interstitial lists EVERY version for + // select/multi-select and deletes exactly what the user picked, one file + // or the whole set. + async function removeSongsFlow(song) { + let wk = song.work_key, count = song.chart_count; + if (count === undefined && song.filename) { + // Flat-mode grid / tree rows don't carry the group annotation. + const w = await jget('/api/chart/' + enc(song.filename) + '/work'); + if (w) { wk = w.work_key; count = w.chart_count; } + } + if (wk && count >= 2) { + const data = await jget('/api/work/' + enc(wk) + '/charts'); + const charts = (data && data.charts) || []; + if (charts.length >= 2) { openVersionRemoveModal(song, charts); return; } + } + if (!(await _confirmRemove(song.title || song.filename, 1))) return; + await _deleteFiles([song.filename]); + } + + async function _confirmRemove(label, n) { + const what = n === 1 ? '"' + label + '"' : n + ' versions of "' + label + '"'; + if (typeof window.uiConfirm === 'function') { + return window.uiConfirm({ + title: 'Remove from library?', + html: 'Remove ' + esc(what) + ' from your library?' + + '

This permanently deletes the file' + (n === 1 ? '' : 's') + ' from disk. This cannot be undone.

', + confirmText: 'Remove', cancelText: 'Cancel', danger: true, + }); + } + return window.confirm('Remove ' + what + ' from your library? This deletes the file' + (n === 1 ? '' : 's') + ' from disk.'); + } + + async function _deleteFiles(files) { + for (const fn of files) { + try { await fetch('/api/song/' + enc(fn), { method: 'DELETE' }); } catch (_) { /* keep going */ } + } + try { _groupChanged(); } catch (_) { try { reload(); } catch (_) { /* */ } } + } + + // The multi-version interstitial: a centred modal (the Tidy-up idiom) + // listing all charts of the work with checkboxes — the card's own chart + // pre-checked — so "delete" does exactly what the user means, whether + // that's one file or the batch. + function openVersionRemoveModal(song, charts) { + const sel = new Set([song.display_chart ? song.display_chart.filename : song.filename]); + const overlay = document.createElement('div'); + overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } }; + function done() { overlay.remove(); document.removeEventListener('keydown', onKey); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); }); + document.body.appendChild(overlay); + + function render() { + const rows = charts.map((c) => { + const tl = (typeof window.displayTuningName === 'function') + ? window.displayTuningName(c.tuning_name || c.tuning) : (c.tuning_name || ''); + const meta = [tl, (c.arrangements || []).map((a) => a.name).join('/'), c.format] + .filter(Boolean).join(' · '); + return ''; + }).join(''); + const n = sel.size; + overlay.innerHTML = + '
' + + '

Remove versions of “' + esc(song.title || '') + '”

' + + '

This song has ' + charts.length + ' charts. Tick the ones to remove — files are deleted from disk and this cannot be undone.

' + + '
' + rows + '
' + + '
' + + '' + + '' + + '
'; + overlay.querySelectorAll('[data-rm]').forEach((cb) => cb.addEventListener('change', () => { + const fn = cb.getAttribute('data-rm'); + if (cb.checked) sel.add(fn); else sel.delete(fn); + render(); + })); + overlay.querySelector('[data-rm-cancel]')?.addEventListener('click', done); + overlay.querySelector('[data-rm-go]')?.addEventListener('click', async () => { + if (!sel.size) return; + done(); + await _deleteFiles([...sel]); + }); + } + render(); + } + + // ── Get info (R2) ──────────────────────────────────────────────────────-- + // File location + pack contents + the match verdict, from + // GET /api/chart/{fn}/fileinfo. Paths and identity values are rendered + // with .fb-selectable so they stay copyable under the v3 no-select default. + function _fmtBytes(n) { + if (!Number.isFinite(n)) return ''; + const u = ['B', 'KB', 'MB', 'GB']; + let i = 0; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return (i ? n.toFixed(1) : n) + ' ' + u[i]; + } + + async function openGetInfo(song) { + const info = await jget('/api/chart/' + enc(song.filename) + '/fileinfo'); + if (!info) return; + const overlay = document.createElement('div'); + overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } }; + function done() { overlay.remove(); document.removeEventListener('keydown', onKey); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); }); + + const row = (label, value, selectable) => value + ? '
' + label + '' + + '' + esc(value) + '
' + : ''; + const m = info.manifest || {}; + const ident = m.identity || {}; + const identLine = Object.keys(ident).map((k) => + k + ': ' + (Array.isArray(ident[k]) ? ident[k].join(', ') : ident[k])).join(' · '); + const match = info.match || {}; + const matchLine = match.match_state === 'manual' ? 'Pinned by you' + : match.match_state === 'matched' ? ('Matched (' + (match.match_source || 'auto') + + (match.match_score != null ? ', ' + Math.round(match.match_score * 100) + '%' : '') + ')') + : match.match_state === 'review' ? 'Waiting for review' + : match.match_state === 'failed' ? 'Not matched' + : 'Not scanned yet'; + const contents = [ + (m.arrangements || []).length ? (m.arrangements.length + ' arrangement' + (m.arrangements.length === 1 ? '' : 's') + ' (' + m.arrangements.join(', ') + ')') : '', + (m.stems || []).length ? ('stems: ' + m.stems.join(', ')) : '', + m.has_cover ? 'cover art' : 'no cover art', + m.has_lyrics ? 'lyrics' : '', + ].filter(Boolean).join(' · '); + + overlay.innerHTML = + '
' + + '
' + + '

' + esc(song.title || info.filename) + '

' + + '
' + + '
' + + row('Location', info.path, true) + + row('Folder', info.folder, true) + + row('Format', info.format === 'sloppak' ? 'Feedpak' : info.format) + + row('Size', _fmtBytes(info.size)) + + row('Modified', info.mtime ? new Date(info.mtime * 1000).toLocaleString() : '') + + (info.manifest ? ( + '
' + + row('Contents', contents) + + row('Authors', (m.authors || []).filter(Boolean).join(', ')) + + row('Identity', identLine || 'no identity keys authored', !!identLine) + ) : '') + + '
' + + row('Match', matchLine) + + (match.canon_artist ? row('Canonical', [match.canon_artist, match.canon_title, match.canon_album, match.canon_year].filter(Boolean).join(' — '), true) : '') + + '
'; + document.body.appendChild(overlay); + overlay.querySelector('[data-gi-x]')?.addEventListener('click', done); + } + + // ── Charts drawer (P5d, design §7.1 UX-2/3) ──────────────────────────────── + // The single deep-management surface for a work's charts. A body-appended + // slide-in panel (the filter-drawer idiom; body-appended like the playlist + // picker so it opens from any view) listing every chart of the work as a + // radiogroup — the checked row is the keeper the grid card plays. Clicking + // an unchecked row (or Enter/Space on it) = Set as preferred, one tap; + // "Reset to auto pick" appears when the keeper is your explicit pick. + // Writes go through the work-charts API and the drawer re-renders from the + // response (there's no server-side library event bus — the drawer is its + // own refresh); the grid re-fetches because the representative may have + // flipped. Global mode only — the slot-scoped (curated-album) mode is P6; + // the per-row Split escape hatch is P5e. + let _chartsPrevFocus = null; // focus to restore when the drawer closes + + function _chartsDrawerEls() { + let ov = document.getElementById('v3-charts-overlay'); + let dr = document.getElementById('v3-charts-drawer'); + if (!ov) { + ov = document.createElement('div'); + ov.id = 'v3-charts-overlay'; + ov.className = 'fixed inset-0 bg-black/50 z-40 hidden'; + ov.addEventListener('click', closeChartsDrawer); + document.body.appendChild(ov); + } + if (!dr) { + dr = document.createElement('aside'); + dr.id = 'v3-charts-drawer'; + dr.className = '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'; + dr.setAttribute('role', 'dialog'); + dr.setAttribute('aria-modal', 'true'); + dr.setAttribute('aria-label', 'Charts of this song'); + // a11y: Escape closes; Tab is trapped inside the open drawer + // (aria-modal alone doesn't trap for keyboard users); ArrowUp/Down + // move focus between the chart rows (focus only — selection stays + // on Enter/Space, since a native-radio "arrow = select" would fire + // a preferred write on every keystroke). + dr.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { e.preventDefault(); closeChartsDrawer(); return; } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const rows = [...dr.querySelectorAll('[role="radio"]')]; + if (!rows.length) return; + const i = rows.indexOf(document.activeElement); + if (i === -1) return; + e.preventDefault(); + rows[(i + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length].focus(); + return; + } + if (e.key !== 'Tab') return; + const foci = dr.querySelectorAll('button, [tabindex]:not([tabindex="-1"])'); + if (!foci.length) return; + const first = foci[0], last = foci[foci.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + }); + document.body.appendChild(dr); + } + return { ov: ov, dr: dr }; + } + + // One chart row. `checked` = the current keeper (pref or auto-pick). The + // filename line is deliberate: duplicate charts usually share title/artist, + // so the pack filename is often the only human-readable distinguisher. + function _chartRowHtml(c, data) { + const checked = c.is_representative; + const prefLabel = checked + ? (data.preferred_source === 'user' ? 'Preferred — your pick' : 'Preferred (auto)') + : ''; + const tuningLabel = (typeof window.displayTuningName === 'function') + ? window.displayTuningName(c.tuning_name || c.tuning) + : (c.tuning_name || ''); + const meta = [fmtLabel(c), tuningLabel, + (c.arrangements || []).map((a) => a.name).join('/'), + c.year ? String(c.year) : ''] + .filter(Boolean).join(' · '); + const acc = (typeof c.best_accuracy === 'number') + ? '' + Math.floor(c.best_accuracy * 100) + '%' + : 'not played'; + return ''; + } + + // A preferred/auto flip can change which chart the grid's card stands for — + // re-fetch the grid in place (scroll preserved; renderWindow refills from + // the current scrollTop). The tree lists every chart flat, so it's + // unaffected; rare curate action, so a full re-fetch is fine. + function _groupChanged() { + if (state.view === 'grid' && groupingActive()) loadGrid(true); + } + + function _renderChartsDrawer(data, opts) { + const els = _chartsDrawerEls(); + const dr = els.dr; + const head = data.charts.find((c) => c.is_representative) || data.charts[0]; + // Mastery-anchor heads-up (§7.1): shown once, ambiently, right after a + // switch — the headline may drop because history stays with each chart + // (motor mastery is arrangement-specific). Text only, no toast/sound. + const switchNote = (opts && opts.switched) + ? '
Practice history stays with each chart — your new pick starts from its own stats.
' + : ''; + dr.innerHTML = + '
' + + '
' + + '
' + + '

' + esc(head.title) + '

' + + '
' + esc(head.artist) + ' · ' + data.count + ' chart' + (data.count === 1 ? '' : 's') + '
' + + '
' + + '' + + '
' + + switchNote + + '
' + + data.charts.map((c) => _chartRowHtml(c, data)).join('') + + '
' + + (data.preferred_source === 'user' + ? '' + : '
Auto pick sticks with a chart you\'ve practised; otherwise most complete → newest. Tap a chart to pin your keeper.
') + + '
'; + + dr.querySelector('[data-charts-close]').addEventListener('click', closeChartsDrawer); + dr.querySelector('[data-charts-auto]')?.addEventListener('click', async () => { + const fresh = await jsend('DELETE', '/api/work/' + enc(data.work_key) + '/preferred'); + if (!fresh) return; + _renderChartsDrawer(fresh); + _groupChanged(); + dr.querySelector('[role="radio"][aria-checked="true"]')?.focus(); + }); + dr.querySelectorAll('[data-ch]').forEach((row) => { + const fn = row.getAttribute('data-ch'); + const setPreferred = async () => { + if (row.getAttribute('aria-checked') === 'true') return; // already the keeper + const fresh = await jsend('PUT', '/api/work/' + enc(data.work_key) + '/preferred', { filename: fn }); + if (!fresh) return; + _renderChartsDrawer(fresh, { switched: true }); + _groupChanged(); + dr.querySelector('[role="radio"][aria-checked="true"]')?.focus(); + }; + row.addEventListener('click', setPreferred); + row.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setPreferred(); } + }); + row.querySelector('[data-ch-play]')?.addEventListener('click', (e) => { + e.stopPropagation(); + closeChartsDrawer(); + _saveLibraryScrollSnapshot(); + if (window.playSong) window.playSong(enc(fn)); + }); + row.querySelector('[data-ch-pl]')?.addEventListener('click', (e) => { + e.stopPropagation(); + addFilenamesToPlaylist([fn]); + }); + row.querySelector('[data-ch-split]')?.addEventListener('click', async (e) => { + e.stopPropagation(); + const ok = await jsend('POST', '/api/chart/' + enc(fn) + '/split'); + if (!ok) return; + _groupChanged(); + // Re-read the ORIGINAL work — the split chart is gone from it + // (now its own card in the grid; its ⋮ menu offers the rejoin). + const fresh = await jget('/api/work/' + enc(data.work_key) + '/charts'); + if (fresh && Array.isArray(fresh.charts) && fresh.charts.length) _renderChartsDrawer(fresh); + else closeChartsDrawer(); + }); + }); + } + + async function openChartsDrawer(workKey, _song) { + if (!workKey) return; + const data = await jget('/api/work/' + enc(workKey) + '/charts'); + if (!data || !Array.isArray(data.charts) || !data.charts.length) return; + const els = _chartsDrawerEls(); + _chartsPrevFocus = document.activeElement; + _renderChartsDrawer(data); + els.ov.classList.remove('hidden'); + els.dr.classList.remove('translate-x-full'); + els.dr.querySelector('[role="radio"][aria-checked="true"]')?.focus(); + } + + function closeChartsDrawer() { + document.getElementById('v3-charts-overlay')?.classList.add('hidden'); + document.getElementById('v3-charts-drawer')?.classList.add('translate-x-full'); + if (_chartsPrevFocus && typeof _chartsPrevFocus.focus === 'function' && _chartsPrevFocus.isConnected) { + _chartsPrevFocus.focus(); + } + _chartsPrevFocus = null; + } + + // Global opener — the ⚑ chip routes through this, and other views/plugins + // (P2's details drawer, dashboards) can open the drawer without reaching + // into this module. + window.__fbOpenChartsDrawer = openChartsDrawer; + + function wireCards(scope) { + scope.querySelectorAll('[data-fn]').forEach((el) => { + if (el.dataset.wired) return; // don't double-bind on append/auto-fill + el.dataset.wired = '1'; + const fn = el.getAttribute('data-fn'); + const song = state.songsById[fn] || { filename: fn }; + // §7.1: when a chart-intrinsic filter attached a display_chart, + // the card SHOWS that member — so play actions target it too + // (its arrangement indices match the rendered chips). Identity + // actions (heart/save/playlist/menu registry) stay on the rep row. + const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song; + el.querySelectorAll('[data-v3-play]').forEach((pe) => pe.addEventListener('click', (e) => { + if (state.selectMode) { e.preventDefault(); toggleSelect(fn, el); return; } + playCard(playTarget); // local → play; unsynced remote → sync then play + })); + el.querySelector('[data-menu]')?.addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(el, song, e.currentTarget); }); + // Native right-click opens the same overflow menu at the pointer. + el.addEventListener('contextmenu', (e) => { e.preventDefault(); openCardMenu(el, song, null, { x: e.clientX, y: e.clientY }); }); + el.querySelectorAll('[data-arr]').forEach((ab) => ab.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = ab.getAttribute('data-arr'); + playCard(playTarget, idx === '' ? undefined : Number(idx)); + })); + // ⚑ charts chip → the Charts drawer (P5d). Never a card play — the + // chip row sits outside [data-v3-play]; stopPropagation is + // belt-and-braces. + el.querySelector('[data-charts]')?.addEventListener('click', (e) => { + e.stopPropagation(); + openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song); + }); + // "No match" badge → straight into the Fix-metadata popup for this + // song (the batch → fix handoff). stopPropagation so it doesn't also + // trigger the card's play. Follows the displayed chart, like the menu. + el.querySelector('[data-meta-fix]')?.addEventListener('click', (e) => { + e.stopPropagation(); + if (window.__fbFixMatch) window.__fbFixMatch(playTarget); + }); + // Artist line → the artist page (PR-B). In select mode the grid's + // capture-phase toggle intercepts first, so selection still wins. + el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => { + e.stopPropagation(); + openArtistPage(song.artist); + }); + el.querySelector('[data-fav]')?.addEventListener('click', async (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + try { + const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); + const d = await r.json(); + btn.textContent = d.favorite ? '♥' : '♡'; + btn.setAttribute('aria-pressed', d.favorite ? 'true' : 'false'); + // Swap exactly the idle colour this button was rendered with + // (grid = text-white, tree/List view = text-fb-textDim). The old + // hardcoded text-white toggle never removed the list view's + // text-fb-textDim, so the heart changed glyph but stayed dim + // (never turned red) until a re-search re-rendered the row. + const idle = btn.getAttribute('data-fav-idle') || 'text-white'; + btn.classList.toggle('text-fb-accent', d.favorite); + btn.classList.toggle(idle, !d.favorite); + song.favorite = d.favorite; // keep the model in sync for a re-render/recycle + } catch (err) { /* */ } + }); + el.querySelector('[data-save]')?.addEventListener('click', async (e) => { + e.stopPropagation(); + if (window.v3Saved) { const saved = await window.v3Saved.toggle(fn); e.currentTarget.classList.toggle('text-fb-primary', !!saved); } + }); + // Inline/overlay plugin card actions → run via the shared registry. + el.querySelectorAll('[data-act-card]').forEach((ab) => ab.addEventListener('click', async (e) => { + e.stopPropagation(); + const reg = sm && sm.libraryCardActions; + if (reg) await reg.run(ab.getAttribute('data-act-card'), song, { source: 'v3-songs' }); + })); + }); + } + + // ── Multi-select + batch actions ──────────────────────────────────────-- + function toggleSelect(fn, el) { + if (state.selected.has(fn)) state.selected.delete(fn); else state.selected.add(fn); + const on = state.selected.has(fn); + const cb = el.querySelector('[data-select]'); if (cb) cb.checked = on; + el.querySelector('[data-v3-play]')?.classList.toggle('ring-2', on); + el.querySelector('[data-v3-play]')?.classList.toggle('ring-fb-primary', on); + renderBatchBar(); + } + + // Bulletproof multi-select: in select mode a capture-phase click anywhere + // inside a [data-fn] row toggles the card and STOPS the event, so nothing (a + // per-card handler, a stray/legacy listener, an arrangement chip) can start + // playback. Attached ONCE to each persistent host (grid / tree / artist page) + // — their innerHTML is replaced on re-render but the host element survives, + // so a single bind never double-fires. Group headers / non-song chrome sit + // outside any [data-fn], so closest() is null and their native clicks pass + // through untouched. + function bindSelectGuard(hostEl) { + if (!hostEl) return; + hostEl.addEventListener('click', (e) => { + if (!state.selectMode) return; + const card = e.target.closest('[data-fn]'); + if (!card || !hostEl.contains(card)) return; + e.preventDefault(); + e.stopImmediatePropagation(); + toggleSelect(card.getAttribute('data-fn'), card); + }, true); + } + + function setSelectMode(on) { + state.selectMode = on; + if (!on) state.selected.clear(); + const btn = document.getElementById('v3-songs-select'); + if (btn) btn.className = btnCtrl + (on ? ' bg-fb-primary text-white' : ''); + reload(); // re-render cards with/without checkboxes + renderBatchBar(); + } + + function renderBatchBar() { + let bar = document.getElementById('v3-songs-batch'); + if (!state.selectMode || state.selected.size === 0) { if (bar) bar.remove(); return; } + if (!bar) { + bar = document.createElement('div'); + bar.id = 'v3-songs-batch'; + bar.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-fb-card border border-fb-border/60 rounded-full shadow-xl px-4 py-2'; + document.body.appendChild(bar); + } + bar.innerHTML = + '' + state.selected.size + ' selected' + + '' + + '' + + '' + + ''; + bar.querySelector('[data-batch="clear"]').addEventListener('click', () => { state.selected.clear(); reload(); renderBatchBar(); }); + bar.querySelector('[data-batch="saved"]').addEventListener('click', batchSave); + bar.querySelector('[data-batch="playlist"]').addEventListener('click', batchAddToPlaylist); + bar.querySelector('[data-batch="details"]').addEventListener('click', openBulkEdit); + } + + async function batchSave() { + const lists = (await jget('/api/playlists')) || []; + const saved = lists.find((p) => p.system_key === 'saved_for_later'); + let present = new Set(); + if (saved) { const pl = await jget('/api/playlists/' + saved.id); present = new Set(((pl && pl.songs) || []).map((s) => s.filename)); } + // Additive: only toggle (add) songs not already saved. + for (const fn of state.selected) { + if (!present.has(fn)) { + try { await fetch('/api/saved/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ } + } + } + finishBatch(); + } + + // Modern "Add to playlist" picker - replaces the old run-on numbered prompt + // ("1. Foo 2. Bar ...", which didn't scale past a couple of playlists). Shows a + // checkbox list of playlists with membership PRE-CHECK (a song already in a + // playlist shows checked; a multi-song selection shows the indeterminate box + // when only some are in), an inline "New playlist" row, and a search box once + // the list is long. Toggling a row adds/removes the given songs via the + // existing REST; only rows the user actually TOUCHED are changed. Resolves + // true if any change was applied, else null (cancelled / no-op). Shared by the + // per-card more-menu and the select-mode batch bar. + function openPlaylistPicker(fns) { + return new Promise((resolve) => { (async () => { + const lists = ((await jget('/api/playlists')) || []).filter((p) => !p.system_key); + // Pre-check membership: fetch each playlist's songs once (playlists are + // few, and this is a one-off on open, not a hot path). ALL selected in + // -> checked; SOME -> indeterminate. + const counts = await Promise.all(lists.map(async (p) => { + const pl = await jget('/api/playlists/' + p.id); + const has = new Set(((pl && pl.songs) || []).map((s) => s.filename)); + return fns.reduce((n, fn) => n + (has.has(fn) ? 1 : 0), 0); + })); + const rows = lists.map((p, i) => ({ + id: p.id, name: p.name, count: p.count || 0, + initial: counts[i] === fns.length ? 'all' : (counts[i] > 0 ? 'some' : 'none'), + checked: counts[i] === fns.length, touched: false, + })); + + const overlay = document.createElement('div'); + overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; + let query = ''; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(null); } }; + function done(applied) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(applied || null); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(null); }); + + const boxFor = (r) => r.touched ? (r.checked ? '☑' : '☐') + : (r.initial === 'all' ? '☑' : r.initial === 'some' ? '▣' : '☐'); + function rowHtml(r) { + return ''; + } + function listHtml() { + const q = query.trim().toLowerCase(); + const shown = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows; + if (!shown.length) return '

' + (rows.length ? 'No matches.' : 'No playlists yet - create one above.') + '

'; + return shown.map(rowHtml).join(''); + } + function bindRows() { + overlay.querySelectorAll('[data-pl]').forEach((b) => b.addEventListener('click', () => { + const r = rows.find((x) => String(x.id) === b.getAttribute('data-pl')); + if (!r) return; + r.touched = true; r.checked = !r.checked; + repaintList(); + })); + } + function repaintList() { const el = overlay.querySelector('[data-list]'); if (el) { el.innerHTML = listHtml(); bindRows(); } } + async function createNew() { + const inp = overlay.querySelector('[data-new]'); + const name = ((inp && inp.value) || '').trim(); + if (!name) return; + const created = await jsend('POST', '/api/playlists', { name }); + if (created && created.id) { + rows.unshift({ id: created.id, name: created.name || name, count: 0, initial: 'none', checked: true, touched: true }); + query = ''; paint(); + overlay.querySelector('[data-new]')?.focus(); + } + } + async function apply() { + // Act only where the final state differs from what's already stored. + const acts = rows.filter((r) => r.touched && ((r.checked && r.initial !== 'all') || (!r.checked && r.initial !== 'none'))); + for (const r of acts) { + for (const fn of fns) { + try { + if (r.checked) await fetch('/api/playlists/' + r.id + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); + else await fetch('/api/playlists/' + r.id + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' }); + } catch (e) { /* */ } + } + } + if (window.v3Playlists) { try { window.v3Playlists.refresh(); } catch (e) { /* */ } } + if (acts.length && window.fbNotify) { + try { window.fbNotify.show({ title: 'Playlists updated', message: 'Updated ' + acts.length + ' playlist' + (acts.length === 1 ? '' : 's'), icon: '🎵' }); } catch (e) { /* */ } + } + done(acts.length ? true : null); + } + function paint() { + overlay.innerHTML = + ''; + overlay.querySelector('[data-x]').addEventListener('click', () => done(null)); + overlay.querySelector('[data-done]').addEventListener('click', apply); + overlay.querySelector('[data-create]').addEventListener('click', createNew); + overlay.querySelector('[data-new]').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); createNew(); } }); + const s = overlay.querySelector('[data-search]'); + if (s) s.addEventListener('input', (e) => { query = e.target.value; repaintList(); }); + bindRows(); + } + + document.body.appendChild(overlay); + paint(); + (overlay.querySelector('[data-search]') || overlay.querySelector('[data-new]'))?.focus(); + })(); }); + } + + // Open the picker for the given song filenames. Shared by the select-mode + // batch bar and the per-card more-menu. Resolves truthy if a change was applied. + async function addFilenamesToPlaylist(filenames) { + const fns = Array.from(filenames || []); + if (!fns.length) return null; + return openPlaylistPicker(fns); + } + + async function batchAddToPlaylist() { + const pid = await addFilenamesToPlaylist(state.selected); + // Only tear down the multi-select when the add actually happened. A + // cancelled or failed picker returns null — preserve the selection (and + // skip the reload) so the user can retry, matching the pre-refactor + // behaviour where !ans / !pid returned early before finishBatch(). + if (pid) finishBatch(); + } + + function finishBatch() { + state.selected.clear(); + if (window.v3Playlists) { try { window.v3Playlists.refresh(); window.v3Playlists.refreshSaved(); } catch (e) { /* */ } } + reload(); renderBatchBar(); + } + + // Bulk personal-meta editor (P2) — apply-to-all over the current selection via + // the batch endpoint (one request). Additive + mixed-state safe: difficulty + // defaults to "Leave" (each song keeps its own value); tags ADD/REMOVE rather + // than replace, so a bulk action never silently wipes per-song data. Notes are + // inherently per-song, so they're deliberately not bulk-editable. + function openBulkEdit() { + const fns = [...state.selected]; + if (!fns.length) return; + // Tags present across the selection (from the embedded row payload) → one-tap + // removable chips, no extra fetch. + const present = new Set(); + fns.forEach((fn) => ((state.songsById[fn] && state.songsById[fn].tags) || []).forEach((t) => present.add(t))); + const bulk = { diff: 'keep', add: [], remove: new Set() }; + const norm = (raw) => String(raw || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 60); + + const overlay = document.createElement('div'); + overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } }; + function done() { overlay.remove(); document.removeEventListener('keydown', onKey); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); }); + document.body.appendChild(overlay); + + function render() { + const diffBtn = (val, label) => + ''; + const addChips = bulk.add.map((t) => '' + esc(t) + + '').join(''); + const rmChips = [...present].sort((a, b) => a.localeCompare(b)).map((t) => + '').join(''); + const nothing = bulk.diff === 'keep' && !bulk.add.length && !bulk.remove.size; + overlay.innerHTML = + '
' + + '
' + + '

Edit ' + fns.length + ' songs

' + + '
' + + + '
Difficulty (for you)
' + + '
' + diffBtn('keep', 'Leave') + diffBtn(1, '1') + diffBtn(2, '2') + diffBtn(3, '3') + diffBtn(4, '4') + diffBtn(5, '5') + diffBtn('clear', 'Clear') + '
' + + '
"Leave" keeps each song's own value; a number or Clear applies to all ' + fns.length + '.
' + + + '
Add tags to all
' + + '
' + (addChips || 'None') + '
' + + '
' + + '
' + + + (present.size ? '
Remove tags (present in the selection)
' + rmChips + '
' : '') + + + '
' + + '
' + + '
'; + + overlay.querySelectorAll('[data-bulk-x]').forEach((b) => b.addEventListener('click', done)); + overlay.querySelectorAll('[data-bd]').forEach((b) => b.addEventListener('click', () => { bulk.diff = b.getAttribute('data-bd'); render(); })); + overlay.querySelectorAll('[data-badd-rm]').forEach((b) => b.addEventListener('click', () => { const t = b.getAttribute('data-badd-rm'); const i = bulk.add.indexOf(t); if (i >= 0) bulk.add.splice(i, 1); render(); })); + overlay.querySelectorAll('[data-brm]').forEach((b) => b.addEventListener('click', () => { const t = b.getAttribute('data-brm'); if (bulk.remove.has(t)) bulk.remove.delete(t); else bulk.remove.add(t); render(); })); + const addInput = overlay.querySelector('[data-badd-input]'); + const addTag = () => { const t = norm(addInput && addInput.value); if (t && !bulk.add.includes(t)) { bulk.add.push(t); render(); overlay.querySelector('[data-badd-input]')?.focus(); } }; + overlay.querySelector('[data-badd-btn]')?.addEventListener('click', addTag); + addInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } }); + overlay.querySelector('[data-bulk-apply]')?.addEventListener('click', applyBulk); + } + + async function applyBulk() { + const body = { filenames: fns }; + if (bulk.diff === 'clear') body.set_difficulty = null; + else if (bulk.diff !== 'keep') body.set_difficulty = Number(bulk.diff); + if (bulk.add.length) body.add_tags = bulk.add; + if (bulk.remove.size) body.remove_tags = [...bulk.remove]; + if (!('set_difficulty' in body) && !body.add_tags && !body.remove_tags) { done(); return; } + // jsend returns null on HTTP error / network failure — don't tear down the + // selection or reload as if it worked. Surface the failure and let the user retry. + const res = await jsend('POST', '/api/songs/user-meta/batch', body); + if (!res) { + if (window.fbNotify) { try { window.fbNotify.show({ title: 'Bulk edit failed', message: 'Could not save your changes. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } } + return; + } + done(); + finishBatch(); + } + + render(); + overlay.querySelector('[data-badd-input]')?.focus(); + } + + async function jsend(method, url, body) { + 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 (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 + // 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 _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; + } + + // 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; + } + + 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 ''; + } + + // Signature of the card at absolute index i: real-card vs skeleton, plus the + // select-mode it was built under. A change here is the ONLY reason a recycled + // node must be rebuilt (a hole filled after a fetch, or select mode toggled) — + // otherwise the node is reused as-is across window slides. + function _cardSig(i) { + return (state.songs[i] ? 'r' : 's') + (state.selectMode ? '1' : '0'); + } + + function _buildCardNode(i) { + const s = state.songs[i]; + const tmp = document.createElement('div'); + tmp.innerHTML = s ? songCard(s) : _skeletonCard(); + const node = tmp.firstElementChild; + node.setAttribute('data-idx', String(i)); + node.setAttribute('data-sig', _cardSig(i)); + return node; + } + + // Reconcile the grid's children to exactly cover [start, end) in ascending + // index order, REUSING the card nodes that stay in-window. Sliding the window + // one row now mutates only the row that entered/left instead of tearing down + + // rebuilding (+ re-wiring) the whole ~60-card window every frame — that + // per-slide teardown was the main-thread stall behind the "library skips every + // so many scrolls, up or down" report (the stall buffers held-arrow key-repeats + // that then flush in a burst). wireCards()'s data-wired guard wires only the + // freshly-built nodes. + function _syncWindow(grid, start, end) { + // Pass 1: drop nodes that left the window, are untagged, or whose content + // signature is stale (skeleton→real, or select-mode toggled). What remains + // is a reusable, correctly-rendered subset in ascending DOM order. + for (const el of Array.from(grid.children)) { + const a = el.getAttribute('data-idx'); + const idx = a == null ? NaN : Number(a); + if (!(idx >= start && idx < end) || el.getAttribute('data-sig') !== _cardSig(idx)) { + el.remove(); + } + } + // Pass 2: walk [start, end) in order, reusing survivors and inserting new + // nodes into their correct slot; `ref` tracks the child expected next. + const existing = new Map(); + for (const el of grid.children) existing.set(Number(el.getAttribute('data-idx')), el); + let ref = grid.firstChild; + for (let i = start; i < end; i++) { + let node = existing.get(i); + if (!node) node = _buildCardNode(i); + if (node === ref) { + ref = ref.nextSibling; + } else { + grid.insertBefore(node, ref); + } + } + } + + // 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 }; + if (groupingActive()) extra.group = 1; + 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); + } + } + + // Empty-library dead-end card (launch polish): only for a genuinely empty + // LOCAL library — a search / filter / format narrowing that merely matched + // nothing keeps the plain blank grid (saying "empty" there would lie), and + // remote providers own their own emptiness. The inline grid-column style + // spans the card across the grid without a new Tailwind class. + function _emptyLibraryHtml() { + if (state.q || state.format || activeFilterCount() !== 0 || state.provider !== 'local') return ''; + return '
' + + '
Your library is empty
' + + '
Drop .sloppak files into your library folder, or use Upload above.
' + + '' + + '
'; + } + + 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 = _emptyLibraryHtml(); grid.style.top = '0px'; + state.winRange = { start: 0, end: 0 }; + if (grid.innerHTML) { + // The grid is absolutely positioned inside the sizer — give the + // sizer the card's height so it participates in layout. + sizer.style.height = grid.offsetHeight + 'px'; + grid.querySelector('[data-lib-empty-settings]')?.addEventListener('click', () => { + if (window.showScreen) window.showScreen('settings'); + }); + } + 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'; + _syncWindow(grid, start, end); // recycle in-window nodes; only the entering/leaving row rebuilds + wireCards(grid); + decorateTuningChips(grid); // colour tuning chips by working-tuning match (async, feature-detected) + 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') 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 ───────────────────────────────────────────── + // 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(); } + + 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. + // group must MATCH the grid's page fetches (see groupingActive) so the + // rail's per-letter counts sum to the same works total the sizer uses. + const railParams = { sort_letters: 1 }; + if (groupingActive()) railParams.group = 1; + const stats = await jget('/api/library/stats?' + queryParams(railParams).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; + // 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; } + const desc = state.sort.endsWith('-desc'); + const order = desc ? RAIL_BUCKETS.slice().reverse() : RAIL_BUCKETS; + // Roving tabindex: only the first present letter is in the tab order; + // the rest are reached with the arrow keys (see bindRailOnce). Avoids + // dumping up to 27 tab stops into the page. + let firstPresent = true; + rail.innerHTML = order.map((L) => { + const n = letters[L] || 0; + const present = n > 0; + const tabbable = present && firstPresent; + if (tabbable) firstPresent = false; + const name = L === '#' ? 'non-alphabetical' : L; + return ''; + }).join(''); + rail.classList.remove('hidden'); + bindRailOnce(); + } + + function _setRailActive(letter) { + railEl()?.querySelectorAll('.v3-azrail-letter').forEach((b) => { + b.classList.toggle('is-active', b.getAttribute('data-letter') === letter); + }); + } + function _showBubble(letter) { const b = railBubbleEl(); if (b) { b.textContent = letter; b.classList.remove('hidden'); } } + function _hideBubble() { railBubbleEl()?.classList.add('hidden'); } + + // 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; + // `smooth` animates the scroll (a discrete tap / keyboard jump). A drag-scrub + // passes `false` so each step snaps INSTANTLY: stacked smooth animations over + // the windowed grid lag and settle imprecisely, which is why a drag used to + // land somewhere other than the let-go letter. + async function jumpToLetter(letter, smooth = true) { + const grid = _gridEl(), sizer = _sizerEl(), main = document.getElementById('v3-main'); + if (!grid || !sizer || !main || state.view !== 'grid' || !letter) return; + _setRailActive(letter); + const myToken = ++_jumpToken; // a newer jump supersedes this one + 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 + } + 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 + const top = Math.max(0, sizerTop + targetRow * rowH - pad); + main.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' }); + requestWindowRender(); + } + + function bindRailOnce() { + const rail = railEl(); + if (!rail || rail._bound) return; + rail._bound = true; + let dragging = false, lastDrag = null, railBtns = []; + // Resolve the letter button under a viewport-Y from the per-drag cached + // list (avoids a querySelectorAll per pointermove), clamping past the + // top/bottom so a scrub off the ends still seeks the first/last letter. + const letterAtY = (y) => { + if (!railBtns.length) return null; + for (const el of railBtns) { const r = el.getBoundingClientRect(); if (y >= r.top && y <= r.bottom) return el; } + return y < railBtns[0].getBoundingClientRect().top ? railBtns[0] : railBtns[railBtns.length - 1]; + }; + // Seek to the letter under `y`. `smooth` on the initial press (a tap); + // instant during the scrub so the grid tracks the finger and RELEASE + // lands exactly on the let-go letter. + const seekToY = (y, smooth) => { + const el = letterAtY(y); + if (!el || el.disabled) return; + const L = el.getAttribute('data-letter'); + _showBubble(L); + if (L !== lastDrag) { lastDrag = L; jumpToLetter(L, smooth); } + }; + rail.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || e.isPrimary === false) return; // primary tap only; ignore right/middle-click + secondary touches + const btn = e.target.closest('.v3-azrail-letter'); + if (!btn) return; + railBtns = [...rail.querySelectorAll('.v3-azrail-letter')]; + dragging = true; lastDrag = null; + // Capture so a vertical scrub keeps seeking even if the pointer drifts + // off the thin rail horizontally. + try { rail.setPointerCapture(e.pointerId); } catch (_) { /* */ } + // Drive the jump from here, NOT from the click event: pointer capture + // retargets the follow-up click to the rail (never a letter), so a + // captured tap's click can't resolve a letter and used to no-op + // ("clicked O, nothing happened"). preventDefault() suppresses the + // text-selection / focus-scroll default; we re-focus below for kbd. + e.preventDefault(); + try { btn.focus({ preventScroll: true }); } catch (_) { /* */ } + seekToY(e.clientY, true); // jump on press → a tap lands immediately + }); + rail.addEventListener('pointermove', (e) => { + if (!dragging) return; + seekToY(e.clientY, false); // instant tracking during the scrub + }); + const end = () => { dragging = false; railBtns = []; _hideBubble(); }; + rail.addEventListener('pointerup', end); + rail.addEventListener('pointercancel', end); + // Keyboard activation only. A pointer-driven click (detail >= 1) is + // retargeted to the rail by pointer capture and can't resolve a letter, + // so the pointer path owns taps; act here only on Enter/Space, whose + // synthesized click has detail === 0. + rail.addEventListener('click', (e) => { + if (e.detail !== 0) return; + const btn = e.target.closest('.v3-azrail-letter'); + if (!btn || btn.disabled) return; + jumpToLetter(btn.getAttribute('data-letter')); + }); + 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 + // rows, so its height is measured (and re-measured on resize) instead of + // hard-coded. + function positionToolbar() { + const topbar = document.getElementById('v3-topbar'); + const bar = document.getElementById('v3-songs-toolbar'); + if (!topbar || !bar) return; + bar.style.top = topbar.offsetHeight + 'px'; + } + function bindToolbarReflow() { + if (state.resizeBound) return; + const topbar = document.getElementById('v3-topbar'); + if (!topbar) return; + state.resizeBound = true; + // Observe the topbar itself: its height changes with viewport width AND + // when the song search is toggled in/out on screen changes. ResizeObserver + // fires once on observe(), so this also fixes up the initial position + // regardless of render() vs syncActive() ordering. + if (typeof ResizeObserver === 'function') { + new ResizeObserver(positionToolbar).observe(topbar); + } else { + window.addEventListener('resize', positionToolbar, { passive: true }); + } + } + + // ── Tree ──────────────────────────────────────────────────────────────── + // ── Albums (album-condensed browse; consumes /api/library/albums) ───────── + // Album cards -> click -> a track list (reusing /api/library?artist=&album=) + // with Play-album (feeds the play-queue). Respects the active drawer filters. + async function loadAlbums() { + const host = document.getElementById('v3-songs-albums'); + if (!host) return; + host.innerHTML = '

Loading…

'; + const data = await jget('/api/library/albums?' + queryParams().toString()); + const albums = (data && data.albums) || []; + if (!albums.length) { host.innerHTML = '

No albums match.

'; return; } + host.innerHTML = + '
' + + albums.map((a, i) => + '').join('') + '
'; + host.querySelectorAll('[data-album]').forEach((b) => b.addEventListener('click', () => { + const a = albums[Number(b.getAttribute('data-album'))]; + if (a) openAlbum(a); + })); + } + // `opts` (PR-B): the artist page reuses this album detail inside its own + // host with its own back label/target — { host, backLabel, onBack, + // ignoreFilters }. Call sites without opts are byte-for-byte the original + // albums-view flow. + async function openAlbum(a, opts) { + const host = (opts && opts.host) || document.getElementById('v3-songs-albums'); + if (!host) return; + const backLabel = (opts && opts.backLabel) || '← Albums'; + const onBack = (opts && opts.onBack) || (() => loadAlbums()); + host.innerHTML = '

Loading…

'; + // Normally honour the active drawer filters (like the album grid) but pin + // THIS album's artist/album and force track order — so the track list and + // Play-album never include songs the user filtered out. When opened FROM + // an artist page (ignoreFilters), drop the global filters entirely: the + // artist page is the artist's whole shelf, so its album view must show + // every track to match the page's counts — scoped only to artist+album. + const p = (opts && opts.ignoreFilters) + ? new URLSearchParams({ provider: state.provider, artist: a.artist, album: a.album, size: '300', sort: 'track' }) + : queryParams({ artist: a.artist, album: a.album, size: '300', sort: 'track' }, { catalog: true }); + const data = await jget('/api/library?' + p.toString()); + const songs = (data && data.songs) || []; + host.innerHTML = + '' + + '
' + + '

' + esc(a.album) + '

' + + '

' + esc(a.artist) + ' · ' + songs.length + ' track' + (songs.length === 1 ? '' : 's') + '

' + + (songs.length ? '' : '') + + '
' + + ''; + host.querySelector('[data-albums-back]')?.addEventListener('click', () => onBack()); + host.querySelector('[data-album-playall]')?.addEventListener('click', () => { + const files = songs.map((s) => s.filename).filter(Boolean); + if (!files.length) return; + if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: a.album }); + else if (typeof window.playSong === 'function') window.playSong(enc(files[0])); + }); + host.querySelectorAll('[data-album-track]').forEach((b) => b.addEventListener('click', () => { + const s = songs[Number(b.getAttribute('data-album-track'))]; + if (s && window.playSong) window.playSong(enc(s.filename)); + })); + } + + // One list-row of a song — shared by the tree view and the artist page's + // song list, so wireCards() gives both the same play/chips/fav/save/⋮ + // behaviour from one markup source. + function treeSongRowHtml(s) { + const k = cardKey(s); const fl = fmtLabel(s); const chips = arrChipsHtml(s); const sel = state.selected.has(k); + // Display-only checkbox (pointer-events-none); the row's + // capture-phase select handler (render()) owns the toggle. + const checkbox = state.selectMode + ? '' + : ''; + return ( + '
' + + checkbox + + '' + + '' + esc(s.title) + '' + + (chips ? '' : '') + + (fl ? '' + fl + '' : '') + + accuracyBadge(k, 'tree') + + // Same fav / save-for-later / overflow-menu cluster as the grid + // card. Always shown (like the arrangement chips), not hover- + // revealed. wireCards() binds all three for any [data-fn]. + '
' + + '' + + '' + + '' + + '
' + + '
'); + } + + // ── Artist page (PR-B, artist-pages launch charrette) ────────────────────── + // An in-place sub-render like openAlbum(): the artist "in your library" — a + // shelf plus your relationship to it, never a discography browser (locked + // position 1). Renders 100% from the local /page payload; the external + // links row is the one decorated extra, gated on the opt-in Settings toggle + // AND a MusicBrainz match, fetched lazily and cached server-side. Every + // count obeys the DENOMINATOR LAW (locked position 2): songs YOU OWN. + + function _artistHostEl() { return document.getElementById('v3-songs-artistpage'); } + + // ── Instrument-aware tuning (the bass-player tuning-filter report) ──────── + // A song's bass chart is often in a different tuning from its guitar chart, + // so the tuning facet/filter/sort and the card chip must speak for the + // instrument the player actually plays. The host's working-tuning + // capability already holds the live selection (seeded from /api/settings on + // boot, updated when the player switches) — read it rather than adding + // another settings fetch. `state.settingsInstrument` is the fallback for + // hosts where the capability isn't mounted. + // Three perspectives, matching `active_instrument_profile`: lead and rhythm + // guitar charts can be tuned differently too, so a rhythm player hits the + // same bug a bassist did. The PROFILE is the only three-valued source (the + // working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm), + // so it wins; the capability is the live fallback for hosts where the + // profile hasn't loaded. + const PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass']; + function libInstrument() { + if (PERSPECTIVES.indexOf(state.settingsProfile) >= 0) return state.settingsProfile; + try { + const wt = window.feedBack && window.feedBack.workingTuning; + if (wt && typeof wt.get === 'function') { + const cur = wt.get(); + if (cur && cur.instrument === 'bass') return 'bass'; + } + } catch (_) { /* capability absent/erroring — fall through to settings */ } + return state.settingsInstrument === 'bass' ? 'bass' : 'guitar-lead'; + } + + // "Playable without retuning" mode reads the player's CURRENT tuning from + // the working-tuning capability (the live session state the tuner writes), + // not a separate setting. No capability => we cannot know the current + // tuning, so the mode is unavailable rather than guessed. + function currentWorkingTuning() { + try { + const wt = window.feedBack && window.feedBack.workingTuning; + if (!wt || typeof wt.get !== 'function') return null; + const cur = wt.get(); + if (!cur || !Array.isArray(cur.offsets) || !cur.offsets.length) return null; + return cur; + } catch (_) { return null; } + } + + function playableAvailable() { return !!currentWorkingTuning(); } + + function applyPlayableParams(p, f) { + if (f.tuningMatch !== 'playable') return; + const cur = currentWorkingTuning(); + if (!cur) return; + p.set('tuning_match', 'playable'); + p.set('playable_offsets', cur.offsets.join(',')); + p.set('playable_instrument', cur.instrument === 'bass' ? 'bass' : 'guitar'); + p.set('playable_string_count', String(cur.stringCount || cur.offsets.length)); + } + + // Short human label for the perspective, for the facet/sort headers. + function libInstrumentLabel() { + const p = libInstrument(); + return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead'; + } + + // The column a row's tuning lives in for the active perspective. + function perspectiveTuningField() { + const p = libInstrument(); + return p === 'bass' ? 'bass_tuning_name' + : p === 'guitar-rhythm' ? 'rhythm_tuning_name' : ''; + } + + // The tuning a card should SHOW: bass players see the bass chart's tuning, + // falling back to the song (guitar-derived) tuning when the song has no + // bass arrangement — the common case, so the fallback is not an edge path. + function shownTuningName(song) { + const f = perspectiveTuningField(); + if (f && song[f]) return song[f]; + return song.tuning_name || song.tuning; + } + + function shownTuningOffsets(song) { + const f = perspectiveTuningField(); + if (f && song[f]) { + return song[f.replace('_name', '_offsets')] || song.tuning_offsets; + } + return song.tuning_offsets; + } + + // Sync the two Settings gates into module state (fire-and-forget — the + // cached flags gate entry-point rendering; openArtistPage re-checks). + function refreshArtistPageGates() { + return jget('/api/settings').then((cfg) => { + if (!cfg) return; + state.artistPagesEnabled = cfg.artist_pages_enabled !== false; + state.artistLinksEnabled = cfg.artist_external_links === true; + // Fallback instrument for hosts without the working-tuning capability. + state.settingsInstrument = cfg.instrument === 'bass' ? 'bass' : 'guitar'; + // The three-valued perspective source (lead / rhythm / bass). + state.settingsProfile = cfg.active_instrument_profile || ''; + }); + } + + // 2×2 mosaic of the artist's OWN album art — the playlist-cover grammar + // (#626 playlistCoverHtml) adapted to the page payload's art_urls. Never a + // broken-image tile: no art → a quiet glyph. + function artistMosaicHtml(arts) { + const box = 'w-32 h-32 sm:w-40 sm:h-40 shrink-0 rounded-xl overflow-hidden bg-fb-card'; + const img = (u) => ''; + if (!arts || !arts.length) return '
🎤
'; + if (arts.length < 4) return '
' + img(arts[0]) + '
'; + return '
' + arts.slice(0, 4).map(img).join('') + '
'; + } + + function _linkDomain(u) { + try { return new URL(u).hostname.replace(/^www\./, ''); } catch (_) { return ''; } + } + + // Toggle the browse hosts (grid/tree/albums/folder + home + rail) so the + // artist page can own the scroller, and back again on close. + function _setBrowseHostsHidden(hidden) { + if (hidden) { + ['v3-songs-gridsizer', 'v3-songs-tree', 'v3-songs-albums', 'lib-folder-tree', + 'v3-lib-home', 'v3-songs-azrail', 'v3-songs-azbubble'] + .forEach((id) => document.getElementById(id)?.classList.add('hidden')); + const fc = document.getElementById('lib-folder-controls'); + if (fc) fc.style.display = 'none'; + } else { + document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid'); + document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree'); + document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums'); + 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'; + refreshRail(); + updateLibraryHome(); + } + } + + // The one exported opener — every entry point (card ⋮ / right-click "Go to + // artist", the grid card's artist line, the Details drawer link, a + // similar-artist chip) funnels through here. + async function openArtistPage(artistName) { + const host = _artistHostEl(); + if (!host || !artistName) return; + if (state.provider !== 'local' || state.artistPagesEnabled === false) return; + const main = _getV3MainScroller(); + // Remember where browsing left off ONCE — chip-hopping between artist + // pages keeps the original return point. + if (!state.artistPage) state.artistReturnScroll = main ? main.scrollTop : 0; + state.artistPage = artistName; + _setBrowseHostsHidden(true); + host.classList.remove('hidden'); + host.innerHTML = '

Loading…

'; + _applyMainScrollTop(0); + const page = await jget('/api/artist/' + enc(artistName) + '/page'); + if (state.artistPage !== artistName) return; // superseded + if (!page) { closeArtistPage(); return; } + await renderArtistPage(page); + } + + function closeArtistPage() { + const host = _artistHostEl(); + if (host) { host.classList.add('hidden'); host.innerHTML = ''; } + if (!state.artistPage) return; + state.artistPage = null; + _setBrowseHostsHidden(false); + const top = state.artistReturnScroll; + state.artistReturnScroll = null; + _applyMainScrollTop(top || 0); + if (state.view === 'grid') requestWindowRender(); + } + + // reload() (any toolbar-driven change) leaves the sub-page without the + // scroll restore — the new state describes a fresh browse from the top. + function _dropArtistPageSilently() { + if (!state.artistPage) return; + state.artistPage = null; + state.artistReturnScroll = null; + const host = _artistHostEl(); + if (host) { host.classList.add('hidden'); host.innerHTML = ''; } + } + + async function renderArtistPage(page) { + const host = _artistHostEl(); + if (!host) return; + const me = state.artistPage; + const name = page.artist || me || ''; + // Songs list: page through /api/library with the artist filter (locked + // position 6 — query_page, keyset-safe; never the DISTINCT+OFFSET + // query_artists path). Unfiltered on purpose: the page is the artist's + // whole shelf, not the grid's current filter view. + const songs = []; + let p = 0, total = Infinity; + while (songs.length < total) { + const q = new URLSearchParams({ + provider: 'local', artist: name, sort: 'artist', + size: '100', page: String(p), + }); + const data = await jget('/api/library?' + q.toString()); + if (!data || !Array.isArray(data.songs)) break; + songs.push(...data.songs); + total = (data.total != null) ? data.total : songs.length; + if (!data.songs.length || p > 50) break; // safety: no progress / runaway + p++; + } + if (state.artistPage !== me || !host.isConnected) return; // superseded mid-fetch + songs.forEach((s) => { state.songsById[cardKey(s)] = s; }); + + const aliasLine = (page.variants || []).length + ? '
also shown as: ' + + page.variants.map((v) => esc(v.name) + ' ×' + v.count).join(' · ') + '
' + : ''; + // Provenance pill — only when the artist is actually matched (drawer/ + // Get-info grammar: say where the tidy names come from, ≤2 taps away). + const pill = page.mb_artist_id + ? '
Matched · MusicBrainz
' + : ''; + // Stats strip. DENOMINATOR LAW: every number is songs in YOUR library; + // the mastered segment is omitted entirely until one exists — + // invitational, never "0 mastered" (launch blind-spot #3). + const bits = [ + page.song_count + ' song' + (page.song_count === 1 ? '' : 's'), + page.album_count + ' album' + (page.album_count === 1 ? '' : 's'), + ]; + if (page.mastered_count > 0) bits.push(page.mastered_count + ' mastered'); + + const albumsHtml = (page.albums || []).length + ? '

Albums

' + + '
' + + page.albums.map((al, i) => + '').join('') + + '
' + : ''; + + const songsHtml = songs.length + ? '

Songs

' + + '
' + songs.map(treeSongRowHtml).join('') + '
' + : '

No songs by this artist are in your library.

'; + + // Similar in your library (locked position 3): genre co-occurrence over + // artists you already OWN — never an acquisition funnel. Empty → the + // whole module hides (never "Similar: none"). + const similarHtml = (page.similar || []).length + ? '

Similar in your library

' + + '
' + + page.similar.map((s) => + '').join('') + + '
' + : ''; + + host.innerHTML = + '' + + '
' + + artistMosaicHtml(page.art_urls) + + '
' + + '

' + esc(name) + '

' + + aliasLine + pill + + '

' + bits.join(' · ') + '

' + + '
' + + (songs.length + ? '' + + '' + : '') + + '' + + '
' + + '
' + + albumsHtml + + songsHtml + + similarHtml + + // External links land here (lazy fetch) — hidden until they exist. + '
'; + + host.querySelector('[data-ap-back]')?.addEventListener('click', closeArtistPage); + // Play all / Shuffle → the shared playQueue (same path as Play-album). + const startQueue = (shuffle) => { + let files = songs.map((s) => s.filename).filter(Boolean); + if (!files.length) return; + if (shuffle) { + files = files.slice(); + for (let i = files.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = files[i]; files[i] = files[j]; files[j] = t; + } + } + _saveLibraryScrollSnapshot(); + if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: name }); + else if (typeof window.playSong === 'function') window.playSong(enc(files[0])); + }; + host.querySelector('[data-ap-playall]')?.addEventListener('click', () => startQueue(false)); + host.querySelector('[data-ap-shuffle]')?.addEventListener('click', () => startQueue(true)); + // Save as smart playlist (locked position 12): a rules-based + // collection over the existing machinery — a LIVING query that + // regenerates, never a completable checklist. + host.querySelector('[data-ap-smart]')?.addEventListener('click', async (e) => { + const btn = e.currentTarget; + const res = await jsend('POST', '/api/collections', { name: name, rules: { artist: name } }); + if (res && res.ok) { + btn.textContent = '✓ Saved'; + btn.disabled = true; + if (window.fbNotify) { + try { window.fbNotify.show({ title: 'Smart playlist saved', message: '“' + name + '” is now a source in the library picker', icon: '🎵' }); } catch (_) { /* */ } + } + } + }); + // Album cells reuse the album detail in place; back returns HERE. + host.querySelectorAll('[data-ap-album]').forEach((b) => b.addEventListener('click', () => { + const al = (page.albums || [])[Number(b.getAttribute('data-ap-album'))]; + if (!al) return; + openAlbum({ artist: name, album: al.name }, + { host: host, backLabel: '← ' + name, onBack: () => openArtistPage(name), ignoreFilters: true }); + })); + // Similar chips → that artist's page (the return point stays the + // original browse position — see openArtistPage). + host.querySelectorAll('[data-ap-similar]').forEach((b) => b.addEventListener('click', () => { + openArtistPage(b.getAttribute('data-ap-similar')); + })); + wireCards(host); + decorateTuningChips(host); // feature-detected; no-op without the capability + _fillArtistLinks(host, name, page); + } + + // External links row (locked position 4): whitelisted MB url-rels, opt-in + // via Settings, always the external browser, domain visible. Renders ONLY + // when the toggle is on AND the fetch yields links — otherwise the section + // simply never appears (empty modules hide). + async function _fillArtistLinks(host, name, page) { + if (!state.artistLinksEnabled || !page.mb_artist_id) return; + const slot = host.querySelector('[data-ap-links]'); + if (!slot) return; + const data = await jget('/api/artist/' + enc(name) + '/links'); + // slot.isConnected covers every superseded case — navigating away, a + // reload, or hopping to another artist all replace this DOM. + if (!data || !slot.isConnected) return; + const links = data.links || {}; + const items = []; + const push = (label, url) => { if (url) items.push({ label: label, url: url }); }; + push('Official site', links.official); + push('Tour dates', links.tour); + push('Videos', links.video); + (Array.isArray(links.social) ? links.social : []).forEach((u) => push('Social', u)); + push('Wikipedia', links.wikipedia); + if (!items.length) return; + slot.innerHTML = + '
' + + '
On the web · opens your browser
' + + '
' + + items.map((it) => + '' + + esc(it.label) + ' ↗ ' + esc(_linkDomain(it.url)) + '').join('') + + '
'; + } + + // Global opener — the drawer link, plugins, and other views reach the page + // without touching this module's internals. + window.__fbOpenArtistPage = openArtistPage; + + async function loadTree() { + const host = document.getElementById('v3-songs-tree'); + if (!host) return; + // The list view always groups artist -> album (query_artists has no + // free sort) — when the picked sort is something else, say so instead + // of silently ignoring it ("why does the sort do nothing here?"). + const _treeSortNote = railSortColumn() === 'artist' ? '' + : '

List view groups by artist — the selected sort applies to the card grid.

'; + // Capture expanded groups BEFORE the "Loading…" wipe below, so a reload + // (e.g. toggling select mode) restores them instead of collapsing all. + const openArtists = new Set( + [...host.querySelectorAll('details[open]')].map((d) => d.getAttribute('data-artist'))); + host.innerHTML = '

Loading…

'; + // Page through ALL artists — the endpoint clamps size to 100, so a + // single request would silently truncate libraries with >100 artists. + const artists = []; + let page = 0, total = Infinity; + while (artists.length < total) { + const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }).toString()); + if (!data || !Array.isArray(data.artists)) break; + artists.push(...data.artists); + total = (data.total_artists != null) ? data.total_artists : artists.length; + if (!data.artists.length || page > 1000) break; // safety: no progress / runaway guard + page++; + } + if (!artists.length) { host.innerHTML = '

Nothing here.

'; return; } + artists.forEach((a) => (a.albums || []).forEach((al) => (al.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; }))); + host.innerHTML = _treeSortNote + artists.map((a) => + '
' + + '' + esc(a.name) + '' + esc(a.song_count) + '' + + '
' + (a.albums || []).map((al) => + '
' + esc(al.name || 'Unknown') + '
' + + (al.songs || []).map(treeSongRowHtml).join('') + '
').join('') + '
').join(''); + wireCards(host); + } + + // ── Filter drawer ─────────────────────────────────────────────────────-- + function triState(list_has, list_lacks, value) { + if (list_has.includes(value)) return 'has'; + if (list_lacks.includes(value)) return 'lacks'; + return 'any'; + } + function cycleTri(hasArr, lacksArr, value) { + const s = triState(hasArr, lacksArr, value); + const rm = (a) => { const i = a.indexOf(value); if (i >= 0) a.splice(i, 1); }; + rm(hasArr); rm(lacksArr); + if (s === 'any') hasArr.push(value); + else if (s === 'has') lacksArr.push(value); + // 'lacks' → cycles back to any (already removed) + } + function triPill(group, value, label, st, title) { + const cls = st === 'has' ? 'bg-fb-good/30 text-fb-good border-fb-good/40' + : st === 'lacks' ? 'bg-fb-low/30 text-fb-low border-fb-low/40' + : 'bg-gray-800/50 text-fb-textDim border-gray-700'; + const mark = st === 'has' ? '✓ ' : st === 'lacks' ? '✕ ' : ''; + const tip = title ? ' title="' + esc(title) + '"' : ''; + return ''; + } + function renderDrawer() { + const d = document.getElementById('v3-songs-drawer'); + if (!d) return; + const f = state.filters; + d.innerHTML = + '
' + + '

Filters

' + + '
' + + section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) + + section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('') + + (() => { + // One-click "which songs still need splitting": lacks EVERY + // instrument stem — the same query Stem Splitter's own + // missing-stems view runs. Toggles off if already active. + const on = STEMS.every((s) => f.stem_lacks.includes(s)) && !f.stem_has.length; + return ''; + })()) + + section('Lyrics', ['', '1', '0'].map((v) => '').join('')) + + // Progress (mastery bands) — multi-select; server filters via song_stats. + section('Progress', [['mastered', 'Mastered'], ['in_progress', 'In progress'], ['not_started', 'Not started']].map((it) => '').join('')) + + // Match (P8) — the song's metadata-match lifecycle state, a triage + // facet for the enrichment layer. Session-only, like Progress. + section('Match', [['review', 'To review'], ['matched', 'Matched'], ['unmatched', 'Unmatched'], ['pending', 'Not scanned']].map((it) => '').join('')) + + // Genre facet — dynamic list from /api/library/genres (primary genre). + (state.genres && state.genres.length ? section('Genre', state.genres.map((g) => '').join('')) : '') + + // The facet header NAMES the perspective. Silent instrument-following + // is the original bug in a new place: the user must be able to tell + // which instrument these tunings describe. + section('Tuning (' + libInstrumentLabel() + ')', + // MODE toggle. Exact match answers "which tuning is this + // labelled"; Playable answers "will this cost me a retune" — + // which is what a player actually wants. Both are offered; + // exact stays the default so nothing changes unasked. + '
' + + [['exact', 'Exact tuning'], ['playable', 'Playable without retuning']].map((m) => { + const on = (f.tuningMatch || 'exact') === m[0]; + const dis = m[0] === 'playable' && !playableAvailable(); + return ''; + }).join('') + + '
' + + (f.tuningMatch === 'playable' + ? '
Charts you can play in your current tuning, no retune. Songs whose lowest string sits below yours are excluded.
' + : '') + + ((state.tuningNames || []).map((t) => { + // Filter on the server's grouping key (raw offsets for customs) + // so two "Custom Tuning" entries are distinct; show their target + // notes in the label so they're distinguishable. + const val = t.key || t.name; + let label = t.name; + if (t.name === 'Custom Tuning' && t.offsets + && typeof window.parseRawTuningOffsets === 'function' + && typeof window.displayTuningTargets === 'function') { + const offs = window.parseRawTuningOffsets(t.offsets); + const notes = offs ? window.displayTuningTargets(offs, { tuningName: t.name }) : ''; + if (notes) label = 'Custom · ' + notes; + } + // Be honest about the fallback: when some of a row's songs have + // no bass chart and are borrowing the guitar tuning, say so + // rather than presenting a borrowed tuning as a measured one. + const inf = t.inferred_count || 0; + const title = inf + ? inf + ' of ' + t.count + ' inferred from the guitar chart (no bass arrangement)' + : ''; + const countLabel = inf ? t.count + ', ' + inf + ' inferred' : String(t.count); + return triPill('tuning', val, label + ' (' + countLabel + ')', + f.tunings.includes(val) ? 'has' : 'any', title); + }).join('') || 'No tunings')) + + // Multi-chart grouping toggle (P5e) — a VIEW mode, not a filter + // (never counted in the badge, never saved into collection rules). + // Local provider only: it's the one that implements group=. + (state.provider === 'local' + ? section('Grouping', '') + : '') + + // Collections always replay against the LOCAL library, so only offer + // "save" when browsing local with a non-empty filter set. + (state.provider === 'local' && Object.keys(currentFilterRules()).length + ? '
' + : '') + + // Artist canonicalization (P4) — local library only (aliases apply to + // the local catalog). Opens the Tidy-up modal to merge "ACDC"/"AC/DC". + (state.provider === 'local' + ? '
' + : '') + + '
' + + '
'; + + d.querySelectorAll('[data-tri]').forEach((b) => b.addEventListener('click', () => { + const g = b.getAttribute('data-tri'), v = b.getAttribute('data-val'); + if (g === 'arr') cycleTri(f.arr_has, f.arr_lacks, v); + else if (g === 'stem') cycleTri(f.stem_has, f.stem_lacks, v); + else if (g === 'tuning') { const i = f.tunings.indexOf(v); if (i >= 0) f.tunings.splice(i, 1); else f.tunings.push(v); } + renderDrawer(); + })); + d.querySelectorAll('[data-tuning-match]').forEach((b) => b.addEventListener('click', () => { + if (b.disabled) return; + f.tuningMatch = b.getAttribute('data-tuning-match'); + renderDrawer(); + })); + d.querySelector('[data-stem-unsplit]')?.addEventListener('click', () => { + const on = STEMS.every((s) => f.stem_lacks.includes(s)) && !f.stem_has.length; + f.stem_has.length = 0; + f.stem_lacks.length = 0; + if (!on) f.stem_lacks.push(...STEMS); + renderDrawer(); + }); + d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); })); + d.querySelectorAll('[data-mastery]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-mastery'); const i = f.mastery.indexOf(v); if (i >= 0) f.mastery.splice(i, 1); else f.mastery.push(v); renderDrawer(); })); + d.querySelector('[data-grouping]')?.addEventListener('click', () => { + state.grouping = !(state.grouping !== false); + renderDrawer(); + reload(); // re-fetches grid + rail with/without group=1, saves prefs + }); + d.querySelectorAll('[data-match]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-match'); const i = f.match.indexOf(v); if (i >= 0) f.match.splice(i, 1); else f.match.push(v); renderDrawer(); })); + d.querySelectorAll('[data-genre]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-genre'); const i = f.genre.indexOf(v); if (i >= 0) f.genre.splice(i, 1); else f.genre.push(v); renderDrawer(); })); + d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection); + d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp); + d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer); + d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => { + state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' }; + state.artist = ''; + state.album = ''; + renderDrawer(); + await loadArtistCatalog(); + refreshArtistAlbumSelects(); + reload(); + }); + d.querySelector('[data-drawer-apply]')?.addEventListener('click', async () => { + closeDrawer(); + await loadArtistCatalog(); + refreshArtistAlbumSelects(); + reload(); + }); + } + function section(label, inner) { + return '
' + label + '
' + inner + '
'; + } + function openDrawer() { renderDrawer(); document.getElementById('v3-songs-drawer')?.classList.remove('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.remove('hidden'); } + function closeDrawer() { document.getElementById('v3-songs-drawer')?.classList.add('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.add('hidden'); updateFilterBadge(); } + function updateFilterBadge() { const b = document.getElementById('v3-songs-filter-count'); if (b) { const n = activeFilterCount(); b.textContent = n; b.classList.toggle('hidden', n === 0); } } + + // ── Song Details drawer (P2) ───────────────────────────────────────────-- + // Evolves the legacy edit-metadata modal into a v3 slide-in drawer that + // unifies catalog identity (writes back into the feedpak FILE), the personal + // practice layer (difficulty / tags / notes — local, never shared), the like + // heart, and remove-from-library. Reuses the filter-drawer slide idiom + // (fixed-right panel + overlay + translate-x-full) but is built on demand and + // body-appended, so it opens from any card context. The Charts drawer (P5d) + // reuses this pattern. + const DIFF_LABELS = ['', 'Very easy', 'Easy', 'Medium', 'Hard', 'Very hard']; + let _detailsEls = null; // {overlay, drawer, opener, onKey} while open + + function closeDetails() { + if (!_detailsEls) return; + const { overlay, drawer, opener, onKey } = _detailsEls; + _detailsEls = null; + document.removeEventListener('keydown', onKey); + drawer.classList.add('translate-x-full'); + overlay.classList.add('opacity-0'); + setTimeout(() => { drawer.remove(); overlay.remove(); }, 200); + if (opener && document.body.contains(opener)) { try { opener.focus({ preventScroll: true }); } catch (_) { /* */ } } + } + + async function openDetails(song) { + if (!song || !song.filename) return; + closeDetails(); + const fn = song.filename; + const opener = document.activeElement; + // Notes aren't in the grid row payload; difficulty/tags are, but re-read + // for authority (another tab / a bulk edit may have changed them). + let meta = { user_difficulty: (song.user_difficulty != null ? song.user_difficulty : null), notes: '', tags: song.tags || [] }; + try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ } + let vocab = []; + try { const r = await fetch('/api/tags'); if (r.ok) vocab = (await r.json()).tags || []; } catch (_) { /* */ } + // Match provenance (launch polish): the drawer names what this chart + // matched, so a silently-wrong first match is visible where the + // metadata lives. 404 (no row yet) / offline → no line. + let enrich = null; + try { const r = await fetch('/api/enrichment/song/' + enc(fn)); if (r.ok) enrich = await r.json(); } catch (_) { /* offline → no provenance line */ } + if (_detailsEls) closeDetails(); // a concurrent open resolved first + + const st = { + t: song.title || '', a: song.artist || '', al: song.album || '', y: song.year != null ? String(song.year) : '', + diff: (meta.user_difficulty != null ? meta.user_difficulty : null), + notes: meta.notes || '', tags: (meta.tags || []).slice(), + fav: !!song.favorite, artDataUrl: null, + gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys + enrich: enrich, // match provenance for the Identity section + }; + + const overlay = document.createElement('div'); + overlay.className = 'fixed inset-0 bg-black/50 z-[60] opacity-0 transition-opacity duration-200'; + const drawer = document.createElement('aside'); + drawer.id = 'v3-song-details-drawer'; + drawer.className = 'fixed top-0 right-0 h-full w-full sm:w-[26rem] bg-fb-sidebar border-l border-fb-border/50 z-[61] transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll'; + drawer.setAttribute('role', 'dialog'); + drawer.setAttribute('aria-modal', 'true'); + drawer.setAttribute('aria-label', 'Song details'); + document.body.appendChild(overlay); + document.body.appendChild(drawer); + + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeDetails(); } }; + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', closeDetails); + _detailsEls = { overlay, drawer, opener, onKey }; + + const render = () => { drawer.innerHTML = detailsHtml(song, st, vocab); wireDetails(drawer, song, st, vocab, render); }; + render(); + requestAnimationFrame(() => { drawer.classList.remove('translate-x-full'); overlay.classList.remove('opacity-0'); }); + if (window._trapFocusInModal) { try { window._trapFocusInModal(drawer); } catch (_) { /* */ } } + const first = drawer.querySelector('#det-title'); + if (first) { try { first.focus({ preventScroll: true }); const n = first.value.length; first.setSelectionRange(n, n); } catch (_) { /* */ } } + } + + // Gap-fill (R4a) block inside the drawer's Identity section: preview → + // per-key confirm → written. Adds ABSENT keys only; the server re-checks + // under its io lock, so this UI can never replace an author-set value. + const GAP_KEY_LABELS = { album: 'Album', year: 'Year', genres: 'Genres', mbid: 'MusicBrainz ID', isrc: 'ISRC' }; + function gapFillHtml(st) { + const g = st.gap; + if (!g) return ''; + if (g.loading) return '
Checking the file…
'; + if (g.written) { + const names = Object.keys(g.written).map((k) => GAP_KEY_LABELS[k] || k).join(', '); + return '
✓ Added to file: ' + esc(names) + '
'; + } + if (!g.eligible) { + const why = { + 'not-sloppak': 'Only feedpak songs can be written to.', + 'no-match': 'No confirmed match yet — nothing verified to write.', + 'review': 'This song’s match is waiting for review — confirm it first.', + 'nothing-missing': 'Nothing missing — the file already has all of this.', + }[g.reason] || 'Could not check the file. Try again.'; + return '
' + esc(why) + '
'; + } + const rows = (g.missing || []).map((m) => { + const val = Array.isArray(m.value) ? m.value.join(', ') : String(m.value); + return ''; + }).join(''); + return '
' + + '
Write to file
' + rows + + '
Only adds what’s missing — nothing already in the file is changed. A backup (.bak) is kept beside the file.
' + + '
' + + '
'; + } + + // Match-provenance line under the Identity fields (launch polish): names + // the canonical identity this chart matched — the invisible-first-wrong- + // match fix — with the same Fix-match escape hatch the card menu offers. + // Only for settled matches; pending/review/failed rows stay silent here + // (the review chip / match facet own those states). + function provenanceHtml(st) { + const e = st.enrich; + if (!e || (e.match_state !== 'matched' && e.match_state !== 'manual')) return ''; + const who = [e.canon_artist, e.canon_title].filter(Boolean).join(' — '); + if (!who) return ''; + const src = e.match_state === 'manual' ? 'your pick' : 'MusicBrainz'; + return '
' + + 'Matched: ' + esc(who) + ' (' + esc(src) + ')' + + '
'; + } + + function detailsHtml(song, st, vocab) { + const art = st.artDataUrl || artUrl(song); + const diffBtns = [1, 2, 3, 4, 5].map((n) => + '').join(''); + const applied = new Set(st.tags); + const tagChips = st.tags.length + ? st.tags.map((t) => '' + esc(t) + + '').join('') + : 'No tags yet'; + const suggest = (vocab || []).filter((v) => !applied.has(v.tag)).slice(0, 8).map((v) => + '').join(''); + const field = (id, label, val) => + '
' + + '
'; + return '
' + + '

Song details

' + + '
' + + + '
' + + '
' + + '' + + '
Change
' + + '
' + + '
' + esc(song.title || song.filename) + '
' + + '
' + + 'a like, not a rating
' + + + // Identity — writes back into the feedpak FILE + '
Identity
' + + 'From pack
' + + field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + + // Artist page (PR-B, entry point 3) — a small jump-off next to the + // Artist field; local library + pages-toggle gated like the others. + ((state.provider === 'local' && (st.a || song.artist) && state.artistPagesEnabled !== false) + ? '' + : '') + + field('det-album', 'Album', st.al) + + '
' + + provenanceHtml(st) + + '
' + gapFillHtml(st) + '
' + + + // Personal practice layer — local, never shared + '
Your practice · stays on this device
' + + '
' + + '
' + + '
' + diffBtns + '' + esc(st.diff ? DIFF_LABELS[st.diff] : 'Not set') + '
' + + '
' + + '
' + tagChips + '
' + + '
' + + '
' + + (suggest ? '
' + suggest + '
' : '') + '
' + + '
' + + '
' + + + '
' + + '
' + + '
' + + '
'; + } + + function wireDetails(drawer, song, st, vocab, render) { + const $ = (s) => drawer.querySelector(s); + drawer.querySelectorAll('[data-det-close]').forEach((b) => b.addEventListener('click', closeDetails)); + // Catalog inputs update state without a re-render (keep caret / focus). + const bindInput = (id, key) => { const el = $('#' + id); if (el) el.addEventListener('input', () => { st[key] = el.value; }); }; + bindInput('det-title', 't'); bindInput('det-artist', 'a'); bindInput('det-album', 'al'); bindInput('det-year', 'y'); + const notes = $('#det-notes'); if (notes) notes.addEventListener('input', () => { st.notes = notes.value; }); + + drawer.querySelectorAll('[data-diff]').forEach((b) => b.addEventListener('click', () => { + const n = Number(b.getAttribute('data-diff')); + st.diff = (st.diff === n ? null : n); // click the active one to unset + render(); + })); + $('[data-diff-clear]')?.addEventListener('click', () => { st.diff = null; render(); }); + + drawer.querySelectorAll('[data-tag-rm]').forEach((b) => b.addEventListener('click', () => { + const t = b.getAttribute('data-tag-rm'); const i = st.tags.indexOf(t); if (i >= 0) st.tags.splice(i, 1); render(); + })); + drawer.querySelectorAll('[data-tag-add]').forEach((b) => b.addEventListener('click', () => { if (addDetailTag(st, b.getAttribute('data-tag-add'))) render(); })); + const addFromInput = () => { const el = $('#det-tag-input'); if (!el) return; if (addDetailTag(st, el.value)) { render(); drawer.querySelector('#det-tag-input')?.focus(); } }; + $('[data-tag-addbtn]')?.addEventListener('click', addFromInput); + $('#det-tag-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addFromInput(); } }); + + const artWrap = $('[data-det-art]'); const artFile = $('#det-art-file'); + if (artWrap && artFile) { + // Art click opens the cover PICKER (PR-C) — the old direct file + // dialog lives on inside it as the Upload tile. The picker applies + // immediately (its own routes + refresh), so it bypasses the + // drawer's Save; the file-input path below stays as the fallback + // when image-picker.js isn't loaded. + artWrap.addEventListener('click', () => { + if (window.__fbOpenImagePicker) { + window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album }); + } else { + artFile.click(); + } + }); + artFile.addEventListener('change', () => { + const f = artFile.files && artFile.files[0]; if (!f) return; + const rd = new FileReader(); + rd.onload = (e) => { st.artDataUrl = e.target.result; const img = $('#det-art-preview'); if (img) { img.src = st.artDataUrl; img.style.visibility = 'visible'; } }; + rd.readAsDataURL(f); + }); + } + + $('[data-det-fav]')?.addEventListener('click', async (e) => { + try { + const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: song.filename }) }); + const d = await r.json(); st.fav = !!d.favorite; + const btn = e.currentTarget; btn.textContent = st.fav ? '♥ Liked' : '♡ Like'; + btn.classList.toggle('text-fb-accent', st.fav); btn.classList.toggle('text-fb-textDim', !st.fav); + _patchCardFav(song.filename, st.fav); + } catch (_) { /* */ } + }); + + $('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st)); + $('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song)); + // Fix match → the exact flow the card ⋮ menu uses (match-review.js). + // The drawer closes first: the match modal sits below the drawer's + // z-index, and the fix supersedes the edit anyway. + $('[data-det-fixmatch]')?.addEventListener('click', () => { + closeDetails(); + if (window.__fbFixMatch) window.__fbFixMatch(song); + }); + // "View artist page →" — uses the field's CURRENT text (an in-progress + // rename still lands on the right page once saved; unsaved text simply + // canonicalizes server-side), falling back to the row's artist. + $('[data-det-artist-page]')?.addEventListener('click', () => { + const a = (st.a || '').trim() || song.artist || ''; + if (!a) return; + closeDetails(); + openArtistPage(a); + }); + + // Gap-fill (R4a): user-initiated write of CONFIRMED missing info into + // the pack file. The server recomputes proposals under its io lock, so + // a key that gained an author value since the preview is skipped. + $('[data-gapfill-check]')?.addEventListener('click', async () => { + st.gap = { loading: true }; render(); + let d = null; + try { const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill'); if (r.ok) d = await r.json(); } catch (_) { /* offline */ } + st.gap = d || { eligible: false, reason: 'error' }; + st.gapSel = new Set(((d && d.missing) || []).map((m) => m.key)); + render(); + }); + drawer.querySelectorAll('[data-gapfill-key]').forEach((cb) => cb.addEventListener('change', () => { + const k = cb.getAttribute('data-gapfill-key'); + if (!st.gapSel) st.gapSel = new Set(); + if (cb.checked) st.gapSel.add(k); else st.gapSel.delete(k); + })); + $('[data-gapfill-cancel]')?.addEventListener('click', () => { st.gap = null; render(); }); + $('[data-gapfill-write]')?.addEventListener('click', async () => { + const keys = st.gapSel ? Array.from(st.gapSel) : []; + if (!keys.length) return; + let d = null, ok = false; + try { + const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys }) }); + ok = r.ok; d = await r.json(); + } catch (_) { /* offline */ } + if (!ok || !d || !d.written) { + if (window.fbNotify) { try { window.fbNotify.show({ title: 'Write failed', message: 'Could not write to the file. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } } + st.gap = null; render(); return; + } + // Reflect what landed in the open drawer (and keep saveDetails' + // changed-detection honest by updating both sides), then refresh + // the grid quietly. + if (d.written.album != null) { st.al = String(d.written.album); song.album = st.al; } + if (d.written.year != null) { st.y = String(d.written.year); song.year = d.written.year; } + st.gap = { written: d.written }; render(); + try { reload(); } catch (_) { /* not on the songs grid */ } + }); + } + + // Normalize + append a tag to the drawer's working set (mirrors the server's + // _normalize_tag). Returns whether it was actually added (new + non-blank). + function addDetailTag(st, raw) { + const t = String(raw || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 60); + if (!t || st.tags.includes(t)) return false; + st.tags.push(t); st.tags.sort((a, b) => a.localeCompare(b)); return true; + } + + // Keep a rendered card's heart in sync when the drawer toggles the like (the + // heart is instant, not part of Save — matches the on-card heart). + function _patchCardFav(fn, fav) { + const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn; + document.querySelectorAll('[data-fn="' + sel + '"] [data-fav]').forEach((btn) => { + btn.textContent = fav ? '♥' : '♡'; + btn.setAttribute('aria-pressed', fav ? 'true' : 'false'); + // Swap exactly the idle colour this heart was rendered with (grid = + // text-white, tree/List view = text-fb-textDim) — mirrors wireCards. + // A hardcoded text-white toggle would leave List-View rows' text-fb-textDim + // in place, so the heart changed glyph but stayed dim (never turned red). + const idle = btn.getAttribute('data-fav-idle') || 'text-white'; + btn.classList.toggle('text-fb-accent', fav); btn.classList.toggle(idle, !fav); + }); + } + + async function saveDetails(song, st) { + const fn = song.filename; + const catChanged = st.t.trim() !== (song.title || '') || st.a.trim() !== (song.artist || '') + || st.al.trim() !== (song.album || '') || String(st.y).trim() !== (song.year != null ? String(song.year) : ''); + const ops = []; + if (catChanged) ops.push(fetch('/api/song/' + enc(fn) + '/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: st.t.trim(), artist: st.a.trim(), album: st.al.trim(), year: String(st.y).trim() }) })); + ops.push(fetch('/api/song/' + enc(fn) + '/user-meta', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_difficulty: st.diff, notes: st.notes, tags: st.tags }) })); + if (st.artDataUrl) ops.push(fetch('/api/song/' + enc(fn) + '/art/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: st.artDataUrl }) })); + // Fire the writes concurrently but know each outcome — a failed write must not + // look like a save. allSettled keeps partial results; treat a rejection OR a + // non-ok HTTP response as failure and keep the drawer open so the user can retry. + const results = await Promise.allSettled(ops); + const failed = results.some((r) => r.status === 'rejected' || (r.value && r.value.ok === false)); + if (failed) { + if (window.fbNotify) { try { window.fbNotify.show({ title: 'Save failed', message: 'Could not save your changes. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } } + return; + } + closeDetails(); + try { reload(); } catch (_) { /* not on the songs grid */ } + } + + async function removeFromLibrary(song) { + const title = song.title || song.filename; + let ok; + if (window._confirmDialog) { + ok = await window._confirmDialog({ + title: 'Remove from library?', + body: '

Remove ' + esc(title) + ' from your library?

' + + '

This permanently deletes the file from disk. This cannot be undone.

', + confirmText: 'Remove', cancelText: 'Cancel', danger: true, + }); + } else { ok = window.confirm('Remove "' + title + '" from your library? This deletes the file from disk.'); } + if (!ok) return; + try { const r = await fetch('/api/song/' + enc(song.filename), { method: 'DELETE' }); if (!r.ok) return; } catch (_) { return; } + closeDetails(); + try { reload(); } catch (_) { /* */ } + } + + // Expose the opener so the core `edit-metadata` card action opens this drawer + // (it falls back to the legacy modal where this isn't defined). + window.__fbOpenSongDetails = openDetails; + + // ── Artist Tidy-up (P4) ────────────────────────────────────────────────-- + // Merge messy artist variants ("ACDC" + "AC/DC") into one canonical name. + // Aliases apply AT DISPLAY only (no file rewrite); the dropdown/tree/grid pick + // up the change on the next load. Self-contained centered modal. + function openArtistTidyUp() { + const st = { raw: [], aliases: [], sel: new Set(), query: '', mergeInto: '', mergeTouched: false, busy: false }; + + const overlay = document.createElement('div'); + overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } }; + function done() { overlay.remove(); document.removeEventListener('keydown', onKey); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); }); + document.body.appendChild(overlay); + + async function refresh() { + const [raw, aliases] = await Promise.all([jget('/api/artists/raw'), jget('/api/artist-aliases')]); + st.raw = (raw && raw.artists) || []; + st.aliases = (aliases && aliases.aliases) || []; + render(); + } + + // Default canonical = the highest-count selected variant (the one most of + // your library already uses), unless the user typed their own. + function defaultCanonical() { + let best = '', bestC = -1; + st.raw.forEach((a) => { if (st.sel.has(a.name) && a.count > bestC) { bestC = a.count; best = a.name; } }); + return best; + } + + function render() { + if (!st.mergeTouched) st.mergeInto = defaultCanonical(); + const q = st.query.trim().toLowerCase(); + const list = st.raw.filter((a) => !q || (a.name || '').toLowerCase().includes(q)); + const rows = list.map((a) => { + const checked = st.sel.has(a.name) ? ' checked' : ''; + const mapped = (a.canonical && a.canonical.toLowerCase() !== (a.name || '').toLowerCase()) + ? '→ ' + esc(a.canonical) + '' : ''; + return ''; + }).join('') || '
No artists
'; + + const canReady = st.mergeInto.trim() && st.sel.size && !st.busy; + const mergeBar = st.sel.size + ? '
' + + '
' + st.sel.size + ' selected — merge into:
' + + '
' + + '
' + : ''; + + const aliasRows = st.aliases.length + ? st.aliases.map((al) => '
' + esc(al.raw_name) + ' ' + esc(al.canonical_name) + '' + + '
').join('') + : '
No merges yet
'; + + overlay.innerHTML = + '
' + + '

Tidy up artists

' + + '
' + + '
' + + '
' + rows + '
' + + '
' + mergeBar + '
' + + '
Current merges
' + aliasRows + '
' + + '
' + + '
'; + + overlay.querySelectorAll('[data-tidy-x]').forEach((b) => b.addEventListener('click', done)); + const search = overlay.querySelector('[data-tidy-search]'); + if (search) search.addEventListener('input', () => { st.query = search.value; render(); const s = overlay.querySelector('[data-tidy-search]'); if (s) { s.focus(); const n = s.value.length; try { s.setSelectionRange(n, n); } catch (_) { /* */ } } }); + overlay.querySelectorAll('[data-tidy-sel]').forEach((cb) => cb.addEventListener('change', () => { + const n = cb.getAttribute('data-tidy-sel'); + if (cb.checked) st.sel.add(n); else st.sel.delete(n); + render(); + })); + const canon = overlay.querySelector('[data-tidy-canon]'); + if (canon) canon.addEventListener('input', () => { + st.mergeInto = canon.value; st.mergeTouched = true; + const btn = overlay.querySelector('[data-tidy-merge]'); + if (btn) btn.disabled = !(st.mergeInto.trim() && st.sel.size && !st.busy); + }); + overlay.querySelector('[data-tidy-merge]')?.addEventListener('click', doMerge); + overlay.querySelectorAll('[data-tidy-unmerge]').forEach((b) => b.addEventListener('click', () => doUnmerge(b.getAttribute('data-tidy-unmerge')))); + } + + async function doMerge() { + const canonical = st.mergeInto.trim(); + if (!canonical || !st.sel.size || st.busy) return; + st.busy = true; + await jsend('POST', '/api/artist-aliases/merge', { raw_names: [...st.sel], canonical_name: canonical }); + st.sel.clear(); st.mergeTouched = false; st.busy = false; + await afterChange(); + } + + async function doUnmerge(raw) { + if (!raw) return; + try { await fetch('/api/artist-aliases/' + enc(raw), { method: 'DELETE' }); } catch (_) { /* */ } + await afterChange(); + } + + async function afterChange() { + await refresh(); + // Reflect canonical names in the toolbar dropdown + grid immediately. + try { await loadArtistCatalog(); refreshArtistAlbumSelects(); reload(); } catch (_) { /* not on the songs grid */ } + } + + refresh(); + } + + // The host loads the Folder Library plugin's screen.js at startup (defining + // window.folderLibrary). If it isn't present yet, inject it once; the + // plugin's IIFEs are idempotent so a redundant evaluation is a no-op. The + // promise is memoised so concurrent folder-view switches don't double-inject. + let _flLoadPromise = null; + function _ensureFolderLibrary() { + if (window.folderLibrary) return Promise.resolve(); + if (_flLoadPromise) return _flLoadPromise; + _flLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = '/api/plugins/folder_library/screen.js'; + s.onload = () => resolve(); + s.onerror = () => { _flLoadPromise = null; reject(new Error('Failed to load Folder Library')); }; + document.head.appendChild(s); + }); + return _flLoadPromise; + } + + function reload() { + _clearLibraryScrollSnapshot(); + // Any toolbar-driven change backs out of the artist sub-page — the new + // state describes a fresh browse, and the host toggles below re-show + // the picked view (mirrors how openAlbum's detail yields to a reload). + _dropArtistPageSilently(); + // Record the state this fetch reflects so a later sidebar return can + // tell whether the grid is stale (e.g. an off-screen search changed + // state.q) and needs a refresh rather than a scroll-preserving no-op. + state.renderedHash = _libraryStateHash(); + saveLibraryPrefs(); + 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. + // 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'); + document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums'); + // Refresh the A–Z jump rail (shows only for the grid + alphabetical + // sorts; hides itself otherwise). Independent of the grid load. + refreshRail(); + // Refresh the practice-aware home (repertoire meter + keep-practicing + // shelf); hides itself when searching/filtering/selecting or off-grid. + updateLibraryHome(); + { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; } + if (state.view === 'folder') { + _applyMainScrollTop(0); + return _ensureFolderLibrary().then(() => window.folderLibrary?.load()); + } + const loaded = state.view === 'grid' ? loadGrid(true) : state.view === 'albums' ? loadAlbums() : loadTree(); + _applyMainScrollTop(0); + return loaded; + } + + // ── Chrome ────────────────────────────────────────────────────────────-- + async function loadProviders() { + try { + const lp = sm && sm.libraryProviders; + // refresh() re-fetches /api/library/providers so REMOTE providers + // (registered by feedBack-plugin-remote-library-*) appear — list() + // returns only the capability's initial local-only snapshot. + const fn = lp && (typeof lp.refresh === 'function' ? lp.refresh : (typeof lp.list === 'function' ? lp.list : null)); + if (fn) { + const snap = await fn.call(lp); + if (snap && Array.isArray(snap.providers)) { + state.provider = snap.current || (snap.providers[0] && snap.providers[0].id) || 'local'; + return snap.providers; + } + } + } catch (e) { /* */ } + const data = await jget('/api/library/providers'); + return (data && data.providers) || [{ id: 'local', label: 'My Library' }]; + } + + async function render() { + const root = document.getElementById('v3-songs'); + if (!root) return; + // A full re-render (filter/scan refresh) replaces the DOM the card menu + // anchored to; dismiss any open menu first so it can't float orphaned. + if (_closeCardMenu) _closeCardMenu(); + // Restore last-used sort/format/view/filters once, before building the + // toolbar so its selects reflect the saved choice (default: Artist A–Z). + if (!_prefsRestored) { applySavedPrefs(); _prefsRestored = true; } + const providers = await loadProviders(); + const [, tn] = await Promise.all([ + (async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(), + jget('/api/library/tuning-names?provider=' + enc(state.provider) + + '&instrument=' + enc(libInstrument())), + loadArtistCatalog(), + // Artist-page gates (PR-B) ride the initial fetch batch so the + // first card paint already knows whether artist lines are links. + refreshArtistPageGates(), + ]); + state.tuningNames = (tn && tn.tunings) || []; + _lastRenderInstrument = libInstrument(); + try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; } + + const opt = (arr, sel) => arr.map(([v, l]) => '').join(''); + const provOpts = providers.map((p) => '').join(''); + const ctrl = btnCtrl; + + root.innerHTML = + '
' + + '
' + + '
' + + // The match-review chip is ambient tool-state (design §11): only + // rendered when matches are waiting, silent otherwise. Populated + + // shown by match-review.js (window.__fbMatchReviewChip), which + // also owns the drawer the click opens. + '

' + + '' + + // Batch progress for the Refresh Metadata button (shown only while a + // pass runs). A real songs-processed ratio, not a fake per-song %. + '
' + + '
' + + (providers.length > 1 ? '' : '') + + '' + + '' + + '
' + + // Name the perspective on the SORT too, not just the filter: tuning + // sort orders by musical distance from standard, and for a bass + // player that distance is measured on the bass tuning. Unlabelled, + // the grid silently reorders with no visible cause. + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + // Practice-aware library home: a repertoire progress meter + a + // "Keep practicing" shelf of started-but-not-mastered songs. Shown + // 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). + '
' + + '
' + + '
' + + '' + + '' + + // Artist page host (PR-B) — an openAlbum-style in-place sub-render; + // populated + shown by openArtistPage, cleared on close/reload. + '' + + '' + + '' + + '
' + + // A–Z jump rail (grid + alphabetical sorts only; populated by + // refreshRail). The bubble shows the current letter while dragging. + '' + + '' + + // Filter drawer + overlay + '' + + '' + + '
'; + + // Wire toolbar. + const byId = (id) => document.getElementById(id); + byId('v3-songs-provider')?.addEventListener('change', async (e) => { + state.provider = e.target.value; + state.artist = ''; + state.album = ''; + try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ } + _updateMetaBtnVisibility(); // enrichment is local-only + await loadArtistCatalog(); + refreshArtistAlbumSelects(); + reload(); + }); + byId('v3-songs-artist')?.addEventListener('change', (e) => setArtist(e.target.value)); + byId('v3-songs-album')?.addEventListener('change', (e) => setAlbum(e.target.value)); + byId('v3-songs-sort').addEventListener('change', (e) => { state.sort = e.target.value; reload(); }); + byId('v3-songs-format').addEventListener('change', async (e) => { + state.format = e.target.value; + await loadArtistCatalog(); + refreshArtistAlbumSelects(); + reload(); + }); + byId('v3-songs-filters').addEventListener('click', openDrawer); + byId('v3-songs-overlay').addEventListener('click', closeDrawer); + // Match-review chip: feature-detected (match-review.js owns the + // drawer + the count; absent → the chip just stays hidden). + byId('v3-songs-match-review')?.addEventListener('click', () => { if (window.__fbOpenMatchReview) window.__fbOpenMatchReview(); }); + if (window.__fbMatchReviewChip) window.__fbMatchReviewChip(); + byId('v3-songs-upload').addEventListener('click', () => { + const legacy = document.getElementById('upload-songs-file'); + // Upload targets the LOCAL library + scan; watchUploadScan refreshes + // the grid for the local provider. Uploading while browsing a remote + // provider won't surface the new local songs — switching the grid to + // local on upload is a P23 remote-provider follow-up. + if (legacy) { legacy.click(); watchUploadScan(); } + }); + byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode)); + byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary); + // Refresh Metadata: local-only, so hide it for remote providers. The + // button doubles as its own Stop while a pass runs (see onMetaBtnClick). + byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick); + byId('v3-songs-unmatched')?.addEventListener('click', toggleUnmatchedFilter); + _updateMetaBtnVisibility(); + // Reflect a scan already in progress (Settings button or a background + // pass) on the Refresh button, so its state isn't just tied to clicks here. + (async () => { + try { + const r = await fetch('/api/scan-status'); + const sd = r.ok ? await r.json() : null; + if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); } + } catch (e) { /* */ } + })(); + // Reflect an enrichment pass already running (Settings "Match now" or a + // post-scan background pass) on the Metadata button + bar. + (async () => { + try { + const r = await fetch('/api/enrichment/status'); + const es = r.ok ? await r.json() : null; + if (es && es.running) { _setMetaState(es); _watchEnrich({ announce: false }); } + } catch (e) { /* */ } + })(); + + // Capture-phase select-mode guard on each persistent list host. Without + // it, clicking a card/row (or its arrangement chip) in select mode falls + // through to the per-card play handler and starts playback instead of + // selecting ("checkbox click opens the song / access-denied"). The artist + // page renders the same [data-fn] song rows into its own host, so it + // needs the guard too — otherwise a row click there plays instead of + // toggling when select mode is already on. + bindSelectGuard(byId('v3-songs-grid')); + bindSelectGuard(byId('v3-songs-tree')); + bindSelectGuard(byId('v3-songs-artistpage')); + const setView = async (v) => { + state.view = v; + byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); + byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); + byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); + { const ab = byId('v3-songs-albums-btn'); if (ab) ab.className = 'px-3 py-2 text-sm ' + (v === 'albums' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); } + if (v === 'folder') await _ensureFolderLibrary(); + return reload(); + }; + byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid')); + byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree')); + byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder')); + byId('v3-songs-albums-btn')?.addEventListener('click', () => setView('albums')); + // Await the initial load so a caller awaiting render() (the scroll + // restore on screen re-entry) sees a populated grid + real state.total + // before it tries to page deeper. + await setView(state.view); + bindScroll(); + bindGridResize(); + positionToolbar(); + bindToolbarReflow(); + updateFilterBadge(); + state.built = true; + } + + async function onV3SongsScreenEnter() { + // A library scan / DLC-folder change marked the grid stale — re-fetch + // from scratch instead of restoring a cached (possibly empty, pre-DLC) + // snapshot. Must win over every fast-path below. + if (_libraryDirty) { _libraryDirty = false; await reload(); return; } + // Keep the entry-point gates current (a Settings visit may have + // toggled artist pages / external links). Fire-and-forget. + refreshArtistPageGates(); + // An open artist sub-page survives a screen bounce as-is — its DOM is + // self-contained. A torn-down/hidden host means the state is stale; + // clear it and fall through to the normal restore paths. + if (state.artistPage) { + const ah = document.getElementById('v3-songs-artistpage'); + if (ah && !ah.classList.contains('hidden') && ah.childElementCount) return; + state.artistPage = null; + state.artistReturnScroll = null; + } + // 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'); + const chromeOk = _chromeIntact(); + const viewOk = state.view === (snap && snap.view ? snap.view : state.view); + + if (snap && hashMatch && domReady && chromeOk && viewOk) { + if (state.view === 'grid' && _gridDomIntact()) { + // 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-gridsizer')?.classList.toggle('hidden', true); + document.getElementById('v3-songs-tree')?.classList.toggle('hidden', false); + syncChromeFromState(); + _applyMainScrollTop(snap.scrollTop || 0); + _clearLibraryScrollSnapshot(); + return; + } + } + + // Sidebar return without a player snapshot — keep grid, refresh chrome. + if (!snap && domReady && chromeOk && state.built) { + syncChromeFromState(); + // If state drifted while we were away (notably an off-screen topbar + // search updating state.q), the persisted grid is stale — refetch + // 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-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; + } + + const snapToRestore = hashMatch ? snap : null; + if (snap && !hashMatch) _clearLibraryScrollSnapshot(); + await render(); + if (snapToRestore && snapToRestore.hash === _libraryStateHash()) { + // 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(); + } + + // After an upload click-through (which reuses the legacy uploader + + // background scan), poll /api/scan-status and reload the v3 grid once the + // scan we triggered finishes — the legacy uploader only refreshes the + // legacy screens, so without this newly-uploaded songs wouldn't appear in + // v3 until a manual refresh. Bounded so a no-op upload can't poll forever. + let _uploadScanTimer = null; + // ── Library refresh (rescan) from the Songs toolbar ─────────────────────── + // The tester ask: a media-server-style "I dropped files in my folder, hit + // refresh" button, with live progress. Reuses the SAME machinery the Settings + // Rescan buttons drive (/api/rescan + /api/scan-status) and emits + // library:changed so the grid reloads. A scan already running (Settings or a + // background pass) is reflected on the button too. + let _refreshPoll = null; + function _setRefreshState(sd) { + const btn = document.getElementById('v3-songs-refresh'); + if (!btn) return; + if (sd && sd.running) { + // Determinate count only exists in the 'scanning' stage; 'listing' is + // indeterminate (total 0), so show a plain "Scanning…" then. + const det = (sd.stage === 'scanning' && sd.total) ? ' ' + sd.done + '/' + sd.total : ''; + btn.textContent = '⟳ Scanning' + det + '…'; + btn.disabled = true; + btn.classList.add('opacity-70'); + const pct = sd.total ? Math.round((sd.done / sd.total) * 100) + '% · ' : ''; + btn.title = 'Scanning new/changed songs… ' + pct + (sd.current || ''); + } else { + btn.textContent = '⟳ Refresh'; + btn.disabled = false; + btn.classList.remove('opacity-70'); + btn.title = 'Refresh library (scan for new songs)'; + } + } + // Show a completion toast (reuses the shared fbNotify surface), suppressed + // while in a song. Honest + never-punishing copy: shows the "N added / M + // removed" delta from the scan when there is one, else "up to date". + function _scanCompleteToast(sd) { + if (document.querySelector('.screen.active') && document.querySelector('.screen.active').id === 'player') return; + if (!window.fbNotify) return; + const added = (sd && sd.added) || 0, removed = (sd && sd.removed) || 0; + let msg; + if (sd && sd.error) msg = 'Scan finished with an error'; + else if (added || removed) { + const parts = []; + if (added) parts.push(added + ' song' + (added === 1 ? '' : 's') + ' added'); + if (removed) parts.push(removed + ' removed'); + msg = parts.join(' · '); + } else msg = 'Your library is up to date'; + try { window.fbNotify.show({ title: 'Library scan complete', message: msg, icon: '🔄', accent: '#22C55E' }); } catch (e) { /* */ } + } + // Poll scan-status until the scan finishes, driving the button state. On + // completion, emit library:changed (grid reloads via the listener below) and, + // for a user-initiated refresh (announce), show the toast. announce:false is + // used when we only attached to a scan we didn't start. + function _watchScan(opts) { + if (_refreshPoll) return; + const announce = !opts || opts.announce !== false; + let sawRunning = false, ticks = 0; + _refreshPoll = setInterval(async () => { + ticks++; + let sd = null; + try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ } + _setRefreshState(sd); + if (sd && sd.running) sawRunning = true; + // A user-initiated refresh that never saw a running scan = nothing to + // do (already up to date); give prompt feedback instead of waiting. + const noopDone = announce && !sawRunning && ticks >= 3; + if ((sawRunning && sd && !sd.running) || noopDone || ticks >= 180) { + clearInterval(_refreshPoll); _refreshPoll = null; + _setRefreshState(null); + const hasDelta = sd && (((sd.added || 0) > 0) || ((sd.removed || 0) > 0)); + if (sawRunning && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'rescan', added: (sd && sd.added) || 0, removed: (sd && sd.removed) || 0 }); } catch (e) { /* */ } } + // A user-initiated refresh always confirms; a scan we only attached + // to (background / Settings) toasts just when it changed something, + // so a periodic no-op pass stays silent. + if (announce || hasDelta) _scanCompleteToast(sd); + } + }, 1000); + } + async function refreshLibrary() { + if (_refreshPoll) return; // a scan is already in progress + try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* */ } + _watchScan({ announce: true }); + } + + function watchUploadScan() { + if (_uploadScanTimer) clearInterval(_uploadScanTimer); + let sawRunning = false, ticks = 0; + _uploadScanTimer = setInterval(async () => { + ticks++; + let sd = null; + try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ } + if (sd && sd.running) sawRunning = true; + if ((sawRunning && sd && !sd.running) || ticks >= 90) { + clearInterval(_uploadScanTimer); _uploadScanTimer = null; + if (sawRunning) reload(); + } + }, 1000); + } + + // ── Refresh Metadata (batch enrichment) from the Songs toolbar ───────────── + // The metadata counterpart to ⟳ Refresh (which scans FILES): matches + // titles/artist/album/artwork against MusicBrainz for the songs that still + // need it — the ambient background matcher, run on demand (a media-server's + // "Refresh Metadata" vs "Scan Files"). Mirrors the scan machinery: a 1 Hz + // poll of /api/enrichment/status drives the button + batch bar, while + // /api/enrichment/states drives per-tile badges on the visible window. + // Enrichment is local-only, so the button hides for remote providers. + let _metaPoll = null; + let _metaRunning = false; + + function _updateMetaBtnVisibility() { + const local = state.provider === 'local'; // enrichment + its filter are local-only + const btn = document.getElementById('v3-songs-refresh-meta'); + if (btn) btn.style.display = local ? '' : 'none'; + const um = document.getElementById('v3-songs-unmatched'); + if (um) um.style.display = local ? '' : 'none'; + } + + // Quick "Show unmatched" — the same filter as the drawer's Match → Unmatched, + // one click from the toolbar so the no-match pile is reachable right after a + // batch. Toggles the button + re-queries the grid. + function toggleUnmatchedFilter() { + const m = state.filters.match || (state.filters.match = []); + const i = m.indexOf('unmatched'); + const on = i < 0; + if (on) m.push('unmatched'); else m.splice(i, 1); + const btn = document.getElementById('v3-songs-unmatched'); + if (btn) { btn.classList.toggle('bg-fb-primary', on); btn.classList.toggle('text-white', on); } + reload(); + } + + // The local filenames the grid is currently SHOWING (data-fn is the local + // filename the enrichment cache keys on). The grid is windowed, so this is + // the visible slice only — exactly what the per-tile poll should cover. + function _visibleLocalFilenames() { + const grid = document.getElementById('v3-songs-grid'); + if (!grid) return []; + return [...grid.querySelectorAll('[data-fn]')] + .map((el) => el.getAttribute('data-fn')).filter(Boolean); + } + + // Set/clear one card's live badge (recycled cards re-derive from _metaTile on + // the next paint, so update the map too — mirrors _patchCardFav). + function _patchCardEnrich(fn, st) { + if (st) _metaTile[fn] = st; else delete _metaTile[fn]; + const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn; + document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => { + const el = play.querySelector('.v3-meta-tile'); + const html = enrichBadge(fn); + if (!html) { if (el) el.remove(); return; } + if (el) el.outerHTML = html; else play.insertAdjacentHTML('beforeend', html); + }); + } + + function _clearMetaTiles() { + Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; }); + document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove()); + // The persistent "No match" badge derives from _unmatched (not _metaTile), + // yet shares the .v3-meta-tile class — so the blanket remove above strips it. + // Repaint the resting indicator on any rendered card so a metadata rescan's + // tile-clear doesn't silently drop it until the next scroll/re-render. + _unmatched.forEach((fn) => { + const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn; + document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => { + if (play.querySelector('.v3-meta-tile')) return; + const html = enrichBadge(fn); + if (html) play.insertAdjacentHTML('beforeend', html); + }); + }); + } + + + // Drive the button (which doubles as Stop) + the batch bar from a status body. + function _setMetaState(es) { + const btn = document.getElementById('v3-songs-refresh-meta'); + const prog = document.getElementById('v3-meta-progress'); + const fill = document.getElementById('v3-meta-progress-fill'); + const label = document.getElementById('v3-meta-progress-label'); + if (!btn) return; + const running = !!(es && es.running); + _metaRunning = running; + if (running) { + const total = (es && es.total) || 0, done = (es && es.matched) || 0; + const cancelling = !!(es && es.cancelling); + btn.textContent = cancelling ? 'Stopping…' : ('⏹ Stop' + (total ? ' · ' + done + '/' + total : '')); + btn.disabled = cancelling; + btn.classList.toggle('opacity-70', cancelling); + btn.title = cancelling ? 'Stopping after the current song…' : 'Stop refreshing metadata'; + if (prog) { + prog.classList.remove('hidden'); prog.classList.add('flex'); + if (label) label.textContent = total ? ('Matching metadata ' + done + '/' + total) : 'Matching metadata…'; + // Real songs-processed ratio; a tiny sliver while the queue size + // is still being computed (phase 1) so the bar isn't dead-empty. + if (fill) fill.style.width = (total ? Math.round((done / total) * 100) : 6) + '%'; + } + } else { + btn.textContent = '🏷 Metadata'; + btn.disabled = false; + btn.classList.remove('opacity-70'); + btn.title = 'Refresh metadata for the songs shown (re-match titles, artwork & more)'; + if (prog) { prog.classList.add('hidden'); prog.classList.remove('flex'); } + } + } + + // Completion toast — reuse the shared fbNotify surface (visual-only, so + // hearing-safe for free). Honest + never-punishing copy, in-game suppressed. + function _metaCompleteToast(es) { + const active = document.querySelector('.screen.active'); + if (active && active.id === 'player') return; + if (!window.fbNotify) return; + const matched = (es && es.matched) || 0; + const msg = matched + ? (matched + ' song' + (matched === 1 ? '' : 's') + ' matched') + : 'Your library metadata is up to date'; + try { window.fbNotify.show({ title: 'Metadata refresh complete', message: msg, icon: '🏷️', accent: '#22C55E' }); } catch (e) { /* */ } + } + + // Poll enrichment status (button + bar) AND the visible window's per-song + // states (tile badges) until the pass finishes. announce:false = we only + // attached to a pass we didn't start (no toast unless it actually changed + // something). + function _watchEnrich(opts) { + if (_metaPoll) return; + const announce = !opts || opts.announce !== false; + let sawRunning = false, ticks = 0, lastStatus = null; + _metaPoll = setInterval(async () => { + ticks++; + let es = null; + try { const r = await fetch('/api/enrichment/status'); if (r.ok) es = await r.json(); } catch (e) { /* */ } + if (es) { lastStatus = es; _setMetaState(es); if (es.running) sawRunning = true; } + // Per-tile badges: only songs we're tracking (seeded 'queued'). A + // tile flips to 'working' when it's the current song, then to + // 'done' (matched) / 'nochange' (failed) once it leaves unscanned. + if (Object.keys(_metaTile).length) { + const fns = _visibleLocalFilenames(); + try { + const r = await fetch('/api/enrichment/states', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filenames: fns }), + }); + if (r.ok) { + const j = await r.json(); + const states = j.states || {}, current = j.current; + fns.forEach((fn) => { + if (!(fn in _metaTile)) return; + if (fn === current) { _patchCardEnrich(fn, 'working'); return; } + const s = states[fn]; + if (s && s !== 'unscanned' && s !== 'pending') { + _patchCardEnrich(fn, s === 'failed' ? 'nochange' : 'done'); + } + }); + } + } catch (e) { /* */ } + } + // Cap at 20 min (a ~1000-song trickle at ≤1/s is ~17 min); a + // user-initiated no-op that never saw a running pass ends quickly. + const noopDone = announce && !sawRunning && ticks >= 3; + if ((sawRunning && es && !es.running) || noopDone || ticks >= 1200) { + clearInterval(_metaPoll); _metaPoll = null; + _setMetaState(null); + const changed = sawRunning && lastStatus && (lastStatus.matched || 0) > 0; + if (announce || changed) _metaCompleteToast(lastStatus); + // Let the final 'done' badges register, then clear + (if anything + // matched) reload so new canonical titles/art show. + setTimeout(() => { + _clearMetaTiles(); + if (changed && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'enrich', matched: lastStatus.matched }); } catch (e) { /* */ } } + }, 1600); + } + }, 1000); + } + + // Force a fresh re-match of the songs currently SHOWN (the visible grid + // window) — a media-server-style per-view "Refresh Metadata". Resets those + // songs and re-fetches, so it's visible even on an already-matched library. + // Manual pins are skipped server-side; scoped to the visible set so it's + // fast + can't blow the whole rate budget. + async function refreshMetadata() { + if (_metaRunning || _metaPoll) return; // already running + const fns = _visibleLocalFilenames(); + _clearMetaTiles(); + if (!fns.length) { _metaCompleteToast({ matched: 0 }); return; } + let queued = []; + try { + const r = await fetch('/api/enrichment/rematch', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filenames: fns }), + }); + if (r.ok) queued = (await r.json()).queued || []; + } catch (e) { /* offline → nothing queued */ } + // Badge exactly what the server queued (everything visible except your + // manual pins). Nothing queued = all visible songs are pinned/unknown. + queued.forEach((fn) => _patchCardEnrich(fn, 'queued')); + if (!queued.length) { _metaCompleteToast({ matched: 0 }); return; } + _watchEnrich({ announce: true }); + } + + async function stopMetadata() { + try { await fetch('/api/enrichment/cancel', { method: 'POST' }); } catch (e) { /* */ } + _setMetaState({ running: true, cancelling: true }); // optimistic; the poll confirms + } + + // The Metadata button toggles role: kick a refresh when idle, Stop when a + // pass is running. + function onMetaBtnClick() { + if (_metaRunning) stopMetadata(); else refreshMetadata(); + } + + // Topbar search drives this screen. + async function search(q) { + state.q = q || ''; + if (songsActive()) { + await loadArtistCatalog(); + refreshArtistAlbumSelects(); + reload(); + } else if (window.showScreen) { + window.showScreen('v3-songs'); + } + } + + window.v3Songs = { + render: render, + reload: reload, + search: search, + setQuery: (q) => { state.q = q || ''; }, + 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(); + if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(',')); + if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(',')); + if (f.stem_has.length) p.set('stems_has', f.stem_has.join(',')); + if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(',')); + if (f.lyrics) p.set('has_lyrics', f.lyrics); + if (f.tunings.length) p.set('tunings', f.tunings.join(',')); + return p.toString(); + }, + _scrollHelpers: { + SCROLL_STATE_KEY, + buildLibraryStateHash, + readSnapshot: _readLibraryScrollSnapshot, + clearSnapshot: _clearLibraryScrollSnapshot, + }, + }; + + if (sm && typeof sm.on === 'function') { + sm.on('screen:changed', (e) => { + const id = e && e.detail && e.detail.id; + if (id === 'v3-songs') { onV3SongsScreenEnter(); return; } + // Leaving Songs: tear down select mode + the body-mounted batch bar, + // so an active multi-selection doesn't leave a floating bar (and + // stale selection) visible on unrelated screens. + if (state.selectMode || state.selected.size) { + state.selectMode = false; + state.selected.clear(); + const bar = document.getElementById('v3-songs-batch'); + if (bar) bar.remove(); + } + }); + // 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) => { + // Decode to the library-card key space — the event carries the + // encodeURIComponent'd filename, but cards (data-fn) and + // state.accuracy key on the decoded library filename. Without this + // the repaint below (and applyScoreRefresh's repaintAccuracy) match + // no card, so a just-earned score stays invisible until a full + // render() (restart / search / re-enter), which is this bug. + const fn = decFn(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(); + }); + // A library scan (rescan / full rescan from Settings, or a DLC-folder + // change) can add or remove songs while this grid is cached — the + // Settings rescan only refreshed the classic library, so the v3 grid + // stayed on its pre-scan (e.g. empty, pre-DLC) state until an app + // restart. Reload now if we're showing; otherwise mark dirty so the next + // entry re-fetches instead of restoring the stale snapshot. + sm.on('library:changed', () => { + const active = document.querySelector('.screen.active'); + if (active && active.id === 'v3-songs') { _libraryDirty = false; reload(); } + else _libraryDirty = true; + }); + // Your live tuning changed (retune / instrument swap / reset) → re-colour the + // visible tuning chips against the new tuning. Cheap: re-decorates in place, + // no re-fetch or re-paint. No-op off the Songs grid or without the capability. + sm.on('working-tuning-changed', () => { + // A guitar<->bass SWITCH changes which tuning the facet, the filter, + // the sort and the card chip speak for, so the grid must re-query — + // re-colouring chips would leave the guitar tuning on screen and a + // guitar-keyed filter applied. A retune within one instrument still + // takes the cheap in-place path below. + const inst = libInstrument(); + if (inst !== _lastRenderInstrument) { + _lastRenderInstrument = inst; + // A tuning selection keyed to the old instrument means nothing + // for the new one; clearing avoids an empty grid the user can't + // explain (the pills are re-rendered from the new facet). + state.filters.tunings = []; + const active = document.querySelector('.screen.active'); + if (active && active.id === 'v3-songs') reload(); + else _libraryDirty = true; + return; + } + if (typeof songsActive === 'function' && !songsActive()) return; + if (state.view !== 'grid') return; + decorateTuningChips(_gridEl()); + }); + } +})();