/* * 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'], ]; 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: [] }, 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.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 are session-only facets (deliberately not // persisted), but the restored object must still CARRY the keys — // the filter drawer indexes f.mastery/f.match 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: [], }; } } // 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); if (f.tunings.length) p.set('tunings', f.tunings.join(',')); 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 ''; const pct = Math.round(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; points at the manual fix. nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'No metadata match found — right-click to fix it by hand'], }; const conf = M[st] || M.queued; // top-10 clears the tuning chip (top-2) in both normal and select mode; // z-20 sits it above the art. Non-interactive so it never eats a click. 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; 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. const tuningLabel = (typeof window.displayTuningName === 'function') ? window.displayTuningName(shown.tuning_name || shown.tuning) : (shown.tuning_name || ''); let tuning = ''; if (tuningLabel) { const rawOffsets = (typeof window.parseRawTuningOffsets === 'function') ? (window.parseRawTuningOffsets(shown.tuning_offsets) || window.parseRawTuningOffsets(shown.tuning_name || shown.tuning)) : null; const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets && typeof window.displayTuningTargets === 'function') ? window.displayTuningTargets(rawOffsets, { tuningName: tuningLabel }) : ''; const badgeTitle = targetNotes ? ('Custom Tuning: ' + targetNotes) : tuningLabel; 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 || []; const chipIsBass = 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) + ''; } } // 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 match…' }, { 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 }); 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.round(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); }); // 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'); } // 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; }); } // 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) { 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' ? '✕ ' : ''; 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 (sloppak)', 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('')) : '') + section('Tuning', (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; } return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any'); }).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-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: [] }; 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 }); } 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)), 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) || []; 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 ? '' : '') + '' + '' + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '
' + // 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', () => { if (typeof songsActive === 'function' && !songsActive()) return; if (state.view !== 'grid') return; decorateTuningChips(_gridEl()); }); } })();