diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6cd9c7c..6e84c53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- **Library filter: one-click "Not split" + a piano stem pill.** The v3 Filters drawer's
+ stems section gains a **Not split** shortcut that selects "lacks every instrument stem"
+ in one tap — the same query Stem Splitter's missing-stems view runs — instead of
+ cycling five pills to ✕ by hand. The pill row also gains `piano` (the drawer offered
+ five of the canonical six stems, so a piano-only song wrongly matched a hand-built
+ "lacks all" filter). "No lyrics" already existed in the Lyrics section.
- **Gold tier (career passports)** — an earned badge turns **gold** when
Virtuoso verifies an improvised jam in the passport's style (the
`gold_improv` artifact relays with the drill snapshot; a genre inherits its
diff --git a/static/v3/songs.js b/static/v3/songs.js
index a12c663..5b21196 100644
--- a/static/v3/songs.js
+++ b/static/v3/songs.js
@@ -1,4317 +1,4334 @@
-/*
- * fee[dB]ack v0.3.0 — Songs / Library (#v3-songs), native rebuild.
- *
- * A vanilla-JS library browser over the existing /api/library* endpoints:
- * provider selector (via the `library` capability, not DOM scraping), grid +
- * tree views, sort, format filter, a tri-state filter drawer (arrangements /
- * stems / lyrics / tunings), search (driven by the topbar), infinite scroll,
- * fb song cards with accuracy badges (song_stats), favorite + save-for-later,
- * and upload. Reuses window.playSong for playback (design/05: library is an
- * active capability domain; everything else stays on the documented globals).
- */
-(function () {
- 'use strict';
- const sm = window.feedBack;
- const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
- { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
- const enc = encodeURIComponent;
- // Inverse of `enc` for matching a played song's filename back to a library
- // card. The `stats:recorded` event (like `song:loading`) carries the
- // filename exactly as it was handed to `playSong` — i.e. encodeURIComponent'd
- // (see `playCard`, and the highway WS which decodeURIComponent's it). But
- // cards key on the DECODED library filename (`cardKey` → `localFilename`),
- // and `/api/stats/best` is server-canonicalized to that same decoded key, so
- // an encoded filename matches no card and the post-play badge repaint silently
- // no-ops. Decode to land in the card/`state.accuracy` key space. Idempotent
- // for already-decoded names (no '%'); on malformed input falls back to the
- // original so a real filename containing a literal '%' is never corrupted.
- function decFn(fn) {
- if (typeof fn !== 'string' || fn.indexOf('%') === -1) return fn || '';
- try { return decodeURIComponent(fn); } catch (_) { return fn; }
- }
-
- const SORTS = [
- ['artist', 'Artist A–Z'], ['artist-desc', 'Artist Z–A'],
- ['title', 'Title A–Z'], ['title-desc', 'Title Z–A'],
- ['recent', 'Recently Added'], ['year-desc', 'Year (newest)'],
- ['year', 'Year (oldest)'], ['tuning', 'Tuning'],
- // Mastery = best accuracy across arrangements (song_stats); unscored songs
- // sort last either way. Ascending surfaces what needs work; never default.
- ['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'],
- // Personal difficulty (song_user_meta.user_difficulty, 1-5); unrated
- // songs sort last either way.
- ['difficulty', 'Difficulty (easiest first)'], ['difficulty-desc', 'Difficulty (hardest first)'],
- ];
- const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
- const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
- const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'other'];
- const PAGE_SIZE = 24;
- // Extra rows rendered above/below the viewport so a fast scroll doesn't flash
- // blank before the next window render lands.
- const OVERSCAN_ROWS = 2;
- const SCROLL_STATE_KEY = 'v3:songs-scroll-state';
- // Persisted "how I'm looking" prefs (sort / format / view / drawer filters).
- // The tester ask: "most users pick one sort and leave it" + remember filters.
- // The search query and the artist/album drill-down are navigational, so they
- // are deliberately NOT persisted; cold start stays the neutral Artist A–Z.
- const PREFS_KEY = 'v3:songs-prefs';
- const btnCtrl = 'bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary';
-
- const state = {
- provider: 'local', view: 'grid', sort: 'artist', format: '', q: '',
- artist: '', album: '',
- grouping: true, // one card per song (multi-chart grouping); persisted
-
- filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' },
- page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], genres: [],
- artistCatalog: [], renderedHash: '',
- scrollBound: false,
- songsById: {}, selectMode: false, selected: new Set(),
- railLetters: null, railLettersAreSongCounts: false, railJumping: false,
- // ── Artist page (PR-B) ──
- // Non-null while the artist sub-page is showing (the artist's canonical
- // or raw name). The gates mirror the two Settings toggles: pages are
- // local-only and default ON; the external-links row is opt-in.
- artistPage: null,
- artistReturnScroll: null, // scrollTop to restore on ← Song Library
- artistPagesEnabled: true,
- artistLinksEnabled: false,
- // ── Windowed (virtualized) grid, stage 2 of #636 item 3 ──
- // state.songs is a SPARSE array indexed by absolute library position
- // (0..total-1); only the fetched pages are populated and only the visible
- // window ± overscan is ever in the DOM. The sizer element gives the
- // scrollbar the full-library geometry. See renderWindow / ensureWindow.
- songs: [], // sparse: absoluteIndex → song row
- pageCursors: {}, // pageIndex → next_cursor (keyset forward fast-path)
- keysetOk: false, // did page 0 return a non-null cursor (local + keyset sort)?
- pageProms: {}, // pageIndex → in-flight fetch promise (de-dupe + await)
- epoch: 0, // bumped on every reset; a stale in-flight fetch checks it
- geom: null, // { cols, rowH, gap } measured from the live grid
- winRange: null, // { start, end } last rendered, to skip redundant renders
- renderedSelectMode: null, // the selectMode the current window was rendered under
- gridResizeBound: false,
- };
-
- // ── A–Z jump rail ───────────────────────────────────────────────────────
- // Ordered buckets shown on the rail: '#' (non-alphabetic) first, then A–Z.
- const RAIL_BUCKETS = ['#'].concat('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
- // The rail only makes sense for the alphabetical sorts; for recent/year/
- // tuning a letter jump is meaningless, so it's hidden. Returns the column
- // the active sort keys on ('artist' | 'title') or null when not alphabetical.
- function railSortColumn() {
- if (state.sort === 'artist' || state.sort === 'artist-desc') return 'artist';
- if (state.sort === 'title' || state.sort === 'title-desc') return 'title';
- return null;
- }
- // The bucket a song falls in for the active sort: first char of the sort
- // column, uppercased; anything non-A–Z (digits, symbols, accents, blank)
- // buckets under '#'. Mirrors the server's letter grouping in query_stats —
- // which keys on raw SUBSTR(col, 1, 1) with no trim, and the grid ORDER BY
- // is likewise raw, so we must NOT trim here either: a leading-space title
- // sorts (and buckets) under '#' on both sides, keeping the rail consistent.
- function songBucket(song) {
- const col = railSortColumn();
- if (!col) return '';
- const raw = String((col === 'title' ? song.title : song.artist) || '');
- const ch = raw.charAt(0).toUpperCase();
- return (ch >= 'A' && ch <= 'Z') ? ch : '#';
- }
-
- function activeFilterCount() {
- const f = state.filters;
- return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length +
- (f.lyrics ? 1 : 0) + (f.tuningMatch === 'playable' ? 1 : f.tunings.length) + (f.mastery ? f.mastery.length : 0) +
- (f.match ? f.match.length : 0) + (f.genre ? f.genre.length : 0) +
- (state.artist ? 1 : 0) + (state.album ? 1 : 0);
- }
-
- function _getV3MainScroller() { return document.getElementById('v3-main'); }
-
- function buildLibraryStateHash(st) {
- const f = (st && st.filters) || {};
- return JSON.stringify({
- view: st.view || 'grid',
- q: st.q || '',
- sort: st.sort || 'artist',
- provider: st.provider || 'local',
- format: st.format || '',
- artist: st.artist || '',
- album: st.album || '',
- filters: {
- arr_has: [...(f.arr_has || [])].sort(),
- arr_lacks: [...(f.arr_lacks || [])].sort(),
- stem_has: [...(f.stem_has || [])].sort(),
- stem_lacks: [...(f.stem_lacks || [])].sort(),
- lyrics: f.lyrics || '',
- tunings: [...(f.tunings || [])].sort(),
- mastery: [...(f.mastery || [])].sort(),
- match: [...(f.match || [])].sort(),
- genre: [...(f.genre || [])].sort(),
- },
- });
- }
-
- function _libraryStateHash() { return buildLibraryStateHash(state); }
-
- // Restore the persisted view prefs once per page load, before the first
- // toolbar build so the selects render with the saved values. Every value is
- // validated against its known option list, so a stale/removed setting can
- // never wedge the UI — it just falls back to the default.
- let _prefsRestored = false;
- function applySavedPrefs() {
- let saved;
- try { saved = JSON.parse(localStorage.getItem(PREFS_KEY)); } catch (_) { return; }
- if (!saved || typeof saved !== 'object') return;
- if (SORTS.some(([v]) => v === saved.sort)) state.sort = saved.sort;
- if (FORMATS.some(([v]) => v === saved.format)) state.format = saved.format;
- if (saved.view === 'grid' || saved.view === 'tree' || saved.view === 'folder' || saved.view === 'albums') state.view = saved.view;
- if (typeof saved.grouping === 'boolean') state.grouping = saved.grouping;
- const f = saved.filters;
- if (f && typeof f === 'object') {
- const arr = (x) => (Array.isArray(x) ? x.slice() : []);
- // mastery + match + genre are session-only facets (deliberately not
- // persisted), but the restored object must still CARRY the keys —
- // the filter drawer indexes f.mastery/f.match/f.genre unconditionally,
- // so dropping them here breaks the drawer for anyone with saved prefs.
- state.filters = {
- arr_has: arr(f.arr_has), arr_lacks: arr(f.arr_lacks),
- stem_has: arr(f.stem_has), stem_lacks: arr(f.stem_lacks),
- lyrics: f.lyrics || '', tunings: arr(f.tunings),
- mastery: [], match: [], genre: [],
- };
- }
- }
- // Persist the current view prefs (best-effort; storage may be full/disabled).
- // Called from reload(), which every sort/format/filter/view change funnels
- // through — so this is the single write point.
- function saveLibraryPrefs() {
- try {
- const f = state.filters;
- localStorage.setItem(PREFS_KEY, JSON.stringify({
- sort: state.sort, format: state.format, view: state.view,
- grouping: state.grouping !== false,
- filters: {
- arr_has: [...f.arr_has], arr_lacks: [...f.arr_lacks],
- stem_has: [...f.stem_has], stem_lacks: [...f.stem_lacks],
- lyrics: f.lyrics || '', tunings: [...f.tunings],
- },
- }));
- } catch (_) { /* best-effort */ }
- }
-
- function _saveLibraryScrollSnapshot() {
- const main = _getV3MainScroller();
- // Geometry is now stable (the sizer reserves the full scroll height
- // regardless of how many cards are actually in the DOM), so the scroll
- // position alone is enough to restore — no page-depth bookkeeping.
- const snap = {
- hash: _libraryStateHash(),
- scrollTop: main ? main.scrollTop : 0,
- view: state.view,
- };
- try { sessionStorage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } catch (e) { /* quota / private mode */ }
- }
-
- function _readLibraryScrollSnapshot() {
- try {
- const raw = sessionStorage.getItem(SCROLL_STATE_KEY);
- if (!raw) return null;
- const snap = JSON.parse(raw);
- return (snap && typeof snap === 'object') ? snap : null;
- } catch (e) { return null; }
- }
-
- function _clearLibraryScrollSnapshot() {
- try { sessionStorage.removeItem(SCROLL_STATE_KEY); } catch (e) { /* */ }
- }
-
- function _applyMainScrollTop(scrollTop) {
- const main = _getV3MainScroller();
- if (!main) return;
- const top = Math.max(0, Number(scrollTop) || 0);
- const apply = () => { main.scrollTop = top; };
- apply();
- requestAnimationFrame(apply);
- setTimeout(apply, 0);
- }
-
- // The windowed grid keeps only a slice of cards in the DOM, so "intact" can no
- // longer mean "has cards" — it means the grid + sizer chrome exist and page 0
- // is loaded (state.total known, first rows present), so renderWindow() can
- // repaint the right slice at any scroll position.
- function _gridDomIntact() {
- const grid = document.getElementById('v3-songs-grid');
- const sizer = document.getElementById('v3-songs-gridsizer');
- return !!grid && !!sizer && state.total > 0 && state.songs[0] !== undefined;
- }
-
- function _treeDomIntact() {
- const tree = document.getElementById('v3-songs-tree');
- if (!tree) return false;
- return !!(tree.querySelector('[data-fn]') || tree.querySelector('details'));
- }
-
- // ── Multi-chart grouping (P5c, design §7.1) ─────────────────────────────
- // The grid queries with group=1 so charts of the same work collapse to ONE
- // card — the representative (preferred/auto-pick) chart — with rows carrying
- // chart_count + work_key from the materialized work_display read-model
- // (P5a/P5b). group must ride BOTH the page fetch and the rail's stats fetch
- // so page, total and sort_letters all count works identically — a
- // works-vs-charts mismatch would break the rail's cumulative-seek math and
- // the sizer geometry. Grouping is default-ON per the design, with a
- // persisted toggle in the filter drawer (P5e) — OFF falls back to today's
- // one-card-per-chart. Only the local provider implements group=; smart
- // collections and remote providers ignore it and stay flat (their rows
- // then carry no chart_count, so no ⚑ chips render).
- function groupingActive() { return state.grouping !== false; }
-
- function queryParams(extra, opts) {
- const f = state.filters;
- const skipArtistAlbum = opts && opts.catalog;
- const p = new URLSearchParams();
- p.set('provider', state.provider);
- p.set('sort', state.sort);
- if (state.format) p.set('format', state.format);
- if (state.q) p.set('q', state.q);
- if (!skipArtistAlbum && state.artist) p.set('artist', state.artist);
- if (!skipArtistAlbum && state.album) p.set('album', state.album);
- if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(','));
- if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(','));
- if (f.stem_has.length) p.set('stems_has', f.stem_has.join(','));
- if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
- if (f.lyrics) p.set('has_lyrics', f.lyrics);
- // The two modes answer different questions, so only one filters at a
- // time: sending both would silently intersect them.
- if (f.tunings.length && f.tuningMatch !== 'playable') p.set('tunings', f.tunings.join(','));
- // Which perspective the `tunings` filter + the tuning sort read.
- if (libInstrument() !== 'guitar-lead') p.set('instrument', libInstrument());
- // "Playable without retuning": send the player's LIVE tuning and let the
- // server do the pitch maths (the pitch tables live in lib/tunings.py —
- // duplicating them here is how the two drift apart).
- applyPlayableParams(p, f);
- if (f.mastery && f.mastery.length) p.set('mastery', f.mastery.join(','));
- if (f.match && f.match.length) p.set('match', f.match.join(','));
- if (f.genre && f.genre.length) p.set('genre', f.genre.join(','));
- Object.entries(extra || {}).forEach(([k, v]) => p.set(k, v));
- return p;
- }
-
- // The active filter set as a smart-collection rule object (raw query-param
- // format the backend stores). Mirrors queryParams' filter fields, minus
- // provider/page/size. Empty object → nothing worth saving as a collection.
- function currentFilterRules() {
- const f = state.filters, r = {};
- if (state.q) r.q = state.q;
- if (state.format) r.format = state.format;
- if (state.artist) r.artist = state.artist;
- if (state.album) r.album = state.album;
- if (f.arr_has.length) r.arrangements_has = f.arr_has.join(',');
- if (f.arr_lacks.length) r.arrangements_lacks = f.arr_lacks.join(',');
- if (f.stem_has.length) r.stems_has = f.stem_has.join(',');
- if (f.stem_lacks.length) r.stems_lacks = f.stem_lacks.join(',');
- if (f.lyrics) r.has_lyrics = f.lyrics;
- if (f.tunings.length) r.tunings = f.tunings.join(',');
- if (state.sort && state.sort !== 'artist') r.sort = state.sort;
- return r;
- }
-
- // Save the current filter set as a smart collection (a saved live query that
- // shows up as a source in the picker). #636 item 2.
- async function saveCurrentAsCollection() {
- const rules = currentFilterRules();
- if (!Object.keys(rules).length) return;
- const name = ((await window.uiPrompt({
- title: 'Save as collection',
- label: 'A live view of the current filters, in the source picker.',
- okLabel: 'Save',
- placeholder: 'Collection name',
- })) || '').trim();
- if (!name) return;
- try {
- const res = await fetch('/api/collections', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name, rules }),
- });
- if (!res.ok) return;
- const col = (await res.json()).collection;
- closeDrawer();
- if (col && col.id != null) state.provider = 'collection:' + col.id;
- await render(); // rebuilds the toolbar (provider picker now lists + selects it)
- } catch (e) { /* offline / aborted — leave the drawer as-is */ }
- }
-
- function albumsForArtist(name) {
- const a = (state.artistCatalog || []).find((x) => x.name === name);
- return a ? (a.albums || []) : [];
- }
-
- function _chromeIntact() {
- return !!(document.getElementById('v3-songs-filters') &&
- document.getElementById('v3-songs-artist') &&
- document.getElementById('v3-songs-grid'));
- }
-
- function artistSelectHtml() {
- const opts = ['All artists ']
- .concat((state.artistCatalog || []).map((a) =>
- '' + esc(a.name) + ' '));
- return opts.join('');
- }
-
- function albumSelectHtml() {
- if (!state.artist) {
- return 'Choose artist first ';
- }
- const albums = albumsForArtist(state.artist);
- const opts = ['All albums ']
- .concat(albums.map((n) =>
- '' + esc(n) + ' '));
- return opts.join('');
- }
-
- function refreshArtistAlbumSelects() {
- const artistEl = document.getElementById('v3-songs-artist');
- const albumEl = document.getElementById('v3-songs-album');
- if (artistEl) artistEl.innerHTML = artistSelectHtml();
- if (albumEl) {
- albumEl.innerHTML = albumSelectHtml();
- albumEl.disabled = !state.artist;
- }
- }
-
- function syncChromeFromState() {
- const map = {
- 'v3-songs-provider': state.provider,
- 'v3-songs-sort': state.sort,
- 'v3-songs-format': state.format,
- };
- Object.entries(map).forEach(([id, val]) => {
- const el = document.getElementById(id);
- if (el && el.value !== val) el.value = val;
- });
- refreshArtistAlbumSelects();
- const gridBtn = document.getElementById('v3-songs-grid-btn');
- const treeBtn = document.getElementById('v3-songs-tree-btn');
- if (gridBtn) gridBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
- if (treeBtn) treeBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
- const folderBtn = document.getElementById('v3-songs-folder-btn');
- if (folderBtn) folderBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
- // Select button tracks state.selectMode — the screen-leave teardown clears
- // select mode, so a cached-DOM re-entry must re-style the button (and the
- // window re-renders without checkboxes via renderWindow's selectMode check).
- const selBtn = document.getElementById('v3-songs-select');
- if (selBtn) selBtn.className = btnCtrl + (state.selectMode ? ' bg-fb-primary text-white' : '');
- updateFilterBadge();
- }
-
- async function loadArtistCatalog() {
- const artists = [];
- let page = 0, total = Infinity;
- while (artists.length < total) {
- const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }, { catalog: true }).toString());
- if (!data || !Array.isArray(data.artists)) break;
- artists.push(...data.artists);
- total = (data.total_artists != null) ? data.total_artists : artists.length;
- if (!data.artists.length || page > 1000) break;
- page++;
- }
- state.artistCatalog = artists.map((a) => ({
- name: a.name,
- albums: (a.albums || []).map((al) => al.name),
- }));
- if (state.artist && !state.artistCatalog.some((a) => a.name === state.artist)) {
- state.artist = '';
- state.album = '';
- } else if (state.album && !albumsForArtist(state.artist).includes(state.album)) {
- state.album = '';
- }
- return state.artistCatalog;
- }
-
- function resetScrollToTop() {
- _clearLibraryScrollSnapshot();
- _applyMainScrollTop(0);
- }
-
- function setArtist(value) {
- state.artist = value || '';
- if (state.album && !albumsForArtist(state.artist).includes(state.album)) state.album = '';
- resetScrollToTop();
- refreshArtistAlbumSelects();
- reload();
- }
-
- function setAlbum(value) {
- if (!state.artist) { state.album = ''; return; }
- state.album = value || '';
- resetScrollToTop();
- reload();
- }
-
- async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } }
-
- // ── Provider-aware song helpers ────────────────────────────────────────
- // Remote library providers (feedBack-plugin-remote-library-*) expose songs
- // by provider-owned id with their own art/sync/play flow. Reuse the legacy
- // app.js globals (the shared engine) so v3 behaves identically for remote
- // providers instead of assuming every row is a local file. All degrade to
- // the local path when the helpers/providers aren't present.
- function songId(s) {
- return (window._librarySongId ? window._librarySongId(s) : (s.filename || '')) || '';
- }
- function localFilename(s) {
- return window._libraryLocalFilename ? window._libraryLocalFilename(s, state.provider) : (s.filename || '');
- }
- // Stable per-card key: the local filename when present (local song, or a
- // synced remote one), else the provider song id.
- function cardKey(s) { return localFilename(s) || songId(s); }
-
- function artUrl(song) {
- if (window._librarySongArtUrl) return window._librarySongArtUrl(song, state.provider);
- const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : '';
- return song.filename ? '/api/song/' + enc(song.filename) + '/art' + v : '';
- }
-
- // Play a card: local (or already-synced remote) → playSong the local file;
- // an unsynced remote song → sync it first, then play when ready.
- function playCard(song, arrIdx) {
- if (!song) return;
- _saveLibraryScrollSnapshot();
- const lf = localFilename(song);
- if (lf) { if (window.playSong) window.playSong(enc(lf), arrIdx); return; }
- const sid = songId(song);
- if (window.syncLibrarySong && sid) window.syncLibrarySong(state.provider, sid, { playWhenReady: true });
- }
-
- // Accuracy badge markup. `variant` is 'grid' (overlay pill on the card art,
- // default) or 'tree' (inline percentage in the list row). Both carry the
- // .fb-acc-badge class so a post-play refresh (repaintAccuracy) can find and
- // replace them in place without re-rendering the whole list.
- function accuracyBadge(filename, variant) {
- const acc = state.accuracy[filename];
- if (acc == null) return '';
- // Floor, never round: 100% must mean every note hit.
- const pct = Math.floor(acc * 100);
- if (variant === 'tree') {
- const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low';
- return '' + pct + '% ';
- }
- const color = acc >= MASTERY_ACCURACY ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
- const text = acc >= 0.5 && acc < MASTERY_ACCURACY ? 'text-black' : 'text-white';
- return '' +
- ' ' + pct + '% ';
- }
-
- // ── Metadata-refresh per-tile state (the "Refresh Metadata" batch) ─────────
- // A transient badge painted ONLY while a metadata refresh is running: the
- // songs actually being (re)matched animate queued → working → done. Keyed by
- // the card's data-fn (= the local filename the enrichment cache keys on).
- // Empty for every song outside a refresh, so an idle card is byte-identical
- // to before (keeps the windowed grid's height math untouched). Honest state
- // transitions, NOT a fake per-song %: a match is binary (design §11).
- const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange'
- // Cards whose enrichment landed 'failed' (from the grid payload) — tracked so
- // the PERSISTENT "no match" badge survives a batch tile clearing (a
- // _patchCardEnrich with no flag falls back to this instead of wiping it).
- // Populated as cards render (enrichBadge is called per card with the flag).
- const _unmatched = new Set();
- function enrichBadge(fn, unmatched) {
- if (unmatched !== undefined) { if (unmatched) _unmatched.add(fn); else _unmatched.delete(fn); }
- // A live batch tile wins over the resting no-match marker (they never
- // coexist — the batch clears its tiles when it finishes).
- const st = _metaTile[fn] || (_unmatched.has(fn) ? 'nomatch' : null);
- if (!st) return '';
- const M = {
- queued: ['bg-black/60 text-fb-textDim', '• Queued', ''],
- working: ['bg-fb-primary text-white', '⟳ Matching…', ''],
- done: ['bg-fb-good/90 text-black', '✓ Updated', ''],
- nochange: ['bg-black/60 text-fb-textDim', '— No match', ''],
- // Resting indicator: subtle, so a mostly-unmatched library isn't a
- // wall of loud badges. Clickable — a one-click handoff into the
- // Fix-metadata popup for this song (see the [data-meta-fix] wiring).
- nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'Click to fix the metadata by hand'],
- };
- const conf = M[st] || M.queued;
- const fixable = st === 'nomatch'; // resting badge → opens Fix-metadata
- // top-10 clears the tuning chip (top-2) in both normal and select mode;
- // z-20 sits it above the art. Batch states are non-interactive; the
- // resting "no match" badge is the handoff into the popup.
- const cls = 'v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
- ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight ' +
- (fixable ? 'pointer-events-auto cursor-pointer hover:bg-fb-primary hover:text-white transition-colors' : 'pointer-events-none');
- return '' + conf[1] + ' ';
- }
-
- // After a song is scored, the badge for that card is stale until the next
- // full render(). Refresh state.accuracy from the server and patch the badge
- // of any currently-rendered card/row in place (grid + tree). `_dirtyScores`
- // tracks filenames scored while the library was off-screen, applied on enter.
- const _dirtyScores = new Set();
-
- // Set when a library scan / DLC-folder change happened while this screen was
- // off (or showing a stale, e.g. pre-DLC empty, grid). The grid's cached DOM /
- // snapshot would otherwise survive a sidebar return, so we force a full
- // re-fetch on the next entry. (feedBack — "No DLC until restart".)
- let _libraryDirty = false;
-
- function repaintAccuracy(key) {
- const apply = (el, variant) => {
- if (el.getAttribute('data-fn') !== key) return;
- const old = el.querySelector('.fb-acc-badge');
- const html = accuracyBadge(key, variant);
- if (variant === 'grid') {
- const art = el.querySelector('[data-v3-play]');
- if (!art) return;
- if (old) old.remove();
- if (html) art.insertAdjacentHTML('beforeend', html);
- } else if (old) {
- if (html) old.outerHTML = html; else old.remove();
- } else if (html) {
- // No prior badge in this row — insert before the favorite button
- // so it keeps its slot (after the format chip).
- const fav = el.querySelector('[data-fav]');
- if (fav) fav.insertAdjacentHTML('beforebegin', html);
- else el.insertAdjacentHTML('beforeend', html);
- }
- };
- document.querySelectorAll('#v3-songs-grid [data-fn]').forEach((el) => apply(el, 'grid'));
- document.querySelectorAll('#v3-songs-tree [data-fn]').forEach((el) => apply(el, 'tree'));
- }
-
- async function applyScoreRefresh() {
- if (!_dirtyScores.size) return;
- const fresh = await jget('/api/stats/best');
- // Fetch failed — keep the entries dirty so the next trigger (or screen
- // enter) retries rather than silently dropping the badge update.
- if (!fresh) return;
- state.accuracy = fresh;
- const keys = Array.from(_dirtyScores);
- _dirtyScores.clear();
- keys.forEach(repaintAccuracy);
- // A new score shifts the repertoire meter + the keep-practicing shelf.
- renderLibraryHome();
- }
-
- // ── Practice-aware library home (repertoire meter + "Keep practicing") ─────
- // The meter reads state.accuracy (/api/stats/best = {filename: best_accuracy});
- // the shelf reads the growth-edge recommender (/api/library/practice-suggestions,
- // P3). A song is "in your repertoire" at the same threshold the green accuracy
- // badge uses (>= 0.9); a started song below that is "in progress". This is
- // descriptive encouragement — it never gates content, decays, or nags (the
- // goal-gradient / endowed-progress idea, kept healthy).
- const MASTERY_ACCURACY = 0.9;
-
- function _repertoireCounts() {
- let mastered = 0, learning = 0;
- for (const v of Object.values(state.accuracy || {})) {
- if (typeof v !== 'number') continue;
- if (v >= MASTERY_ACCURACY) mastered++; else learning++;
- }
- return { mastered, learning };
- }
-
- // The home block is the unfiltered "front door": shown on the grid view when
- // the user isn't running a focused query (search / filter) or selecting.
- // Local provider only — the meter's mastered count and the shelf both read
- // local practice stats (state.accuracy / the practice-suggestions endpoint),
- // so on a remote provider they'd mix local numerators with a remote song total
- // and play local files while browsing a remote library. Hide it there.
- function libHomeVisible() {
- return state.view === 'grid' && state.provider === 'local'
- && !state.selectMode && !state.q && activeFilterCount() === 0;
- }
-
- let _homeToken = 0;
- async function renderLibraryHome() {
- const host = document.getElementById('v3-lib-home');
- if (!host) return;
- if (!libHomeVisible()) { host.classList.add('hidden'); return; }
- // A newer render (view/filter/score change) supersedes this one so a
- // slow response can't repaint a home the grid already moved past.
- const myToken = ++_homeToken;
- // Unfiltered library size for the meter denominator (the grid's
- // state.total tracks the active filter; the meter is library-wide) +
- // the growth-edge "practice next" shelf rows, fetched together.
- const [stats, suggestions] = await Promise.all([
- jget('/api/library/stats?provider=' + enc(state.provider)),
- jget('/api/library/practice-suggestions?limit=8'),
- ]);
- if (_homeToken !== myToken || !libHomeVisible()) { // changed mid-fetch
- if (_homeToken === myToken) host.classList.add('hidden');
- return;
- }
- const total = (stats && (stats.total_songs ?? stats.total)) || 0;
- if (total <= 0) { host.classList.add('hidden'); return; } // empty library
- // Shelf = the growth-edge recommender (P3): attempted-but-not-mastered
- // songs ordered by difficulty-appropriateness × mastery-proximity, so it
- // points at the version worth practicing next rather than just the most
- // recent. The server already gates (not-mastered) + aggregates per song,
- // so the rows are the shelf as-is; each row's `arrangement` is the one
- // closest to mastery (what a click should open).
- const shelf = Array.isArray(suggestions) ? suggestions : [];
-
- const { mastered, learning } = _repertoireCounts();
- // Day-one zero-state (launch polish): no practice data and no real
- // growth-edge rows → an invitational meter, never "0 of N". Starter
- // rows are the server's no-attempts fallback, so they count as "no
- // practice yet" too.
- const starterShelf = shelf.length > 0 && !!shelf[0].starter;
- const invitational = (mastered + learning) === 0 && (!shelf.length || starterShelf);
- let meter;
- if (invitational) {
- meter =
- '
' +
- '
' +
- 'Repertoire ' +
- 'grows as you master songs ' +
- '
' +
- '
' +
- '
';
- } else {
- const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
- meter =
- '' +
- '
' +
- 'Repertoire ' +
- '' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
- (learning ? ' · ' + learning + ' in progress' : '') + ' ' +
- '
' +
- '
' +
- '
';
- }
-
- let shelfHtml = '';
- if (shelf.length) {
- const cards = shelf.map((r) =>
- '' +
- '' +
- '
' +
- accuracyBadge(r.filename) +
- '
' +
- '' + esc(r.title) + '
' +
- '' + esc(r.artist) + '
' +
- ' ').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 per
- // arrangement. wireCards() binds the click to playCard(song, index), which
- // opens THAT arrangement in the highway via playSong(filename, index).
- // Shared by the grid card and the tree row so both views render the same
- // badges with the same click-to-open behaviour. Capped at 4 to match the
- // card layout.
- function arrChipsHtml(song) {
- return (song.arrangements || []).slice(0, 4).map((a) =>
- '' + esc(a.name) + ' ').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 '⚑ ' + n + ' charts ';
- }
-
- // ── Tuning-match flags (working-tuning PR 6) ───────────────────────────────
- // Colour each song's tuning chip by whether your CURRENT working tuning covers
- // it: green = play it now, amber = needs a retune. Uses the tuner plugin's
- // coverage check (async) + the host workingTuning state — BOTH feature-detected,
- // so without them the chips render exactly as before. Decoration runs AFTER the
- // (sync) window paint so scrolling stays snappy; a token cancels a superseded pass.
- let _tuningDecorToken = 0;
- // The instrument the current grid was queried/painted for, so a
- // working-tuning change can tell a guitar<->bass SWITCH (re-query) from a
- // retune within the same instrument (re-colour only).
- let _lastRenderInstrument = null;
- function _applyChipMatch(chip, stateName) {
- chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400');
- chip.classList.add(stateName === 'match' ? 'bg-emerald-500'
- : stateName === 'retune' ? 'bg-amber-400' : 'bg-fb-mid');
- if (!chip.dataset.baseTitle) chip.dataset.baseTitle = chip.getAttribute('title') || '';
- chip.setAttribute('title', chip.dataset.baseTitle + (stateName === 'match'
- ? ' — matches your tuning' : stateName === 'retune' ? ' — needs a retune' : ''));
- }
- async function decorateTuningChips(grid) {
- if (!grid) return;
- const cov = window._tunerAutoOpen && window._tunerAutoOpen.coverageReport;
- const hasWT = window.feedBack && window.feedBack.workingTuning
- && typeof window.feedBack.workingTuning.get === 'function';
- if (typeof cov !== 'function' || !hasWT) return; // feature-detect → no flags
- const token = ++_tuningDecorToken;
- const chips = grid.querySelectorAll('[data-tuning-chip][data-tuning-offsets]');
- for (const chip of chips) {
- const offs = chip.getAttribute('data-tuning-offsets').split(',').map(Number);
- if (!offs.length || offs.some((n) => !isFinite(n))) continue;
- // Pass the instrument so coverage uses the right base pitches (bass vs guitar).
- const arrangement = chip.dataset.tuningBass === '1' ? 'Bass' : 'Lead';
- let rep = null;
- try { rep = await cov({ tuning: offs, stringCount: offs.length, arrangement: arrangement }); } catch (_) { rep = null; }
- if (token !== _tuningDecorToken) return; // superseded by a re-paint / tuning change
- if (rep) _applyChipMatch(chip, rep.covered ? 'match' : 'retune');
- }
- }
-
- function songCard(song) {
- const fav = song.favorite;
- const key = cardKey(song);
- // §7.1 display-chart switch (P5e): under a chart-intrinsic filter the
- // server may attach `display_chart` — the member that MATCHES the
- // filter when the representative doesn't. The card SHOWS and PLAYS
- // that chart, while the row identity (sort keys, data-fn, the
- // accuracy/heart anchor = the preferred chart) stays the rep's.
- const shown = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
- // In select mode the checkbox occupies top-2 left-2, so shift the
- // tuning chip right (left-9) to avoid overlapping it.
- // Bass players see the bass chart's tuning (guitar fallback) — the card
- // must agree with the facet/filter or the grid contradicts the pills.
- const shownTuning = shownTuningName(shown);
- const tuningLabel = (typeof window.displayTuningName === 'function')
- ? window.displayTuningName(shownTuning)
- : (shownTuning || '');
- let tuning = '';
- if (tuningLabel) {
- const rawOffsets = (typeof window.parseRawTuningOffsets === 'function')
- ? (window.parseRawTuningOffsets(shownTuningOffsets(shown))
- || window.parseRawTuningOffsets(shownTuning))
- : null;
- const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets
- && typeof window.displayTuningTargets === 'function')
- ? window.displayTuningTargets(rawOffsets, { tuningName: tuningLabel })
- : '';
- // Mark a tuning we INFERRED from the guitar chart (this song has no
- // bass arrangement) so a bass player isn't shown a borrowed tuning
- // as if it were their part's. `~` keeps the chip compact; the title
- // spells it out.
- const inferred = shown.tuning_inferred === true;
- const badgeTitle = (targetNotes
- ? ('Custom Tuning: ' + targetNotes)
- : tuningLabel)
- + (inferred ? ' — from the guitar chart (no bass arrangement)' : '');
- const pos = 'absolute top-2 ' + (state.selectMode ? 'left-9' : 'left-2');
- // Tag the chip with its offsets so decorateTuningChips() can colour it
- // green (matches your current tuning) / amber (needs a retune) after paint.
- // Also flag a bass-only song (every arrangement is a bass part) so coverage
- // scores its bass tuning against the bass base pitches, not guitar — otherwise
- // a 4-string bass tuning read as guitar can false-match a guitar player.
- const chipArrs = shown.arrangements || [];
- // Bass either because the chip is SHOWING the bass chart's tuning
- // (a bass player on a song that has one), or because every
- // arrangement is a bass part. Checked via libInstrument() rather
- // than comparing the two names — they are EQUAL for most songs, so
- // a value comparison would flag a guitarist's chip as bass.
- const chipIsBass = (libInstrument() === 'bass' && !!shown.bass_tuning_name)
- || (chipArrs.length > 0
- && chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || '')));
- const matchAttr = (rawOffsets && rawOffsets.length)
- ? ' data-tuning-chip data-tuning-offsets="' + esc(rawOffsets.join(',')) + '"'
- + (chipIsBass ? ' data-tuning-bass="1"' : '') : '';
- if (targetNotes) {
- tuning = ''
- + esc('Custom Tuning') + '' + esc(targetNotes) + ' ';
- } else {
- tuning = '' + esc(tuningLabel) + (inferred ? ' ~ ' : '') + ' ';
- }
- }
- // Display-only (pointer-events-none) so a click falls through to the
- // card's data-v3-play handler, which owns the toggle — avoids double-toggle.
- const checkbox = state.selectMode
- ? ' '
- : '';
- const arrChips = arrChipsHtml(shown);
- const chartsChip = chartsChipHtml(song);
- // Plugin-contributed card actions placed 'inline' (in the hover action
- // row) or 'overlay' (centered over the art). Menu-placed actions live in
- // the ⋮ menu (openCardMenu); rendering these here means plugins using
- // those placements are no longer silently dropped. No bundled action
- // uses them, so for the stock library both strings are empty — the card
- // renders exactly as before.
- const reg = sm && sm.libraryCardActions;
- const acts = (reg && typeof reg.list === 'function') ? reg.list(song) : [];
- const actBtn = (a) =>
- '' + esc(a.icon || a.label || '•') + ' ';
- 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 +
- '' + (fav ? '♥' : '♡') + ' ' +
- '🔖 ' +
- '⋮ ' +
- '
' +
- '
' + 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) + ' '
- : '
' + esc(song.artist) + '
') +
- // Always emit the chip row (even when empty) at a FIXED single-line
- // height — uniform card height is what makes the windowed grid's
- // absolute-position math exact (.v3-card-chips in v3.css).
- '
' + chartsChip + arrChips + '
' +
- '
';
- }
-
- // Per-card action menu, built from the ui.library-card-injection registry
- // (core Edit/Retune + any plugin-registered actions).
- let _closeCardMenu = null; // tears down the currently-open card menu + its document closer
- function openCardMenu(cardEl, song, anchorBtn, pos) {
- // Fully close any already-open menu first — removing just the DOM node
- // (as before) would orphan its document-level click closer.
- if (_closeCardMenu) _closeCardMenu();
- const reg = sm && sm.libraryCardActions;
- // Only show actions intended for the overflow menu — actions placed
- // 'inline'/'overlay' get their own affordances on the card (see songCard).
- // Undefined placement defaults to the menu.
- const items = (reg ? reg.list(song) : []).filter((a) => !a.placement || a.placement === 'menu');
- const menu = document.createElement('div');
- // pos (right-click) positions the menu at the pointer via `fixed`;
- // the ⋮ button keeps the absolute top-right anchor.
- menu.className = 'v3-card-menu z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm ' + (pos ? 'fixed' : 'absolute top-10 right-2');
- // Multi-chart entries (P5d): a grouped grid row carries chart_count +
- // work_key (P5a annotation), so the menu knows inline whether this card
- // stands for versions. Local library only — the work API is local.
- const canCharts = state.provider === 'local' && song.work_key && song.chart_count >= 2;
- // Play follows the DISPLAYED chart (see wireCards) — under an intrinsic
- // filter that's the matching member, not the representative.
- const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
- const rows = [
- { id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(playTarget.filename)); } },
- ...(canCharts ? [
- { id: '__charts', label: 'Charts (' + song.chart_count + ')…' },
- { id: '__playver', label: 'Play version ▸' },
- ] : []),
- // Undo for the drawer's "Split out" (P5e) — a split chart is its
- // own singleton card (no ⚑ chip), so this is its only way back.
- ...(state.provider === 'local' && song.is_split
- ? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []),
- { id: '__playlist', label: 'Add to playlist' },
- { id: '__save', label: 'Save for later' },
- // Artist page (PR-B, entry point 1) — local library only (the
- // page reads the local DB) and gated on the Settings toggle.
- ...(state.provider === 'local' && song.artist && state.artistPagesEnabled !== false
- ? [{ id: '__artist', label: 'Go to artist' }] : []),
- ...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
- // Metadata + file actions (R2) — local library only (they all
- // address the local DB / filesystem). Both openers (⋮ and
- // right-click) share this list, so parity is structural.
- ...(state.provider === 'local' && song.filename ? [
- { id: '__fixmatch', label: 'Fix metadata…' },
- { id: '__cover', label: 'Change cover…' },
- { id: '__refreshmeta', label: 'Refresh metadata' },
- { id: '__getinfo', label: 'Get info…' },
- { id: '__remove', label: 'Remove from library', destructive: true },
- ] : []),
- ];
- menu.innerHTML = rows.map((r) =>
- '' + esc(r.label) +
- (r.plugin && r.plugin !== 'core' ? '' + esc(r.plugin) + ' ' : '') + ' ').join('');
- if (pos) {
- // Right-click: position the menu at the pointer (fixed, viewport-
- // relative), clamped so it never spills off the right/bottom edge.
- document.body.appendChild(menu);
- const x = Math.min(pos.x, window.innerWidth - menu.offsetWidth - 8);
- const y = Math.min(pos.y, window.innerHeight - menu.offsetHeight - 8);
- menu.style.left = Math.max(8, x) + 'px';
- menu.style.top = Math.max(8, y) + 'px';
- } else {
- cardEl.appendChild(menu);
- }
- // Tear down BOTH the menu and its document-level closer together, so a
- // menu-item click doesn't leave the closer attached (it would otherwise
- // leak, retaining this menu's closures until the next document click).
- const closer = (e) => { if (!menu.contains(e.target) && e.target !== anchorBtn) closeMenu(); };
- function closeMenu() { menu.remove(); document.removeEventListener('click', closer); if (_closeCardMenu === closeMenu) _closeCardMenu = null; }
- _closeCardMenu = closeMenu;
- menu.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async (e) => {
- e.stopPropagation();
- const id = b.getAttribute('data-act');
- // 'Play version ▸' swaps THIS menu's rows for the work's charts —
- // it must expand in place, so it's the one entry that doesn't close.
- if (id === '__playver') { await _expandPlayVersions(menu, song, closeMenu); return; }
- closeMenu();
- if (id === '__play') { playCard(playTarget); return; }
- if (id === '__charts') { openChartsDrawer(song.work_key, song); return; }
- if (id === '__unsplit') {
- if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged();
- return;
- }
- if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
- if (id === '__save') { if (window.v3Saved) await window.v3Saved.toggle(song.filename); return; }
- if (id === '__artist') { openArtistPage(song.artist); return; }
- // Per-chart metadata actions follow the DISPLAYED chart (playTarget),
- // like Play — under an intrinsic filter that's the matching member,
- // not the group representative. (__remove stays on `song`: it needs
- // the group's work_key/chart_count and pre-ticks the shown chart.)
- if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
- if (id === '__cover') {
- if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album });
- return;
- }
- if (id === '__refreshmeta') {
- // Silent on success (hearing-safe, like the rest of the match
- // layer) — the re-match trickles in through the normal pass.
- await jsend('POST', '/api/enrichment/refresh/' + enc(playTarget.filename));
- return;
- }
- if (id === '__getinfo') { openGetInfo(playTarget); return; }
- if (id === '__remove') { await removeSongsFlow(song); return; }
- if (reg) await reg.run(id, song, { source: 'v3-songs' });
- }));
- // Tree rows ride the (ungrouped) artists endpoint, so they don't carry
- // chart_count/work_key — resolve the work lazily and slot a
- // "Charts (N)…" entry into the still-open menu when versions exist.
- // Grid rows already carry both (chart_count defined ⇒ no fetch).
- if (state.provider === 'local' && song.chart_count === undefined && song.filename) {
- jget('/api/chart/' + enc(song.filename) + '/work').then((w) => {
- if (!w || !menu.isConnected) return;
- const addEntry = (label, run) => {
- const b = document.createElement('button');
- b.className = 'w-full text-left px-3 py-1.5 hover:bg-fb-card/60 text-fb-text';
- b.textContent = label;
- b.addEventListener('click', (e) => { e.stopPropagation(); closeMenu(); run(); });
- menu.appendChild(b);
- };
- if (w.chart_count >= 2 && w.work_key) {
- addEntry('Charts (' + w.chart_count + ')…', () => openChartsDrawer(w.work_key, song));
- }
- if (w.is_split) {
- addEntry('Rejoin other versions', async () => {
- if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged();
- });
- }
- });
- }
- setTimeout(() => document.addEventListener('click', closer), 0);
- }
-
- // 'Play version ▸' (P5d): swap the ⋮ menu's rows for the work's charts;
- // picking one plays that chart directly. A one-off alternate play — the
- // keeper/headline doesn't move (stats record to the played chart's own
- // filename), which is the design's "casual try ≠ deliberate adopt".
- async function _expandPlayVersions(menu, song, closeMenu) {
- const data = await jget('/api/work/' + enc(song.work_key) + '/charts');
- if (!data || !Array.isArray(data.charts) || !data.charts.length || !menu.isConnected) return;
- menu.innerHTML = data.charts.map((c) => {
- const tl = (typeof window.displayTuningName === 'function')
- ? window.displayTuningName(c.tuning_name || c.tuning) : (c.tuning_name || '');
- return '' +
- (c.is_representative ? '● ' : '') + esc(c.title) +
- (tl ? '' + esc(tl) + ' ' : '') +
- ' ';
- }).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 '' +
- ' ' +
- '' + esc(c.title) +
- (c.is_representative ? ' ● ' : '') + ' ' +
- (meta ? '' + esc(meta) + ' ' : '') +
- '' + esc(c.filename) + ' ';
- }).join('');
- const n = sel.size;
- overlay.innerHTML =
- '';
- 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 =
- '';
- document.body.appendChild(overlay);
- overlay.querySelector('[data-gi-x]')?.addEventListener('click', done);
- }
-
- // ── Charts drawer (P5d, design §7.1 UX-2/3) ────────────────────────────────
- // The single deep-management surface for a work's charts. A body-appended
- // slide-in panel (the filter-drawer idiom; body-appended like the playlist
- // picker so it opens from any view) listing every chart of the work as a
- // radiogroup — the checked row is the keeper the grid card plays. Clicking
- // an unchecked row (or Enter/Space on it) = Set as preferred, one tap;
- // "Reset to auto pick" appears when the keeper is your explicit pick.
- // Writes go through the work-charts API and the drawer re-renders from the
- // response (there's no server-side library event bus — the drawer is its
- // own refresh); the grid re-fetches because the representative may have
- // flipped. Global mode only — the slot-scoped (curated-album) mode is P6;
- // the per-row Split escape hatch is P5e.
- let _chartsPrevFocus = null; // focus to restore when the drawer closes
-
- function _chartsDrawerEls() {
- let ov = document.getElementById('v3-charts-overlay');
- let dr = document.getElementById('v3-charts-drawer');
- if (!ov) {
- ov = document.createElement('div');
- ov.id = 'v3-charts-overlay';
- ov.className = 'fixed inset-0 bg-black/50 z-40 hidden';
- ov.addEventListener('click', closeChartsDrawer);
- document.body.appendChild(ov);
- }
- if (!dr) {
- dr = document.createElement('aside');
- dr.id = 'v3-charts-drawer';
- dr.className = 'fixed top-0 right-0 h-full w-full sm:w-96 bg-fb-sidebar border-l border-fb-border/50 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll';
- dr.setAttribute('role', 'dialog');
- dr.setAttribute('aria-modal', 'true');
- dr.setAttribute('aria-label', 'Charts of this song');
- // a11y: Escape closes; Tab is trapped inside the open drawer
- // (aria-modal alone doesn't trap for keyboard users); ArrowUp/Down
- // move focus between the chart rows (focus only — selection stays
- // on Enter/Space, since a native-radio "arrow = select" would fire
- // a preferred write on every keystroke).
- dr.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') { e.preventDefault(); closeChartsDrawer(); return; }
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
- const rows = [...dr.querySelectorAll('[role="radio"]')];
- if (!rows.length) return;
- const i = rows.indexOf(document.activeElement);
- if (i === -1) return;
- e.preventDefault();
- rows[(i + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length].focus();
- return;
- }
- if (e.key !== 'Tab') return;
- const foci = dr.querySelectorAll('button, [tabindex]:not([tabindex="-1"])');
- if (!foci.length) return;
- const first = foci[0], last = foci[foci.length - 1];
- if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
- else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
- });
- document.body.appendChild(dr);
- }
- return { ov: ov, dr: dr };
- }
-
- // One chart row. `checked` = the current keeper (pref or auto-pick). The
- // filename line is deliberate: duplicate charts usually share title/artist,
- // so the pack filename is often the only human-readable distinguisher.
- function _chartRowHtml(c, data) {
- const checked = c.is_representative;
- const prefLabel = checked
- ? (data.preferred_source === 'user' ? 'Preferred — your pick' : 'Preferred (auto)')
- : '';
- const tuningLabel = (typeof window.displayTuningName === 'function')
- ? window.displayTuningName(c.tuning_name || c.tuning)
- : (c.tuning_name || '');
- const meta = [fmtLabel(c), tuningLabel,
- (c.arrangements || []).map((a) => a.name).join('/'),
- c.year ? String(c.year) : '']
- .filter(Boolean).join(' · ');
- const acc = (typeof c.best_accuracy === 'number')
- ? '' + Math.floor(c.best_accuracy * 100) + '% '
- : 'not played ';
- return '' +
- '
' +
- '
' +
- '
' + esc(c.title) + '
' +
- '
' + esc(meta) + ' · ' + acc + '
' +
- '
' + esc(c.filename) + '
' +
- (prefLabel ? '
' + esc(prefLabel) + '
' : '') +
- '
' +
- '
▶ ' +
- '
' +
- '
' +
- '+ Playlist ' +
- // Split escape hatch (P5e, §7.1): "these aren't the same song".
- // Only offered while the work still has 2+ charts — splitting the
- // last member is meaningless. Undo lives in the split-out card's
- // ⋮ menu ("Rejoin other versions").
- (data.count >= 2
- ? 'Split out '
- : '') +
- '
' +
- '
';
- }
-
- // 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'
- ? '
Reset to auto pick '
- : '
Auto pick sticks with a chart you\'ve practised; otherwise most complete → newest. Tap a chart to pin your keeper.
') +
- '
';
-
- dr.querySelector('[data-charts-close]').addEventListener('click', closeChartsDrawer);
- dr.querySelector('[data-charts-auto]')?.addEventListener('click', async () => {
- const fresh = await jsend('DELETE', '/api/work/' + enc(data.work_key) + '/preferred');
- if (!fresh) return;
- _renderChartsDrawer(fresh);
- _groupChanged();
- dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
- });
- dr.querySelectorAll('[data-ch]').forEach((row) => {
- const fn = row.getAttribute('data-ch');
- const setPreferred = async () => {
- if (row.getAttribute('aria-checked') === 'true') return; // already the keeper
- const fresh = await jsend('PUT', '/api/work/' + enc(data.work_key) + '/preferred', { filename: fn });
- if (!fresh) return;
- _renderChartsDrawer(fresh, { switched: true });
- _groupChanged();
- dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
- };
- row.addEventListener('click', setPreferred);
- row.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setPreferred(); }
- });
- row.querySelector('[data-ch-play]')?.addEventListener('click', (e) => {
- e.stopPropagation();
- closeChartsDrawer();
- _saveLibraryScrollSnapshot();
- if (window.playSong) window.playSong(enc(fn));
- });
- row.querySelector('[data-ch-pl]')?.addEventListener('click', (e) => {
- e.stopPropagation();
- addFilenamesToPlaylist([fn]);
- });
- row.querySelector('[data-ch-split]')?.addEventListener('click', async (e) => {
- e.stopPropagation();
- const ok = await jsend('POST', '/api/chart/' + enc(fn) + '/split');
- if (!ok) return;
- _groupChanged();
- // Re-read the ORIGINAL work — the split chart is gone from it
- // (now its own card in the grid; its ⋮ menu offers the rejoin).
- const fresh = await jget('/api/work/' + enc(data.work_key) + '/charts');
- if (fresh && Array.isArray(fresh.charts) && fresh.charts.length) _renderChartsDrawer(fresh);
- else closeChartsDrawer();
- });
- });
- }
-
- async function openChartsDrawer(workKey, _song) {
- if (!workKey) return;
- const data = await jget('/api/work/' + enc(workKey) + '/charts');
- if (!data || !Array.isArray(data.charts) || !data.charts.length) return;
- const els = _chartsDrawerEls();
- _chartsPrevFocus = document.activeElement;
- _renderChartsDrawer(data);
- els.ov.classList.remove('hidden');
- els.dr.classList.remove('translate-x-full');
- els.dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
- }
-
- function closeChartsDrawer() {
- document.getElementById('v3-charts-overlay')?.classList.add('hidden');
- document.getElementById('v3-charts-drawer')?.classList.add('translate-x-full');
- if (_chartsPrevFocus && typeof _chartsPrevFocus.focus === 'function' && _chartsPrevFocus.isConnected) {
- _chartsPrevFocus.focus();
- }
- _chartsPrevFocus = null;
- }
-
- // Global opener — the ⚑ chip routes through this, and other views/plugins
- // (P2's details drawer, dashboards) can open the drawer without reaching
- // into this module.
- window.__fbOpenChartsDrawer = openChartsDrawer;
-
- function wireCards(scope) {
- scope.querySelectorAll('[data-fn]').forEach((el) => {
- if (el.dataset.wired) return; // don't double-bind on append/auto-fill
- el.dataset.wired = '1';
- const fn = el.getAttribute('data-fn');
- const song = state.songsById[fn] || { filename: fn };
- // §7.1: when a chart-intrinsic filter attached a display_chart,
- // the card SHOWS that member — so play actions target it too
- // (its arrangement indices match the rendered chips). Identity
- // actions (heart/save/playlist/menu registry) stay on the rep row.
- const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
- el.querySelectorAll('[data-v3-play]').forEach((pe) => pe.addEventListener('click', (e) => {
- if (state.selectMode) { e.preventDefault(); toggleSelect(fn, el); return; }
- playCard(playTarget); // local → play; unsynced remote → sync then play
- }));
- el.querySelector('[data-menu]')?.addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(el, song, e.currentTarget); });
- // Native right-click opens the same overflow menu at the pointer.
- el.addEventListener('contextmenu', (e) => { e.preventDefault(); openCardMenu(el, song, null, { x: e.clientX, y: e.clientY }); });
- el.querySelectorAll('[data-arr]').forEach((ab) => ab.addEventListener('click', (e) => {
- e.stopPropagation();
- const idx = ab.getAttribute('data-arr');
- playCard(playTarget, idx === '' ? undefined : Number(idx));
- }));
- // ⚑ charts chip → the Charts drawer (P5d). Never a card play — the
- // chip row sits outside [data-v3-play]; stopPropagation is
- // belt-and-braces.
- el.querySelector('[data-charts]')?.addEventListener('click', (e) => {
- e.stopPropagation();
- openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
- });
- // "No match" badge → straight into the Fix-metadata popup for this
- // song (the batch → fix handoff). stopPropagation so it doesn't also
- // trigger the card's play. Follows the displayed chart, like the menu.
- el.querySelector('[data-meta-fix]')?.addEventListener('click', (e) => {
- e.stopPropagation();
- if (window.__fbFixMatch) window.__fbFixMatch(playTarget);
- });
- // Artist line → the artist page (PR-B). In select mode the grid's
- // capture-phase toggle intercepts first, so selection still wins.
- el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => {
- e.stopPropagation();
- openArtistPage(song.artist);
- });
- el.querySelector('[data-fav]')?.addEventListener('click', async (e) => {
- e.stopPropagation();
- const btn = e.currentTarget;
- try {
- const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) });
- const d = await r.json();
- btn.textContent = d.favorite ? '♥' : '♡';
- btn.setAttribute('aria-pressed', d.favorite ? 'true' : 'false');
- // Swap exactly the idle colour this button was rendered with
- // (grid = text-white, tree/List view = text-fb-textDim). The old
- // hardcoded text-white toggle never removed the list view's
- // text-fb-textDim, so the heart changed glyph but stayed dim
- // (never turned red) until a re-search re-rendered the row.
- const idle = btn.getAttribute('data-fav-idle') || 'text-white';
- btn.classList.toggle('text-fb-accent', d.favorite);
- btn.classList.toggle(idle, !d.favorite);
- song.favorite = d.favorite; // keep the model in sync for a re-render/recycle
- } catch (err) { /* */ }
- });
- el.querySelector('[data-save]')?.addEventListener('click', async (e) => {
- e.stopPropagation();
- if (window.v3Saved) { const saved = await window.v3Saved.toggle(fn); e.currentTarget.classList.toggle('text-fb-primary', !!saved); }
- });
- // Inline/overlay plugin card actions → run via the shared registry.
- el.querySelectorAll('[data-act-card]').forEach((ab) => ab.addEventListener('click', async (e) => {
- e.stopPropagation();
- const reg = sm && sm.libraryCardActions;
- if (reg) await reg.run(ab.getAttribute('data-act-card'), song, { source: 'v3-songs' });
- }));
- });
- }
-
- // ── Multi-select + batch actions ──────────────────────────────────────--
- function toggleSelect(fn, el) {
- if (state.selected.has(fn)) state.selected.delete(fn); else state.selected.add(fn);
- const on = state.selected.has(fn);
- const cb = el.querySelector('[data-select]'); if (cb) cb.checked = on;
- el.querySelector('[data-v3-play]')?.classList.toggle('ring-2', on);
- el.querySelector('[data-v3-play]')?.classList.toggle('ring-fb-primary', on);
- renderBatchBar();
- }
-
- // Bulletproof multi-select: in select mode a capture-phase click anywhere
- // inside a [data-fn] row toggles the card and STOPS the event, so nothing (a
- // per-card handler, a stray/legacy listener, an arrangement chip) can start
- // playback. Attached ONCE to each persistent host (grid / tree / artist page)
- // — their innerHTML is replaced on re-render but the host element survives,
- // so a single bind never double-fires. Group headers / non-song chrome sit
- // outside any [data-fn], so closest() is null and their native clicks pass
- // through untouched.
- function bindSelectGuard(hostEl) {
- if (!hostEl) return;
- hostEl.addEventListener('click', (e) => {
- if (!state.selectMode) return;
- const card = e.target.closest('[data-fn]');
- if (!card || !hostEl.contains(card)) return;
- e.preventDefault();
- e.stopImmediatePropagation();
- toggleSelect(card.getAttribute('data-fn'), card);
- }, true);
- }
-
- function setSelectMode(on) {
- state.selectMode = on;
- if (!on) state.selected.clear();
- const btn = document.getElementById('v3-songs-select');
- if (btn) btn.className = btnCtrl + (on ? ' bg-fb-primary text-white' : '');
- reload(); // re-render cards with/without checkboxes
- renderBatchBar();
- }
-
- function renderBatchBar() {
- let bar = document.getElementById('v3-songs-batch');
- if (!state.selectMode || state.selected.size === 0) { if (bar) bar.remove(); return; }
- if (!bar) {
- bar = document.createElement('div');
- bar.id = 'v3-songs-batch';
- bar.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-fb-card border border-fb-border/60 rounded-full shadow-xl px-4 py-2';
- document.body.appendChild(bar);
- }
- bar.innerHTML =
- '' + state.selected.size + ' selected ' +
- 'Add to playlist ' +
- 'Edit details ' +
- 'Save for Later ' +
- 'Clear ';
- 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 '' +
- '' + boxFor(r) + ' ' +
- '' + esc(r.name) + ' ' +
- '' + (r.count || 0) + ' ';
- }
- 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 =
- '' +
- '
Add ' + fns.length + ' song' + (fns.length === 1 ? '' : 's') + ' to playlist ' +
- '✕ ' +
- '
Create
' +
- (rows.length > 8 ? '
' : '') +
- '
' + listHtml() + '
' +
- '
Done
' +
- '
';
- 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) =>
- '' + label + ' ';
- const addChips = bulk.add.map((t) => '' + esc(t) +
- '× ').join('');
- const rmChips = [...present].sort((a, b) => a.localeCompare(b)).map((t) =>
- '' + esc(t) + ' ').join('');
- const nothing = bulk.diff === 'keep' && !bulk.add.length && !bulk.remove.size;
- overlay.innerHTML =
- '';
-
- 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.
' +
- '
Open Settings ' +
- '
';
- }
-
- 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 ''
- + esc(L) + ' ';
- }).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) =>
- '
' +
- '' +
- (a.cover ? '
' : '') +
- '
' +
- '' + esc(a.album) + '
' +
- '' + esc(a.artist) + ' · ' + (a.count || 0) + ' track' + (a.count === 1 ? '' : 's') + '
' +
- ' ').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(backLabel) + ' ' +
- '' +
- '
' + esc(a.album) + ' ' +
- '
' + esc(a.artist) + ' · ' + songs.length + ' track' + (songs.length === 1 ? '' : 's') + '
' +
- (songs.length ? '
▶ Play album ' : '') +
- '
' +
- '' + songs.map((s, i) =>
- '' +
- '' + (i + 1) + ' ' +
- '' + esc(s.title || s.filename) + ' ').join('') + ' ';
- 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 ? '
' + 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].
- '
' +
- '' + (s.favorite ? '♥' : '♡') + ' ' +
- '🔖 ' +
- '⋮ ' +
- '
' +
- '
');
- }
-
- // ── Artist page (PR-B, artist-pages launch charrette) ──────────────────────
- // An in-place sub-render like openAlbum(): the artist "in your library" — a
- // shelf plus your relationship to it, never a discography browser (locked
- // position 1). Renders 100% from the local /page payload; the external
- // links row is the one decorated extra, gated on the opt-in Settings toggle
- // AND a MusicBrainz match, fetched lazily and cached server-side. Every
- // count obeys the DENOMINATOR LAW (locked position 2): songs YOU OWN.
-
- function _artistHostEl() { return document.getElementById('v3-songs-artistpage'); }
-
- // ── Instrument-aware tuning (the bass-player tuning-filter report) ────────
- // A song's bass chart is often in a different tuning from its guitar chart,
- // so the tuning facet/filter/sort and the card chip must speak for the
- // instrument the player actually plays. The host's working-tuning
- // capability already holds the live selection (seeded from /api/settings on
- // boot, updated when the player switches) — read it rather than adding
- // another settings fetch. `state.settingsInstrument` is the fallback for
- // hosts where the capability isn't mounted.
- // Three perspectives, matching `active_instrument_profile`: lead and rhythm
- // guitar charts can be tuned differently too, so a rhythm player hits the
- // same bug a bassist did. The PROFILE is the only three-valued source (the
- // working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm),
- // so it wins; the capability is the live fallback for hosts where the
- // profile hasn't loaded.
- const PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass'];
- function libInstrument() {
- if (PERSPECTIVES.indexOf(state.settingsProfile) >= 0) return state.settingsProfile;
- try {
- const wt = window.feedBack && window.feedBack.workingTuning;
- if (wt && typeof wt.get === 'function') {
- const cur = wt.get();
- if (cur && cur.instrument === 'bass') return 'bass';
- }
- } catch (_) { /* capability absent/erroring — fall through to settings */ }
- return state.settingsInstrument === 'bass' ? 'bass' : 'guitar-lead';
- }
-
- // "Playable without retuning" mode reads the player's CURRENT tuning from
- // the working-tuning capability (the live session state the tuner writes),
- // not a separate setting. No capability => we cannot know the current
- // tuning, so the mode is unavailable rather than guessed.
- function currentWorkingTuning() {
- try {
- const wt = window.feedBack && window.feedBack.workingTuning;
- if (!wt || typeof wt.get !== 'function') return null;
- const cur = wt.get();
- if (!cur || !Array.isArray(cur.offsets) || !cur.offsets.length) return null;
- return cur;
- } catch (_) { return null; }
- }
-
- function playableAvailable() { return !!currentWorkingTuning(); }
-
- function applyPlayableParams(p, f) {
- if (f.tuningMatch !== 'playable') return;
- const cur = currentWorkingTuning();
- if (!cur) return;
- p.set('tuning_match', 'playable');
- p.set('playable_offsets', cur.offsets.join(','));
- p.set('playable_instrument', cur.instrument === 'bass' ? 'bass' : 'guitar');
- p.set('playable_string_count', String(cur.stringCount || cur.offsets.length));
- }
-
- // Short human label for the perspective, for the facet/sort headers.
- function libInstrumentLabel() {
- const p = libInstrument();
- return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead';
- }
-
- // The column a row's tuning lives in for the active perspective.
- function perspectiveTuningField() {
- const p = libInstrument();
- return p === 'bass' ? 'bass_tuning_name'
- : p === 'guitar-rhythm' ? 'rhythm_tuning_name' : '';
- }
-
- // The tuning a card should SHOW: bass players see the bass chart's tuning,
- // falling back to the song (guitar-derived) tuning when the song has no
- // bass arrangement — the common case, so the fallback is not an edge path.
- function shownTuningName(song) {
- const f = perspectiveTuningField();
- if (f && song[f]) return song[f];
- return song.tuning_name || song.tuning;
- }
-
- function shownTuningOffsets(song) {
- const f = perspectiveTuningField();
- if (f && song[f]) {
- return song[f.replace('_name', '_offsets')] || song.tuning_offsets;
- }
- return song.tuning_offsets;
- }
-
- // Sync the two Settings gates into module state (fire-and-forget — the
- // cached flags gate entry-point rendering; openArtistPage re-checks).
- function refreshArtistPageGates() {
- return jget('/api/settings').then((cfg) => {
- if (!cfg) return;
- state.artistPagesEnabled = cfg.artist_pages_enabled !== false;
- state.artistLinksEnabled = cfg.artist_external_links === true;
- // Fallback instrument for hosts without the working-tuning capability.
- state.settingsInstrument = cfg.instrument === 'bass' ? 'bass' : 'guitar';
- // The three-valued perspective source (lead / rhythm / bass).
- state.settingsProfile = cfg.active_instrument_profile || '';
- });
- }
-
- // 2×2 mosaic of the artist's OWN album art — the playlist-cover grammar
- // (#626 playlistCoverHtml) adapted to the page payload's art_urls. Never a
- // broken-image tile: no art → a quiet glyph.
- function artistMosaicHtml(arts) {
- const box = 'w-32 h-32 sm:w-40 sm:h-40 shrink-0 rounded-xl overflow-hidden bg-fb-card';
- const img = (u) => ' ';
- if (!arts || !arts.length) return '🎤
';
- if (arts.length < 4) return '' + img(arts[0]) + '
';
- return '' + arts.slice(0, 4).map(img).join('') + '
';
- }
-
- function _linkDomain(u) {
- try { return new URL(u).hostname.replace(/^www\./, ''); } catch (_) { return ''; }
- }
-
- // Toggle the browse hosts (grid/tree/albums/folder + home + rail) so the
- // artist page can own the scroller, and back again on close.
- function _setBrowseHostsHidden(hidden) {
- if (hidden) {
- ['v3-songs-gridsizer', 'v3-songs-tree', 'v3-songs-albums', 'lib-folder-tree',
- 'v3-lib-home', 'v3-songs-azrail', 'v3-songs-azbubble']
- .forEach((id) => document.getElementById(id)?.classList.add('hidden'));
- const fc = document.getElementById('lib-folder-controls');
- if (fc) fc.style.display = 'none';
- } else {
- document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
- document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
- document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums');
- document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
- const fc = document.getElementById('lib-folder-controls');
- if (fc) fc.style.display = state.view === 'folder' ? 'flex' : 'none';
- refreshRail();
- updateLibraryHome();
- }
- }
-
- // The one exported opener — every entry point (card ⋮ / right-click "Go to
- // artist", the grid card's artist line, the Details drawer link, a
- // similar-artist chip) funnels through here.
- async function openArtistPage(artistName) {
- const host = _artistHostEl();
- if (!host || !artistName) return;
- if (state.provider !== 'local' || state.artistPagesEnabled === false) return;
- const main = _getV3MainScroller();
- // Remember where browsing left off ONCE — chip-hopping between artist
- // pages keeps the original return point.
- if (!state.artistPage) state.artistReturnScroll = main ? main.scrollTop : 0;
- state.artistPage = artistName;
- _setBrowseHostsHidden(true);
- host.classList.remove('hidden');
- host.innerHTML = 'Loading…
';
- _applyMainScrollTop(0);
- const page = await jget('/api/artist/' + enc(artistName) + '/page');
- if (state.artistPage !== artistName) return; // superseded
- if (!page) { closeArtistPage(); return; }
- await renderArtistPage(page);
- }
-
- function closeArtistPage() {
- const host = _artistHostEl();
- if (host) { host.classList.add('hidden'); host.innerHTML = ''; }
- if (!state.artistPage) return;
- state.artistPage = null;
- _setBrowseHostsHidden(false);
- const top = state.artistReturnScroll;
- state.artistReturnScroll = null;
- _applyMainScrollTop(top || 0);
- if (state.view === 'grid') requestWindowRender();
- }
-
- // reload() (any toolbar-driven change) leaves the sub-page without the
- // scroll restore — the new state describes a fresh browse from the top.
- function _dropArtistPageSilently() {
- if (!state.artistPage) return;
- state.artistPage = null;
- state.artistReturnScroll = null;
- const host = _artistHostEl();
- if (host) { host.classList.add('hidden'); host.innerHTML = ''; }
- }
-
- async function renderArtistPage(page) {
- const host = _artistHostEl();
- if (!host) return;
- const me = state.artistPage;
- const name = page.artist || me || '';
- // Songs list: page through /api/library with the artist filter (locked
- // position 6 — query_page, keyset-safe; never the DISTINCT+OFFSET
- // query_artists path). Unfiltered on purpose: the page is the artist's
- // whole shelf, not the grid's current filter view.
- const songs = [];
- let p = 0, total = Infinity;
- while (songs.length < total) {
- const q = new URLSearchParams({
- provider: 'local', artist: name, sort: 'artist',
- size: '100', page: String(p),
- });
- const data = await jget('/api/library?' + q.toString());
- if (!data || !Array.isArray(data.songs)) break;
- songs.push(...data.songs);
- total = (data.total != null) ? data.total : songs.length;
- if (!data.songs.length || p > 50) break; // safety: no progress / runaway
- p++;
- }
- if (state.artistPage !== me || !host.isConnected) return; // superseded mid-fetch
- songs.forEach((s) => { state.songsById[cardKey(s)] = s; });
-
- const aliasLine = (page.variants || []).length
- ? 'also shown as: ' +
- page.variants.map((v) => esc(v.name) + ' ×' + v.count).join(' · ') + '
'
- : '';
- // Provenance pill — only when the artist is actually matched (drawer/
- // Get-info grammar: say where the tidy names come from, ≤2 taps away).
- const pill = page.mb_artist_id
- ? 'Matched · MusicBrainz
'
- : '';
- // Stats strip. DENOMINATOR LAW: every number is songs in YOUR library;
- // the mastered segment is omitted entirely until one exists —
- // invitational, never "0 mastered" (launch blind-spot #3).
- const bits = [
- page.song_count + ' song' + (page.song_count === 1 ? '' : 's'),
- page.album_count + ' album' + (page.album_count === 1 ? '' : 's'),
- ];
- if (page.mastered_count > 0) bits.push(page.mastered_count + ' mastered');
-
- const albumsHtml = (page.albums || []).length
- ? 'Albums ' +
- '' +
- page.albums.map((al, i) =>
- '
' +
- '' +
- (al.cover ? '
' : '') +
- '
' +
- '' + esc(al.name) + '
' +
- '' + (al.year ? esc(al.year) + ' · ' : '') + (al.count || 0) + ' track' + (al.count === 1 ? '' : 's') + '
' +
- ' ').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) =>
- '' + esc(s.artist) + ' ').join('') +
- '
'
- : '';
-
- host.innerHTML =
- '← Song Library ' +
- '' +
- artistMosaicHtml(page.art_urls) +
- '
' +
- '
' + esc(name) + ' ' +
- aliasLine + pill +
- '
' + bits.join(' · ') + '
' +
- '
' +
- (songs.length
- ? '▶ Play all ' +
- '⇄ Shuffle '
- : '') +
- 'Save as smart playlist ' +
- '
' +
- '
' +
- 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
' +
- '
';
- }
-
- // Global opener — the drawer link, plugins, and other views reach the page
- // without touching this module's internals.
- window.__fbOpenArtistPage = openArtistPage;
-
- async function loadTree() {
- const host = document.getElementById('v3-songs-tree');
- if (!host) return;
- // The list view always groups artist -> album (query_artists has no
- // free sort) — when the picked sort is something else, say so instead
- // of silently ignoring it ("why does the sort do nothing here?").
- const _treeSortNote = railSortColumn() === 'artist' ? ''
- : 'List view groups by artist — the selected sort applies to the card grid.
';
- // Capture expanded groups BEFORE the "Loading…" wipe below, so a reload
- // (e.g. toggling select mode) restores them instead of collapsing all.
- const openArtists = new Set(
- [...host.querySelectorAll('details[open]')].map((d) => d.getAttribute('data-artist')));
- host.innerHTML = 'Loading…
';
- // Page through ALL artists — the endpoint clamps size to 100, so a
- // single request would silently truncate libraries with >100 artists.
- const artists = [];
- let page = 0, total = Infinity;
- while (artists.length < total) {
- const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }).toString());
- if (!data || !Array.isArray(data.artists)) break;
- artists.push(...data.artists);
- total = (data.total_artists != null) ? data.total_artists : artists.length;
- if (!data.artists.length || page > 1000) break; // safety: no progress / runaway guard
- page++;
- }
- if (!artists.length) { host.innerHTML = 'Nothing here.
'; return; }
- artists.forEach((a) => (a.albums || []).forEach((al) => (al.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; })));
- host.innerHTML = _treeSortNote + artists.map((a) =>
- '' +
- '' + esc(a.name) + ' ' + esc(a.song_count) + ' ' +
- '' + (a.albums || []).map((al) =>
- '
' + esc(al.name || 'Unknown') + '
' +
- (al.songs || []).map(treeSongRowHtml).join('') + '
').join('') + '
').join('');
- wireCards(host);
- }
-
- // ── Filter drawer ─────────────────────────────────────────────────────--
- function triState(list_has, list_lacks, value) {
- if (list_has.includes(value)) return 'has';
- if (list_lacks.includes(value)) return 'lacks';
- return 'any';
- }
- function cycleTri(hasArr, lacksArr, value) {
- const s = triState(hasArr, lacksArr, value);
- const rm = (a) => { const i = a.indexOf(value); if (i >= 0) a.splice(i, 1); };
- rm(hasArr); rm(lacksArr);
- if (s === 'any') hasArr.push(value);
- else if (s === 'has') lacksArr.push(value);
- // 'lacks' → cycles back to any (already removed)
- }
- function triPill(group, value, label, st, title) {
- const cls = st === 'has' ? 'bg-fb-good/30 text-fb-good border-fb-good/40'
- : st === 'lacks' ? 'bg-fb-low/30 text-fb-low border-fb-low/40'
- : 'bg-gray-800/50 text-fb-textDim border-gray-700';
- const mark = st === 'has' ? '✓ ' : st === 'lacks' ? '✕ ' : '';
- const tip = title ? ' title="' + esc(title) + '"' : '';
- return '' + mark + esc(label) + ' ';
- }
- function renderDrawer() {
- const d = document.getElementById('v3-songs-drawer');
- if (!d) return;
- const f = state.filters;
- d.innerHTML =
- '' +
- '
Filters ' +
- '✕ ' +
- section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) +
- section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
- section('Lyrics', ['', '1', '0'].map((v) => '
' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + ' ').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) => '
' + it[1] + ' ').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) => '
' + it[1] + ' ').join('')) +
- // Genre facet — dynamic list from /api/library/genres (primary genre).
- (state.genres && state.genres.length ? section('Genre', state.genres.map((g) => '
' + esc(g) + ' ').join('')) : '') +
- // The facet header NAMES the perspective. Silent instrument-following
- // is the original bug in a new place: the user must be able to tell
- // which instrument these tunings describe.
- section('Tuning (' + libInstrumentLabel() + ')',
- // MODE toggle. Exact match answers "which tuning is this
- // labelled"; Playable answers "will this cost me a retune" —
- // which is what a player actually wants. Both are offered;
- // exact stays the default so nothing changes unasked.
- '
'
- + [['exact', 'Exact tuning'], ['playable', 'Playable without retuning']].map((m) => {
- const on = (f.tuningMatch || 'exact') === m[0];
- const dis = m[0] === 'playable' && !playableAvailable();
- return ''
- + esc(m[1]) + ' ';
- }).join('')
- + '
'
- + (f.tuningMatch === 'playable'
- ? '
Charts you can play in your current tuning, no retune. Songs whose lowest string sits below yours are excluded.
'
- : '')
- + ((state.tuningNames || []).map((t) => {
- // Filter on the server's grouping key (raw offsets for customs)
- // so two "Custom Tuning" entries are distinct; show their target
- // notes in the label so they're distinguishable.
- const val = t.key || t.name;
- let label = t.name;
- if (t.name === 'Custom Tuning' && t.offsets
- && typeof window.parseRawTuningOffsets === 'function'
- && typeof window.displayTuningTargets === 'function') {
- const offs = window.parseRawTuningOffsets(t.offsets);
- const notes = offs ? window.displayTuningTargets(offs, { tuningName: t.name }) : '';
- if (notes) label = 'Custom · ' + notes;
- }
- // Be honest about the fallback: when some of a row's songs have
- // no bass chart and are borrowing the guitar tuning, say so
- // rather than presenting a borrowed tuning as a measured one.
- const inf = t.inferred_count || 0;
- const title = inf
- ? inf + ' of ' + t.count + ' inferred from the guitar chart (no bass arrangement)'
- : '';
- const countLabel = inf ? t.count + ', ' + inf + ' inferred' : String(t.count);
- return triPill('tuning', val, label + ' (' + countLabel + ')',
- f.tunings.includes(val) ? 'has' : 'any', title);
- }).join('') || '
No tunings ')) +
- // Multi-chart grouping toggle (P5e) — a VIEW mode, not a filter
- // (never counted in the badge, never saved into collection rules).
- // Local provider only: it's the one that implements group=.
- (state.provider === 'local'
- ? section('Grouping', '
' +
- (state.grouping !== false ? '✓ ' : '') + 'One card per song ')
- : '') +
- // 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
- ? '
+ Save as collection
'
- : '') +
- // 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'
- ? '
Tidy up artists…
'
- : '') +
- '
Clear all ' +
- 'Done
';
-
- d.querySelectorAll('[data-tri]').forEach((b) => b.addEventListener('click', () => {
- const g = b.getAttribute('data-tri'), v = b.getAttribute('data-val');
- if (g === 'arr') cycleTri(f.arr_has, f.arr_lacks, v);
- else if (g === 'stem') cycleTri(f.stem_has, f.stem_lacks, v);
- else if (g === 'tuning') { const i = f.tunings.indexOf(v); if (i >= 0) f.tunings.splice(i, 1); else f.tunings.push(v); }
- renderDrawer();
- }));
- d.querySelectorAll('[data-tuning-match]').forEach((b) => b.addEventListener('click', () => {
- if (b.disabled) return;
- f.tuningMatch = b.getAttribute('data-tuning-match');
- renderDrawer();
- }));
- d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); }));
- d.querySelectorAll('[data-mastery]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-mastery'); const i = f.mastery.indexOf(v); if (i >= 0) f.mastery.splice(i, 1); else f.mastery.push(v); renderDrawer(); }));
- d.querySelector('[data-grouping]')?.addEventListener('click', () => {
- state.grouping = !(state.grouping !== false);
- renderDrawer();
- reload(); // re-fetches grid + rail with/without group=1, saves prefs
- });
- d.querySelectorAll('[data-match]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-match'); const i = f.match.indexOf(v); if (i >= 0) f.match.splice(i, 1); else f.match.push(v); renderDrawer(); }));
- d.querySelectorAll('[data-genre]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-genre'); const i = f.genre.indexOf(v); if (i >= 0) f.genre.splice(i, 1); else f.genre.push(v); renderDrawer(); }));
- d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection);
- d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp);
- d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
- d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => {
- state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' };
- state.artist = '';
- state.album = '';
- renderDrawer();
- await loadArtistCatalog();
- refreshArtistAlbumSelects();
- reload();
- });
- d.querySelector('[data-drawer-apply]')?.addEventListener('click', async () => {
- closeDrawer();
- await loadArtistCatalog();
- refreshArtistAlbumSelects();
- reload();
- });
- }
- function section(label, inner) {
- return '' + label + '
' + inner + '
';
- }
- function openDrawer() { renderDrawer(); document.getElementById('v3-songs-drawer')?.classList.remove('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.remove('hidden'); }
- function closeDrawer() { document.getElementById('v3-songs-drawer')?.classList.add('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.add('hidden'); updateFilterBadge(); }
- function updateFilterBadge() { const b = document.getElementById('v3-songs-filter-count'); if (b) { const n = activeFilterCount(); b.textContent = n; b.classList.toggle('hidden', n === 0); } }
-
- // ── Song Details drawer (P2) ───────────────────────────────────────────--
- // Evolves the legacy edit-metadata modal into a v3 slide-in drawer that
- // unifies catalog identity (writes back into the feedpak FILE), the personal
- // practice layer (difficulty / tags / notes — local, never shared), the like
- // heart, and remove-from-library. Reuses the filter-drawer slide idiom
- // (fixed-right panel + overlay + translate-x-full) but is built on demand and
- // body-appended, so it opens from any card context. The Charts drawer (P5d)
- // reuses this pattern.
- const DIFF_LABELS = ['', 'Very easy', 'Easy', 'Medium', 'Hard', 'Very hard'];
- let _detailsEls = null; // {overlay, drawer, opener, onKey} while open
-
- function closeDetails() {
- if (!_detailsEls) return;
- const { overlay, drawer, opener, onKey } = _detailsEls;
- _detailsEls = null;
- document.removeEventListener('keydown', onKey);
- drawer.classList.add('translate-x-full');
- overlay.classList.add('opacity-0');
- setTimeout(() => { drawer.remove(); overlay.remove(); }, 200);
- if (opener && document.body.contains(opener)) { try { opener.focus({ preventScroll: true }); } catch (_) { /* */ } }
- }
-
- async function openDetails(song) {
- if (!song || !song.filename) return;
- closeDetails();
- const fn = song.filename;
- const opener = document.activeElement;
- // Notes aren't in the grid row payload; difficulty/tags are, but re-read
- // for authority (another tab / a bulk edit may have changed them).
- let meta = { user_difficulty: (song.user_difficulty != null ? song.user_difficulty : null), notes: '', tags: song.tags || [] };
- try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ }
- let vocab = [];
- try { const r = await fetch('/api/tags'); if (r.ok) vocab = (await r.json()).tags || []; } catch (_) { /* */ }
- // Match provenance (launch polish): the drawer names what this chart
- // matched, so a silently-wrong first match is visible where the
- // metadata lives. 404 (no row yet) / offline → no line.
- let enrich = null;
- try { const r = await fetch('/api/enrichment/song/' + enc(fn)); if (r.ok) enrich = await r.json(); } catch (_) { /* offline → no provenance line */ }
- if (_detailsEls) closeDetails(); // a concurrent open resolved first
-
- const st = {
- t: song.title || '', a: song.artist || '', al: song.album || '', y: song.year != null ? String(song.year) : '',
- diff: (meta.user_difficulty != null ? meta.user_difficulty : null),
- notes: meta.notes || '', tags: (meta.tags || []).slice(),
- fav: !!song.favorite, artDataUrl: null,
- gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys
- enrich: enrich, // match provenance for the Identity section
- };
-
- const overlay = document.createElement('div');
- overlay.className = 'fixed inset-0 bg-black/50 z-[60] opacity-0 transition-opacity duration-200';
- const drawer = document.createElement('aside');
- drawer.id = 'v3-song-details-drawer';
- drawer.className = 'fixed top-0 right-0 h-full w-full sm:w-[26rem] bg-fb-sidebar border-l border-fb-border/50 z-[61] transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll';
- drawer.setAttribute('role', 'dialog');
- drawer.setAttribute('aria-modal', 'true');
- drawer.setAttribute('aria-label', 'Song details');
- document.body.appendChild(overlay);
- document.body.appendChild(drawer);
-
- const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeDetails(); } };
- document.addEventListener('keydown', onKey);
- overlay.addEventListener('click', closeDetails);
- _detailsEls = { overlay, drawer, opener, onKey };
-
- const render = () => { drawer.innerHTML = detailsHtml(song, st, vocab); wireDetails(drawer, song, st, vocab, render); };
- render();
- requestAnimationFrame(() => { drawer.classList.remove('translate-x-full'); overlay.classList.remove('opacity-0'); });
- if (window._trapFocusInModal) { try { window._trapFocusInModal(drawer); } catch (_) { /* */ } }
- const first = drawer.querySelector('#det-title');
- if (first) { try { first.focus({ preventScroll: true }); const n = first.value.length; first.setSelectionRange(n, n); } catch (_) { /* */ } }
- }
-
- // Gap-fill (R4a) block inside the drawer's Identity section: preview →
- // per-key confirm → written. Adds ABSENT keys only; the server re-checks
- // under its io lock, so this UI can never replace an author-set value.
- const GAP_KEY_LABELS = { album: 'Album', year: 'Year', genres: 'Genres', mbid: 'MusicBrainz ID', isrc: 'ISRC' };
- function gapFillHtml(st) {
- const g = st.gap;
- if (!g) return 'Write missing info to file… ';
- 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 '' +
- ' ' +
- '' + esc(GAP_KEY_LABELS[m.key] || m.key) + ' ' +
- '' + esc(val) + ' ';
- }).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.
' +
- '
Write to file ' +
- 'Cancel
';
- }
-
- // 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) + ') ' +
- 'Fix match
';
- }
-
- function detailsHtml(song, st, vocab) {
- const art = st.artDataUrl || artUrl(song);
- const diffBtns = [1, 2, 3, 4, 5].map((n) =>
- '' + 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) =>
- '' + esc(v.tag) + ' ').join('');
- const field = (id, label, val) =>
- '' + label + ' ' +
- '
';
- return '' +
- '
Song details ' +
- '✕ ' +
-
- '
' +
- '
' +
- '
' +
- '
Change
' +
- '
' +
- '
' + esc(song.title || song.filename) + '
' +
- '
' + (st.fav ? '♥ Liked' : '♡ Like') + ' ' +
- 'a like, not a rating
' +
-
- // Identity — writes back into the feedpak FILE
- '
' +
- 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)
- ? '
View artist page → '
- : '') +
- field('det-album', 'Album', st.al) +
- '
Year
' +
- provenanceHtml(st) +
- '
' + gapFillHtml(st) + '
' +
-
- // Personal practice layer — local, never shared
- '
Your practice · stays on this device
' +
- '
Difficulty (for you) ' +
- 'Clear
' +
- '
' + diffBtns + '' + esc(st.diff ? DIFF_LABELS[st.diff] : 'Not set') + '
' +
- '
Tags ' +
- '
' + tagChips + '
' +
- '
' +
- 'Add
' +
- (suggest ? '
' + suggest + '
' : '') + '
' +
- '
Notes ' +
- '
' +
-
- '
Save ' +
- 'Cancel
' +
- '
Remove from library
' +
- '
';
- }
-
- function wireDetails(drawer, song, st, vocab, render) {
- const $ = (s) => drawer.querySelector(s);
- drawer.querySelectorAll('[data-det-close]').forEach((b) => b.addEventListener('click', closeDetails));
- // Catalog inputs update state without a re-render (keep caret / focus).
- const bindInput = (id, key) => { const el = $('#' + id); if (el) el.addEventListener('input', () => { st[key] = el.value; }); };
- bindInput('det-title', 't'); bindInput('det-artist', 'a'); bindInput('det-album', 'al'); bindInput('det-year', 'y');
- const notes = $('#det-notes'); if (notes) notes.addEventListener('input', () => { st.notes = notes.value; });
-
- drawer.querySelectorAll('[data-diff]').forEach((b) => b.addEventListener('click', () => {
- const n = Number(b.getAttribute('data-diff'));
- st.diff = (st.diff === n ? null : n); // click the active one to unset
- render();
- }));
- $('[data-diff-clear]')?.addEventListener('click', () => { st.diff = null; render(); });
-
- drawer.querySelectorAll('[data-tag-rm]').forEach((b) => b.addEventListener('click', () => {
- const t = b.getAttribute('data-tag-rm'); const i = st.tags.indexOf(t); if (i >= 0) st.tags.splice(i, 1); render();
- }));
- drawer.querySelectorAll('[data-tag-add]').forEach((b) => b.addEventListener('click', () => { if (addDetailTag(st, b.getAttribute('data-tag-add'))) render(); }));
- const addFromInput = () => { const el = $('#det-tag-input'); if (!el) return; if (addDetailTag(st, el.value)) { render(); drawer.querySelector('#det-tag-input')?.focus(); } };
- $('[data-tag-addbtn]')?.addEventListener('click', addFromInput);
- $('#det-tag-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addFromInput(); } });
-
- const artWrap = $('[data-det-art]'); const artFile = $('#det-art-file');
- if (artWrap && artFile) {
- // Art click opens the cover PICKER (PR-C) — the old direct file
- // dialog lives on inside it as the Upload tile. The picker applies
- // immediately (its own routes + refresh), so it bypasses the
- // drawer's Save; the file-input path below stays as the fallback
- // when image-picker.js isn't loaded.
- artWrap.addEventListener('click', () => {
- if (window.__fbOpenImagePicker) {
- window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
- } else {
- artFile.click();
- }
- });
- artFile.addEventListener('change', () => {
- const f = artFile.files && artFile.files[0]; if (!f) return;
- const rd = new FileReader();
- rd.onload = (e) => { st.artDataUrl = e.target.result; const img = $('#det-art-preview'); if (img) { img.src = st.artDataUrl; img.style.visibility = 'visible'; } };
- rd.readAsDataURL(f);
- });
- }
-
- $('[data-det-fav]')?.addEventListener('click', async (e) => {
- try {
- const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: song.filename }) });
- const d = await r.json(); st.fav = !!d.favorite;
- const btn = e.currentTarget; btn.textContent = st.fav ? '♥ Liked' : '♡ Like';
- btn.classList.toggle('text-fb-accent', st.fav); btn.classList.toggle('text-fb-textDim', !st.fav);
- _patchCardFav(song.filename, st.fav);
- } catch (_) { /* */ }
- });
-
- $('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st));
- $('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song));
- // Fix match → the exact flow the card ⋮ menu uses (match-review.js).
- // The drawer closes first: the match modal sits below the drawer's
- // z-index, and the fix supersedes the edit anyway.
- $('[data-det-fixmatch]')?.addEventListener('click', () => {
- closeDetails();
- if (window.__fbFixMatch) window.__fbFixMatch(song);
- });
- // "View artist page →" — uses the field's CURRENT text (an in-progress
- // rename still lands on the right page once saved; unsaved text simply
- // canonicalizes server-side), falling back to the row's artist.
- $('[data-det-artist-page]')?.addEventListener('click', () => {
- const a = (st.a || '').trim() || song.artist || '';
- if (!a) return;
- closeDetails();
- openArtistPage(a);
- });
-
- // Gap-fill (R4a): user-initiated write of CONFIRMED missing info into
- // the pack file. The server recomputes proposals under its io lock, so
- // a key that gained an author value since the preview is skipped.
- $('[data-gapfill-check]')?.addEventListener('click', async () => {
- st.gap = { loading: true }; render();
- let d = null;
- try { const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill'); if (r.ok) d = await r.json(); } catch (_) { /* offline */ }
- st.gap = d || { eligible: false, reason: 'error' };
- st.gapSel = new Set(((d && d.missing) || []).map((m) => m.key));
- render();
- });
- drawer.querySelectorAll('[data-gapfill-key]').forEach((cb) => cb.addEventListener('change', () => {
- const k = cb.getAttribute('data-gapfill-key');
- if (!st.gapSel) st.gapSel = new Set();
- if (cb.checked) st.gapSel.add(k); else st.gapSel.delete(k);
- }));
- $('[data-gapfill-cancel]')?.addEventListener('click', () => { st.gap = null; render(); });
- $('[data-gapfill-write]')?.addEventListener('click', async () => {
- const keys = st.gapSel ? Array.from(st.gapSel) : [];
- if (!keys.length) return;
- let d = null, ok = false;
- try {
- const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys }) });
- ok = r.ok; d = await r.json();
- } catch (_) { /* offline */ }
- if (!ok || !d || !d.written) {
- if (window.fbNotify) { try { window.fbNotify.show({ title: 'Write failed', message: 'Could not write to the file. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } }
- st.gap = null; render(); return;
- }
- // Reflect what landed in the open drawer (and keep saveDetails'
- // changed-detection honest by updating both sides), then refresh
- // the grid quietly.
- if (d.written.album != null) { st.al = String(d.written.album); song.album = st.al; }
- if (d.written.year != null) { st.y = String(d.written.year); song.year = d.written.year; }
- st.gap = { written: d.written }; render();
- try { reload(); } catch (_) { /* not on the songs grid */ }
- });
- }
-
- // Normalize + append a tag to the drawer's working set (mirrors the server's
- // _normalize_tag). Returns whether it was actually added (new + non-blank).
- function addDetailTag(st, raw) {
- const t = String(raw || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 60);
- if (!t || st.tags.includes(t)) return false;
- st.tags.push(t); st.tags.sort((a, b) => a.localeCompare(b)); return true;
- }
-
- // Keep a rendered card's heart in sync when the drawer toggles the like (the
- // heart is instant, not part of Save — matches the on-card heart).
- function _patchCardFav(fn, fav) {
- const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
- document.querySelectorAll('[data-fn="' + sel + '"] [data-fav]').forEach((btn) => {
- btn.textContent = fav ? '♥' : '♡';
- btn.setAttribute('aria-pressed', fav ? 'true' : 'false');
- // Swap exactly the idle colour this heart was rendered with (grid =
- // text-white, tree/List view = text-fb-textDim) — mirrors wireCards.
- // A hardcoded text-white toggle would leave List-View rows' text-fb-textDim
- // in place, so the heart changed glyph but stayed dim (never turned red).
- const idle = btn.getAttribute('data-fav-idle') || 'text-white';
- btn.classList.toggle('text-fb-accent', fav); btn.classList.toggle(idle, !fav);
- });
- }
-
- async function saveDetails(song, st) {
- const fn = song.filename;
- const catChanged = st.t.trim() !== (song.title || '') || st.a.trim() !== (song.artist || '')
- || st.al.trim() !== (song.album || '') || String(st.y).trim() !== (song.year != null ? String(song.year) : '');
- const ops = [];
- if (catChanged) ops.push(fetch('/api/song/' + enc(fn) + '/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: st.t.trim(), artist: st.a.trim(), album: st.al.trim(), year: String(st.y).trim() }) }));
- ops.push(fetch('/api/song/' + enc(fn) + '/user-meta', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_difficulty: st.diff, notes: st.notes, tags: st.tags }) }));
- if (st.artDataUrl) ops.push(fetch('/api/song/' + enc(fn) + '/art/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: st.artDataUrl }) }));
- // Fire the writes concurrently but know each outcome — a failed write must not
- // look like a save. allSettled keeps partial results; treat a rejection OR a
- // non-ok HTTP response as failure and keep the drawer open so the user can retry.
- const results = await Promise.allSettled(ops);
- const failed = results.some((r) => r.status === 'rejected' || (r.value && r.value.ok === false));
- if (failed) {
- if (window.fbNotify) { try { window.fbNotify.show({ title: 'Save failed', message: 'Could not save your changes. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } }
- return;
- }
- closeDetails();
- try { reload(); } catch (_) { /* not on the songs grid */ }
- }
-
- async function removeFromLibrary(song) {
- const title = song.title || song.filename;
- let ok;
- if (window._confirmDialog) {
- ok = await window._confirmDialog({
- title: 'Remove from library?',
- body: 'Remove ' + esc(title) + ' from your library?
' +
- 'This permanently deletes the file from disk. This cannot be undone.
',
- confirmText: 'Remove', cancelText: 'Cancel', danger: true,
- });
- } else { ok = window.confirm('Remove "' + title + '" from your library? This deletes the file from disk.'); }
- if (!ok) return;
- try { const r = await fetch('/api/song/' + enc(song.filename), { method: 'DELETE' }); if (!r.ok) return; } catch (_) { return; }
- closeDetails();
- try { reload(); } catch (_) { /* */ }
- }
-
- // Expose the opener so the core `edit-metadata` card action opens this drawer
- // (it falls back to the legacy modal where this isn't defined).
- window.__fbOpenSongDetails = openDetails;
-
- // ── Artist Tidy-up (P4) ────────────────────────────────────────────────--
- // Merge messy artist variants ("ACDC" + "AC/DC") into one canonical name.
- // Aliases apply AT DISPLAY only (no file rewrite); the dropdown/tree/grid pick
- // up the change on the next load. Self-contained centered modal.
- function openArtistTidyUp() {
- const st = { raw: [], aliases: [], sel: new Set(), query: '', mergeInto: '', mergeTouched: false, busy: false };
-
- const overlay = document.createElement('div');
- overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
- const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } };
- function done() { overlay.remove(); document.removeEventListener('keydown', onKey); }
- document.addEventListener('keydown', onKey);
- overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); });
- document.body.appendChild(overlay);
-
- async function refresh() {
- const [raw, aliases] = await Promise.all([jget('/api/artists/raw'), jget('/api/artist-aliases')]);
- st.raw = (raw && raw.artists) || [];
- st.aliases = (aliases && aliases.aliases) || [];
- render();
- }
-
- // Default canonical = the highest-count selected variant (the one most of
- // your library already uses), unless the user typed their own.
- function defaultCanonical() {
- let best = '', bestC = -1;
- st.raw.forEach((a) => { if (st.sel.has(a.name) && a.count > bestC) { bestC = a.count; best = a.name; } });
- return best;
- }
-
- function render() {
- if (!st.mergeTouched) st.mergeInto = defaultCanonical();
- const q = st.query.trim().toLowerCase();
- const list = st.raw.filter((a) => !q || (a.name || '').toLowerCase().includes(q));
- const rows = list.map((a) => {
- const checked = st.sel.has(a.name) ? ' checked' : '';
- const mapped = (a.canonical && a.canonical.toLowerCase() !== (a.name || '').toLowerCase())
- ? '→ ' + esc(a.canonical) + ' ' : '';
- return '' +
- ' ' +
- '' + esc(a.name) + mapped + ' ' +
- '' + a.count + ' ';
- }).join('') || 'No artists
';
-
- const canReady = st.mergeInto.trim() && st.sel.size && !st.busy;
- const mergeBar = st.sel.size
- ? '' +
- '
' + st.sel.size + ' selected — merge into:
' +
- '
' +
- 'Merge
'
- : '';
-
- const aliasRows = st.aliases.length
- ? st.aliases.map((al) => '' + esc(al.raw_name) + ' → ' + esc(al.canonical_name) + ' ' +
- 'un-merge
').join('')
- : 'No merges yet
';
-
- overlay.innerHTML =
- '';
-
- overlay.querySelectorAll('[data-tidy-x]').forEach((b) => b.addEventListener('click', done));
- const search = overlay.querySelector('[data-tidy-search]');
- if (search) search.addEventListener('input', () => { st.query = search.value; render(); const s = overlay.querySelector('[data-tidy-search]'); if (s) { s.focus(); const n = s.value.length; try { s.setSelectionRange(n, n); } catch (_) { /* */ } } });
- overlay.querySelectorAll('[data-tidy-sel]').forEach((cb) => cb.addEventListener('change', () => {
- const n = cb.getAttribute('data-tidy-sel');
- if (cb.checked) st.sel.add(n); else st.sel.delete(n);
- render();
- }));
- const canon = overlay.querySelector('[data-tidy-canon]');
- if (canon) canon.addEventListener('input', () => {
- st.mergeInto = canon.value; st.mergeTouched = true;
- const btn = overlay.querySelector('[data-tidy-merge]');
- if (btn) btn.disabled = !(st.mergeInto.trim() && st.sel.size && !st.busy);
- });
- overlay.querySelector('[data-tidy-merge]')?.addEventListener('click', doMerge);
- overlay.querySelectorAll('[data-tidy-unmerge]').forEach((b) => b.addEventListener('click', () => doUnmerge(b.getAttribute('data-tidy-unmerge'))));
- }
-
- async function doMerge() {
- const canonical = st.mergeInto.trim();
- if (!canonical || !st.sel.size || st.busy) return;
- st.busy = true;
- await jsend('POST', '/api/artist-aliases/merge', { raw_names: [...st.sel], canonical_name: canonical });
- st.sel.clear(); st.mergeTouched = false; st.busy = false;
- await afterChange();
- }
-
- async function doUnmerge(raw) {
- if (!raw) return;
- try { await fetch('/api/artist-aliases/' + enc(raw), { method: 'DELETE' }); } catch (_) { /* */ }
- await afterChange();
- }
-
- async function afterChange() {
- await refresh();
- // Reflect canonical names in the toolbar dropdown + grid immediately.
- try { await loadArtistCatalog(); refreshArtistAlbumSelects(); reload(); } catch (_) { /* not on the songs grid */ }
- }
-
- refresh();
- }
-
- // The host loads the Folder Library plugin's screen.js at startup (defining
- // window.folderLibrary). If it isn't present yet, inject it once; the
- // plugin's IIFEs are idempotent so a redundant evaluation is a no-op. The
- // promise is memoised so concurrent folder-view switches don't double-inject.
- let _flLoadPromise = null;
- function _ensureFolderLibrary() {
- if (window.folderLibrary) return Promise.resolve();
- if (_flLoadPromise) return _flLoadPromise;
- _flLoadPromise = new Promise((resolve, reject) => {
- const s = document.createElement('script');
- s.src = '/api/plugins/folder_library/screen.js';
- s.onload = () => resolve();
- s.onerror = () => { _flLoadPromise = null; reject(new Error('Failed to load Folder Library')); };
- document.head.appendChild(s);
- });
- return _flLoadPromise;
- }
-
- function reload() {
- _clearLibraryScrollSnapshot();
- // Any toolbar-driven change backs out of the artist sub-page — the new
- // state describes a fresh browse, and the host toggles below re-show
- // the picked view (mirrors how openAlbum's detail yields to a reload).
- _dropArtistPageSilently();
- // Record the state this fetch reflects so a later sidebar return can
- // tell whether the grid is stale (e.g. an off-screen search changed
- // state.q) and needs a refresh rather than a scroll-preserving no-op.
- state.renderedHash = _libraryStateHash();
- saveLibraryPrefs();
- updateFilterBadge();
- // A sort/filter/search/view change rebuilds the grid from page 0, so any
- // in-flight letter jump is now paging through a dataset that's about to
- // be discarded — supersede it so it can't scroll the rebuilt grid.
- _jumpToken++;
- // Keep a handle on the load so callers (notably the scroll restore on
- // screen re-entry) can await page-0 actually landing before paging
- // deeper. The visibility/scroll resets below stay synchronous.
- // Hide the SIZER (not the inner grid) for non-grid views, so its reserved
- // scroll height collapses and the tree/folder content sits at the top.
- document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
- document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
- document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
- document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums');
- // Refresh the A–Z jump rail (shows only for the grid + alphabetical
- // sorts; hides itself otherwise). Independent of the grid load.
- refreshRail();
- // Refresh the practice-aware home (repertoire meter + keep-practicing
- // shelf); hides itself when searching/filtering/selecting or off-grid.
- updateLibraryHome();
- { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
- if (state.view === 'folder') {
- _applyMainScrollTop(0);
- return _ensureFolderLibrary().then(() => window.folderLibrary?.load());
- }
- const loaded = state.view === 'grid' ? loadGrid(true) : state.view === 'albums' ? loadAlbums() : loadTree();
- _applyMainScrollTop(0);
- return loaded;
- }
-
- // ── Chrome ────────────────────────────────────────────────────────────--
- async function loadProviders() {
- try {
- const lp = sm && sm.libraryProviders;
- // refresh() re-fetches /api/library/providers so REMOTE providers
- // (registered by feedBack-plugin-remote-library-*) appear — list()
- // returns only the capability's initial local-only snapshot.
- const fn = lp && (typeof lp.refresh === 'function' ? lp.refresh : (typeof lp.list === 'function' ? lp.list : null));
- if (fn) {
- const snap = await fn.call(lp);
- if (snap && Array.isArray(snap.providers)) {
- state.provider = snap.current || (snap.providers[0] && snap.providers[0].id) || 'local';
- return snap.providers;
- }
- }
- } catch (e) { /* */ }
- const data = await jget('/api/library/providers');
- return (data && data.providers) || [{ id: 'local', label: 'My Library' }];
- }
-
- async function render() {
- const root = document.getElementById('v3-songs');
- if (!root) return;
- // A full re-render (filter/scan refresh) replaces the DOM the card menu
- // anchored to; dismiss any open menu first so it can't float orphaned.
- if (_closeCardMenu) _closeCardMenu();
- // Restore last-used sort/format/view/filters once, before building the
- // toolbar so its selects reflect the saved choice (default: Artist A–Z).
- if (!_prefsRestored) { applySavedPrefs(); _prefsRestored = true; }
- const providers = await loadProviders();
- const [, tn] = await Promise.all([
- (async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(),
- jget('/api/library/tuning-names?provider=' + enc(state.provider)
- + '&instrument=' + enc(libInstrument())),
- loadArtistCatalog(),
- // Artist-page gates (PR-B) ride the initial fetch batch so the
- // first card paint already knows whether artist lines are links.
- refreshArtistPageGates(),
- ]);
- state.tuningNames = (tn && tn.tunings) || [];
- _lastRenderInstrument = libInstrument();
- try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; }
-
- const opt = (arr, sel) => arr.map(([v, l]) => '' + esc(l) + ' ').join('');
- const provOpts = providers.map((p) => '' + esc(p.label || p.id) + ' ').join('');
- const ctrl = btnCtrl;
-
- root.innerHTML =
- '' +
- '' +
- // Practice-aware library home: a repertoire progress meter + a
- // "Keep practicing" shelf of started-but-not-mastered songs. Shown
- // only on the grid view when not searching/filtering/selecting
- // (renderLibraryHome + updateLibraryHome). Empty/absent → collapses.
- '
' +
- // Windowed grid: the sizer reserves the full-library scroll height;
- // #v3-songs-grid is absolutely positioned inside it and holds only the
- // visible window's cards (.v3-grid-window in v3.css).
- '
' +
- '
' +
- '
' +
- // Artist page host (PR-B) — an openAlbum-style in-place sub-render;
- // populated + shown by openArtistPage, cleared on close/reload.
- '
' +
- '
' +
- '
' +
- '
' +
- // A–Z jump rail (grid + alphabetical sorts only; populated by
- // refreshRail). The bubble shows the current letter while dragging.
- '
' +
- '
' +
- // Filter drawer + overlay
- '
' +
- '' +
- '
';
-
- // Wire toolbar.
- const byId = (id) => document.getElementById(id);
- byId('v3-songs-provider')?.addEventListener('change', async (e) => {
- state.provider = e.target.value;
- state.artist = '';
- state.album = '';
- try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ }
- _updateMetaBtnVisibility(); // enrichment is local-only
- await loadArtistCatalog();
- refreshArtistAlbumSelects();
- reload();
- });
- byId('v3-songs-artist')?.addEventListener('change', (e) => setArtist(e.target.value));
- byId('v3-songs-album')?.addEventListener('change', (e) => setAlbum(e.target.value));
- byId('v3-songs-sort').addEventListener('change', (e) => { state.sort = e.target.value; reload(); });
- byId('v3-songs-format').addEventListener('change', async (e) => {
- state.format = e.target.value;
- await loadArtistCatalog();
- refreshArtistAlbumSelects();
- reload();
- });
- byId('v3-songs-filters').addEventListener('click', openDrawer);
- byId('v3-songs-overlay').addEventListener('click', closeDrawer);
- // Match-review chip: feature-detected (match-review.js owns the
- // drawer + the count; absent → the chip just stays hidden).
- byId('v3-songs-match-review')?.addEventListener('click', () => { if (window.__fbOpenMatchReview) window.__fbOpenMatchReview(); });
- if (window.__fbMatchReviewChip) window.__fbMatchReviewChip();
- byId('v3-songs-upload').addEventListener('click', () => {
- const legacy = document.getElementById('upload-songs-file');
- // Upload targets the LOCAL library + scan; watchUploadScan refreshes
- // the grid for the local provider. Uploading while browsing a remote
- // provider won't surface the new local songs — switching the grid to
- // local on upload is a P23 remote-provider follow-up.
- if (legacy) { legacy.click(); watchUploadScan(); }
- });
- byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));
- byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary);
- // Refresh Metadata: local-only, so hide it for remote providers. The
- // button doubles as its own Stop while a pass runs (see onMetaBtnClick).
- byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick);
- byId('v3-songs-unmatched')?.addEventListener('click', toggleUnmatchedFilter);
- _updateMetaBtnVisibility();
- // Reflect a scan already in progress (Settings button or a background
- // pass) on the Refresh button, so its state isn't just tied to clicks here.
- (async () => {
- try {
- const r = await fetch('/api/scan-status');
- const sd = r.ok ? await r.json() : null;
- if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); }
- } catch (e) { /* */ }
- })();
- // Reflect an enrichment pass already running (Settings "Match now" or a
- // post-scan background pass) on the Metadata button + bar.
- (async () => {
- try {
- const r = await fetch('/api/enrichment/status');
- const es = r.ok ? await r.json() : null;
- if (es && es.running) { _setMetaState(es); _watchEnrich({ announce: false }); }
- } catch (e) { /* */ }
- })();
-
- // Capture-phase select-mode guard on each persistent list host. Without
- // it, clicking a card/row (or its arrangement chip) in select mode falls
- // through to the per-card play handler and starts playback instead of
- // selecting ("checkbox click opens the song / access-denied"). The artist
- // page renders the same [data-fn] song rows into its own host, so it
- // needs the guard too — otherwise a row click there plays instead of
- // toggling when select mode is already on.
- bindSelectGuard(byId('v3-songs-grid'));
- bindSelectGuard(byId('v3-songs-tree'));
- bindSelectGuard(byId('v3-songs-artistpage'));
- const setView = async (v) => {
- state.view = v;
- byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
- byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
- byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
- { const ab = byId('v3-songs-albums-btn'); if (ab) ab.className = 'px-3 py-2 text-sm ' + (v === 'albums' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); }
- if (v === 'folder') await _ensureFolderLibrary();
- return reload();
- };
- byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid'));
- byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree'));
- byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder'));
- byId('v3-songs-albums-btn')?.addEventListener('click', () => setView('albums'));
- // Await the initial load so a caller awaiting render() (the scroll
- // restore on screen re-entry) sees a populated grid + real state.total
- // before it tries to page deeper.
- await setView(state.view);
- bindScroll();
- bindGridResize();
- positionToolbar();
- bindToolbarReflow();
- updateFilterBadge();
- state.built = true;
- }
-
- async function onV3SongsScreenEnter() {
- // A library scan / DLC-folder change marked the grid stale — re-fetch
- // from scratch instead of restoring a cached (possibly empty, pre-DLC)
- // snapshot. Must win over every fast-path below.
- if (_libraryDirty) { _libraryDirty = false; await reload(); return; }
- // Keep the entry-point gates current (a Settings visit may have
- // toggled artist pages / external links). Fire-and-forget.
- refreshArtistPageGates();
- // An open artist sub-page survives a screen bounce as-is — its DOM is
- // self-contained. A torn-down/hidden host means the state is stale;
- // clear it and fall through to the normal restore paths.
- if (state.artistPage) {
- const ah = document.getElementById('v3-songs-artistpage');
- if (ah && !ah.classList.contains('hidden') && ah.childElementCount) return;
- state.artistPage = null;
- state.artistReturnScroll = null;
- }
- // Pull in any scores recorded while the library was off-screen (the usual
- // play→return flow) before the fast-paths below restore the cached DOM,
- // so the just-played song's badge is current. The full render() path
- // re-fetches accuracy itself, so this is a no-op cost there.
- await applyScoreRefresh();
- const snap = _readLibraryScrollSnapshot();
- const hashMatch = !!(snap && snap.hash === _libraryStateHash());
- const domReady = state.built && !!document.getElementById('v3-songs-grid');
- const chromeOk = _chromeIntact();
- const viewOk = state.view === (snap && snap.view ? snap.view : state.view);
-
- if (snap && hashMatch && domReady && chromeOk && viewOk) {
- if (state.view === 'grid' && _gridDomIntact()) {
- // Geometry is stable (the sizer still holds the full height from
- // the prior session), so restore is just: restore scrollTop, then
- // repaint the window that maps to it. No more page-through.
- document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', false);
- document.getElementById('v3-songs-tree')?.classList.toggle('hidden', true);
- syncChromeFromState();
- updateLibraryHome(); // select-mode clear on leave re-shows the home block
- _applyMainScrollTop(snap.scrollTop || 0);
- requestWindowRender();
- _clearLibraryScrollSnapshot();
- return;
- }
- if (state.view === 'tree' && _treeDomIntact()) {
- document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', true);
- document.getElementById('v3-songs-tree')?.classList.toggle('hidden', false);
- syncChromeFromState();
- _applyMainScrollTop(snap.scrollTop || 0);
- _clearLibraryScrollSnapshot();
- return;
- }
- }
-
- // Sidebar return without a player snapshot — keep grid, refresh chrome.
- if (!snap && domReady && chromeOk && state.built) {
- syncChromeFromState();
- // If state drifted while we were away (notably an off-screen topbar
- // search updating state.q), the persisted grid is stale — refetch
- // instead of silently showing the old results. Unchanged state keeps
- // the scroll-preserving no-op.
- if (state.renderedHash !== _libraryStateHash()) { reload(); return; }
- document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
- document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
- document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
- { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
- updateLibraryHome(); // select-mode clear on leave re-shows the home block
- // Re-render in case the viewport resized while we were away (column
- // count / row height may have changed) or select mode was cleared.
- if (state.view === 'grid') requestWindowRender();
- return;
- }
-
- const snapToRestore = hashMatch ? snap : null;
- if (snap && !hashMatch) _clearLibraryScrollSnapshot();
- await render();
- if (snapToRestore && snapToRestore.hash === _libraryStateHash()) {
- // render() built + sized the sizer at scrollTop 0; move to the saved
- // position and let the scroll handler repaint that window.
- _applyMainScrollTop(snapToRestore.scrollTop || 0);
- if (state.view === 'grid') requestWindowRender();
- }
- _clearLibraryScrollSnapshot();
- }
-
- // After an upload click-through (which reuses the legacy uploader +
- // background scan), poll /api/scan-status and reload the v3 grid once the
- // scan we triggered finishes — the legacy uploader only refreshes the
- // legacy screens, so without this newly-uploaded songs wouldn't appear in
- // v3 until a manual refresh. Bounded so a no-op upload can't poll forever.
- let _uploadScanTimer = null;
- // ── Library refresh (rescan) from the Songs toolbar ───────────────────────
- // The tester ask: a media-server-style "I dropped files in my folder, hit
- // refresh" button, with live progress. Reuses the SAME machinery the Settings
- // Rescan buttons drive (/api/rescan + /api/scan-status) and emits
- // library:changed so the grid reloads. A scan already running (Settings or a
- // background pass) is reflected on the button too.
- let _refreshPoll = null;
- function _setRefreshState(sd) {
- const btn = document.getElementById('v3-songs-refresh');
- if (!btn) return;
- if (sd && sd.running) {
- // Determinate count only exists in the 'scanning' stage; 'listing' is
- // indeterminate (total 0), so show a plain "Scanning…" then.
- const det = (sd.stage === 'scanning' && sd.total) ? ' ' + sd.done + '/' + sd.total : '';
- btn.textContent = '⟳ Scanning' + det + '…';
- btn.disabled = true;
- btn.classList.add('opacity-70');
- const pct = sd.total ? Math.round((sd.done / sd.total) * 100) + '% · ' : '';
- btn.title = 'Scanning new/changed songs… ' + pct + (sd.current || '');
- } else {
- btn.textContent = '⟳ Refresh';
- btn.disabled = false;
- btn.classList.remove('opacity-70');
- btn.title = 'Refresh library (scan for new songs)';
- }
- }
- // Show a completion toast (reuses the shared fbNotify surface), suppressed
- // while in a song. Honest + never-punishing copy: shows the "N added / M
- // removed" delta from the scan when there is one, else "up to date".
- function _scanCompleteToast(sd) {
- if (document.querySelector('.screen.active') && document.querySelector('.screen.active').id === 'player') return;
- if (!window.fbNotify) return;
- const added = (sd && sd.added) || 0, removed = (sd && sd.removed) || 0;
- let msg;
- if (sd && sd.error) msg = 'Scan finished with an error';
- else if (added || removed) {
- const parts = [];
- if (added) parts.push(added + ' song' + (added === 1 ? '' : 's') + ' added');
- if (removed) parts.push(removed + ' removed');
- msg = parts.join(' · ');
- } else msg = 'Your library is up to date';
- try { window.fbNotify.show({ title: 'Library scan complete', message: msg, icon: '🔄', accent: '#22C55E' }); } catch (e) { /* */ }
- }
- // Poll scan-status until the scan finishes, driving the button state. On
- // completion, emit library:changed (grid reloads via the listener below) and,
- // for a user-initiated refresh (announce), show the toast. announce:false is
- // used when we only attached to a scan we didn't start.
- function _watchScan(opts) {
- if (_refreshPoll) return;
- const announce = !opts || opts.announce !== false;
- let sawRunning = false, ticks = 0;
- _refreshPoll = setInterval(async () => {
- ticks++;
- let sd = null;
- try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ }
- _setRefreshState(sd);
- if (sd && sd.running) sawRunning = true;
- // A user-initiated refresh that never saw a running scan = nothing to
- // do (already up to date); give prompt feedback instead of waiting.
- const noopDone = announce && !sawRunning && ticks >= 3;
- if ((sawRunning && sd && !sd.running) || noopDone || ticks >= 180) {
- clearInterval(_refreshPoll); _refreshPoll = null;
- _setRefreshState(null);
- const hasDelta = sd && (((sd.added || 0) > 0) || ((sd.removed || 0) > 0));
- if (sawRunning && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'rescan', added: (sd && sd.added) || 0, removed: (sd && sd.removed) || 0 }); } catch (e) { /* */ } }
- // A user-initiated refresh always confirms; a scan we only attached
- // to (background / Settings) toasts just when it changed something,
- // so a periodic no-op pass stays silent.
- if (announce || hasDelta) _scanCompleteToast(sd);
- }
- }, 1000);
- }
- async function refreshLibrary() {
- if (_refreshPoll) return; // a scan is already in progress
- try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* */ }
- _watchScan({ announce: true });
- }
-
- function watchUploadScan() {
- if (_uploadScanTimer) clearInterval(_uploadScanTimer);
- let sawRunning = false, ticks = 0;
- _uploadScanTimer = setInterval(async () => {
- ticks++;
- let sd = null;
- try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ }
- if (sd && sd.running) sawRunning = true;
- if ((sawRunning && sd && !sd.running) || ticks >= 90) {
- clearInterval(_uploadScanTimer); _uploadScanTimer = null;
- if (sawRunning) reload();
- }
- }, 1000);
- }
-
- // ── Refresh Metadata (batch enrichment) from the Songs toolbar ─────────────
- // The metadata counterpart to ⟳ Refresh (which scans FILES): matches
- // titles/artist/album/artwork against MusicBrainz for the songs that still
- // need it — the ambient background matcher, run on demand (a media-server's
- // "Refresh Metadata" vs "Scan Files"). Mirrors the scan machinery: a 1 Hz
- // poll of /api/enrichment/status drives the button + batch bar, while
- // /api/enrichment/states drives per-tile badges on the visible window.
- // Enrichment is local-only, so the button hides for remote providers.
- let _metaPoll = null;
- let _metaRunning = false;
-
- function _updateMetaBtnVisibility() {
- const local = state.provider === 'local'; // enrichment + its filter are local-only
- const btn = document.getElementById('v3-songs-refresh-meta');
- if (btn) btn.style.display = local ? '' : 'none';
- const um = document.getElementById('v3-songs-unmatched');
- if (um) um.style.display = local ? '' : 'none';
- }
-
- // Quick "Show unmatched" — the same filter as the drawer's Match → Unmatched,
- // one click from the toolbar so the no-match pile is reachable right after a
- // batch. Toggles the button + re-queries the grid.
- function toggleUnmatchedFilter() {
- const m = state.filters.match || (state.filters.match = []);
- const i = m.indexOf('unmatched');
- const on = i < 0;
- if (on) m.push('unmatched'); else m.splice(i, 1);
- const btn = document.getElementById('v3-songs-unmatched');
- if (btn) { btn.classList.toggle('bg-fb-primary', on); btn.classList.toggle('text-white', on); }
- reload();
- }
-
- // The local filenames the grid is currently SHOWING (data-fn is the local
- // filename the enrichment cache keys on). The grid is windowed, so this is
- // the visible slice only — exactly what the per-tile poll should cover.
- function _visibleLocalFilenames() {
- const grid = document.getElementById('v3-songs-grid');
- if (!grid) return [];
- return [...grid.querySelectorAll('[data-fn]')]
- .map((el) => el.getAttribute('data-fn')).filter(Boolean);
- }
-
- // Set/clear one card's live badge (recycled cards re-derive from _metaTile on
- // the next paint, so update the map too — mirrors _patchCardFav).
- function _patchCardEnrich(fn, st) {
- if (st) _metaTile[fn] = st; else delete _metaTile[fn];
- const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
- document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => {
- const el = play.querySelector('.v3-meta-tile');
- const html = enrichBadge(fn);
- if (!html) { if (el) el.remove(); return; }
- if (el) el.outerHTML = html; else play.insertAdjacentHTML('beforeend', html);
- });
- }
-
- function _clearMetaTiles() {
- Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; });
- document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove());
- // The persistent "No match" badge derives from _unmatched (not _metaTile),
- // yet shares the .v3-meta-tile class — so the blanket remove above strips it.
- // Repaint the resting indicator on any rendered card so a metadata rescan's
- // tile-clear doesn't silently drop it until the next scroll/re-render.
- _unmatched.forEach((fn) => {
- const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
- document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => {
- if (play.querySelector('.v3-meta-tile')) return;
- const html = enrichBadge(fn);
- if (html) play.insertAdjacentHTML('beforeend', html);
- });
- });
- }
-
-
- // Drive the button (which doubles as Stop) + the batch bar from a status body.
- function _setMetaState(es) {
- const btn = document.getElementById('v3-songs-refresh-meta');
- const prog = document.getElementById('v3-meta-progress');
- const fill = document.getElementById('v3-meta-progress-fill');
- const label = document.getElementById('v3-meta-progress-label');
- if (!btn) return;
- const running = !!(es && es.running);
- _metaRunning = running;
- if (running) {
- const total = (es && es.total) || 0, done = (es && es.matched) || 0;
- const cancelling = !!(es && es.cancelling);
- btn.textContent = cancelling ? 'Stopping…' : ('⏹ Stop' + (total ? ' · ' + done + '/' + total : ''));
- btn.disabled = cancelling;
- btn.classList.toggle('opacity-70', cancelling);
- btn.title = cancelling ? 'Stopping after the current song…' : 'Stop refreshing metadata';
- if (prog) {
- prog.classList.remove('hidden'); prog.classList.add('flex');
- if (label) label.textContent = total ? ('Matching metadata ' + done + '/' + total) : 'Matching metadata…';
- // Real songs-processed ratio; a tiny sliver while the queue size
- // is still being computed (phase 1) so the bar isn't dead-empty.
- if (fill) fill.style.width = (total ? Math.round((done / total) * 100) : 6) + '%';
- }
- } else {
- btn.textContent = '🏷 Metadata';
- btn.disabled = false;
- btn.classList.remove('opacity-70');
- btn.title = 'Refresh metadata for the songs shown (re-match titles, artwork & more)';
- if (prog) { prog.classList.add('hidden'); prog.classList.remove('flex'); }
- }
- }
-
- // Completion toast — reuse the shared fbNotify surface (visual-only, so
- // hearing-safe for free). Honest + never-punishing copy, in-game suppressed.
- function _metaCompleteToast(es) {
- const active = document.querySelector('.screen.active');
- if (active && active.id === 'player') return;
- if (!window.fbNotify) return;
- const matched = (es && es.matched) || 0;
- const msg = matched
- ? (matched + ' song' + (matched === 1 ? '' : 's') + ' matched')
- : 'Your library metadata is up to date';
- try { window.fbNotify.show({ title: 'Metadata refresh complete', message: msg, icon: '🏷️', accent: '#22C55E' }); } catch (e) { /* */ }
- }
-
- // Poll enrichment status (button + bar) AND the visible window's per-song
- // states (tile badges) until the pass finishes. announce:false = we only
- // attached to a pass we didn't start (no toast unless it actually changed
- // something).
- function _watchEnrich(opts) {
- if (_metaPoll) return;
- const announce = !opts || opts.announce !== false;
- let sawRunning = false, ticks = 0, lastStatus = null;
- _metaPoll = setInterval(async () => {
- ticks++;
- let es = null;
- try { const r = await fetch('/api/enrichment/status'); if (r.ok) es = await r.json(); } catch (e) { /* */ }
- if (es) { lastStatus = es; _setMetaState(es); if (es.running) sawRunning = true; }
- // Per-tile badges: only songs we're tracking (seeded 'queued'). A
- // tile flips to 'working' when it's the current song, then to
- // 'done' (matched) / 'nochange' (failed) once it leaves unscanned.
- if (Object.keys(_metaTile).length) {
- const fns = _visibleLocalFilenames();
- try {
- const r = await fetch('/api/enrichment/states', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ filenames: fns }),
- });
- if (r.ok) {
- const j = await r.json();
- const states = j.states || {}, current = j.current;
- fns.forEach((fn) => {
- if (!(fn in _metaTile)) return;
- if (fn === current) { _patchCardEnrich(fn, 'working'); return; }
- const s = states[fn];
- if (s && s !== 'unscanned' && s !== 'pending') {
- _patchCardEnrich(fn, s === 'failed' ? 'nochange' : 'done');
- }
- });
- }
- } catch (e) { /* */ }
- }
- // Cap at 20 min (a ~1000-song trickle at ≤1/s is ~17 min); a
- // user-initiated no-op that never saw a running pass ends quickly.
- const noopDone = announce && !sawRunning && ticks >= 3;
- if ((sawRunning && es && !es.running) || noopDone || ticks >= 1200) {
- clearInterval(_metaPoll); _metaPoll = null;
- _setMetaState(null);
- const changed = sawRunning && lastStatus && (lastStatus.matched || 0) > 0;
- if (announce || changed) _metaCompleteToast(lastStatus);
- // Let the final 'done' badges register, then clear + (if anything
- // matched) reload so new canonical titles/art show.
- setTimeout(() => {
- _clearMetaTiles();
- if (changed && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'enrich', matched: lastStatus.matched }); } catch (e) { /* */ } }
- }, 1600);
- }
- }, 1000);
- }
-
- // Force a fresh re-match of the songs currently SHOWN (the visible grid
- // window) — a media-server-style per-view "Refresh Metadata". Resets those
- // songs and re-fetches, so it's visible even on an already-matched library.
- // Manual pins are skipped server-side; scoped to the visible set so it's
- // fast + can't blow the whole rate budget.
- async function refreshMetadata() {
- if (_metaRunning || _metaPoll) return; // already running
- const fns = _visibleLocalFilenames();
- _clearMetaTiles();
- if (!fns.length) { _metaCompleteToast({ matched: 0 }); return; }
- let queued = [];
- try {
- const r = await fetch('/api/enrichment/rematch', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ filenames: fns }),
- });
- if (r.ok) queued = (await r.json()).queued || [];
- } catch (e) { /* offline → nothing queued */ }
- // Badge exactly what the server queued (everything visible except your
- // manual pins). Nothing queued = all visible songs are pinned/unknown.
- queued.forEach((fn) => _patchCardEnrich(fn, 'queued'));
- if (!queued.length) { _metaCompleteToast({ matched: 0 }); return; }
- _watchEnrich({ announce: true });
- }
-
- async function stopMetadata() {
- try { await fetch('/api/enrichment/cancel', { method: 'POST' }); } catch (e) { /* */ }
- _setMetaState({ running: true, cancelling: true }); // optimistic; the poll confirms
- }
-
- // The Metadata button toggles role: kick a refresh when idle, Stop when a
- // pass is running.
- function onMetaBtnClick() {
- if (_metaRunning) stopMetadata(); else refreshMetadata();
- }
-
- // Topbar search drives this screen.
- async function search(q) {
- state.q = q || '';
- if (songsActive()) {
- await loadArtistCatalog();
- refreshArtistAlbumSelects();
- reload();
- } else if (window.showScreen) {
- window.showScreen('v3-songs');
- }
- }
-
- window.v3Songs = {
- render: render,
- reload: reload,
- search: search,
- setQuery: (q) => { state.q = q || ''; },
- getSort: () => state.sort,
- getArtist: () => state.artist,
- getAlbum: () => state.album,
- // The grid is windowed: only a slice of cards is in the DOM at any time.
- // A plugin that decorates cards should read THIS (not a global
- // querySelectorAll that assumes every card is present) and re-run on each
- // `v3:library-window-rendered` event rather than once at load.
- visibleCards: () => document.querySelectorAll('#v3-songs-grid [data-fn]'),
- filterParams: () => {
- const f = state.filters;
- const p = new URLSearchParams();
- if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(','));
- if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(','));
- if (f.stem_has.length) p.set('stems_has', f.stem_has.join(','));
- if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
- if (f.lyrics) p.set('has_lyrics', f.lyrics);
- if (f.tunings.length) p.set('tunings', f.tunings.join(','));
- return p.toString();
- },
- _scrollHelpers: {
- SCROLL_STATE_KEY,
- buildLibraryStateHash,
- readSnapshot: _readLibraryScrollSnapshot,
- clearSnapshot: _clearLibraryScrollSnapshot,
- },
- };
-
- if (sm && typeof sm.on === 'function') {
- sm.on('screen:changed', (e) => {
- const id = e && e.detail && e.detail.id;
- if (id === 'v3-songs') { onV3SongsScreenEnter(); return; }
- // Leaving Songs: tear down select mode + the body-mounted batch bar,
- // so an active multi-selection doesn't leave a floating bar (and
- // stale selection) visible on unrelated screens.
- if (state.selectMode || state.selected.size) {
- state.selectMode = false;
- state.selected.clear();
- const bar = document.getElementById('v3-songs-batch');
- if (bar) bar.remove();
- }
- });
- // stats-recorder POSTs the score asynchronously and emits this once the
- // server has the new best — that's the correct moment to refresh the
- // badge (song:stop fires before the POST resolves, so it's too early).
- // If the library is visible right now, repaint immediately; otherwise
- // mark it dirty and onV3SongsScreenEnter applies it on return.
- sm.on('stats:recorded', (e) => {
- // Decode to the library-card key space — the event carries the
- // encodeURIComponent'd filename, but cards (data-fn) and
- // state.accuracy key on the decoded library filename. Without this
- // the repaint below (and applyScoreRefresh's repaintAccuracy) match
- // no card, so a just-earned score stays invisible until a full
- // render() (restart / search / re-enter), which is this bug.
- const fn = decFn(e && e.detail && e.detail.filename);
- if (!fn) return;
- _dirtyScores.add(fn);
- // Only repaint now if the library is the active screen; otherwise
- // leave it dirty for onV3SongsScreenEnter (applyScoreRefresh clears
- // the set, so repainting against a hidden grid would drop the update).
- const active = document.querySelector('.screen.active');
- if (active && active.id === 'v3-songs') applyScoreRefresh();
- });
- // A library scan (rescan / full rescan from Settings, or a DLC-folder
- // change) can add or remove songs while this grid is cached — the
- // Settings rescan only refreshed the classic library, so the v3 grid
- // stayed on its pre-scan (e.g. empty, pre-DLC) state until an app
- // restart. Reload now if we're showing; otherwise mark dirty so the next
- // entry re-fetches instead of restoring the stale snapshot.
- sm.on('library:changed', () => {
- const active = document.querySelector('.screen.active');
- if (active && active.id === 'v3-songs') { _libraryDirty = false; reload(); }
- else _libraryDirty = true;
- });
- // Your live tuning changed (retune / instrument swap / reset) → re-colour the
- // visible tuning chips against the new tuning. Cheap: re-decorates in place,
- // no re-fetch or re-paint. No-op off the Songs grid or without the capability.
- sm.on('working-tuning-changed', () => {
- // A guitar<->bass SWITCH changes which tuning the facet, the filter,
- // the sort and the card chip speak for, so the grid must re-query —
- // re-colouring chips would leave the guitar tuning on screen and a
- // guitar-keyed filter applied. A retune within one instrument still
- // takes the cheap in-place path below.
- const inst = libInstrument();
- if (inst !== _lastRenderInstrument) {
- _lastRenderInstrument = inst;
- // A tuning selection keyed to the old instrument means nothing
- // for the new one; clearing avoids an empty grid the user can't
- // explain (the pills are re-rendered from the new facet).
- state.filters.tunings = [];
- const active = document.querySelector('.screen.active');
- if (active && active.id === 'v3-songs') reload();
- else _libraryDirty = true;
- return;
- }
- if (typeof songsActive === 'function' && !songsActive()) return;
- if (state.view !== 'grid') return;
- decorateTuningChips(_gridEl());
- });
- }
-})();
+/*
+ * fee[dB]ack v0.3.0 — Songs / Library (#v3-songs), native rebuild.
+ *
+ * A vanilla-JS library browser over the existing /api/library* endpoints:
+ * provider selector (via the `library` capability, not DOM scraping), grid +
+ * tree views, sort, format filter, a tri-state filter drawer (arrangements /
+ * stems / lyrics / tunings), search (driven by the topbar), infinite scroll,
+ * fb song cards with accuracy badges (song_stats), favorite + save-for-later,
+ * and upload. Reuses window.playSong for playback (design/05: library is an
+ * active capability domain; everything else stays on the documented globals).
+ */
+(function () {
+ 'use strict';
+ const sm = window.feedBack;
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
+ { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ const enc = encodeURIComponent;
+ // Inverse of `enc` for matching a played song's filename back to a library
+ // card. The `stats:recorded` event (like `song:loading`) carries the
+ // filename exactly as it was handed to `playSong` — i.e. encodeURIComponent'd
+ // (see `playCard`, and the highway WS which decodeURIComponent's it). But
+ // cards key on the DECODED library filename (`cardKey` → `localFilename`),
+ // and `/api/stats/best` is server-canonicalized to that same decoded key, so
+ // an encoded filename matches no card and the post-play badge repaint silently
+ // no-ops. Decode to land in the card/`state.accuracy` key space. Idempotent
+ // for already-decoded names (no '%'); on malformed input falls back to the
+ // original so a real filename containing a literal '%' is never corrupted.
+ function decFn(fn) {
+ if (typeof fn !== 'string' || fn.indexOf('%') === -1) return fn || '';
+ try { return decodeURIComponent(fn); } catch (_) { return fn; }
+ }
+
+ const SORTS = [
+ ['artist', 'Artist A–Z'], ['artist-desc', 'Artist Z–A'],
+ ['title', 'Title A–Z'], ['title-desc', 'Title Z–A'],
+ ['recent', 'Recently Added'], ['year-desc', 'Year (newest)'],
+ ['year', 'Year (oldest)'], ['tuning', 'Tuning'],
+ // Mastery = best accuracy across arrangements (song_stats); unscored songs
+ // sort last either way. Ascending surfaces what needs work; never default.
+ ['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'],
+ // Personal difficulty (song_user_meta.user_difficulty, 1-5); unrated
+ // songs sort last either way.
+ ['difficulty', 'Difficulty (easiest first)'], ['difficulty-desc', 'Difficulty (hardest first)'],
+ ];
+ const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
+ const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
+ const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'piano', 'other'];
+ const PAGE_SIZE = 24;
+ // Extra rows rendered above/below the viewport so a fast scroll doesn't flash
+ // blank before the next window render lands.
+ const OVERSCAN_ROWS = 2;
+ const SCROLL_STATE_KEY = 'v3:songs-scroll-state';
+ // Persisted "how I'm looking" prefs (sort / format / view / drawer filters).
+ // The tester ask: "most users pick one sort and leave it" + remember filters.
+ // The search query and the artist/album drill-down are navigational, so they
+ // are deliberately NOT persisted; cold start stays the neutral Artist A–Z.
+ const PREFS_KEY = 'v3:songs-prefs';
+ const btnCtrl = 'bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary';
+
+ const state = {
+ provider: 'local', view: 'grid', sort: 'artist', format: '', q: '',
+ artist: '', album: '',
+ grouping: true, // one card per song (multi-chart grouping); persisted
+
+ filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' },
+ page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], genres: [],
+ artistCatalog: [], renderedHash: '',
+ scrollBound: false,
+ songsById: {}, selectMode: false, selected: new Set(),
+ railLetters: null, railLettersAreSongCounts: false, railJumping: false,
+ // ── Artist page (PR-B) ──
+ // Non-null while the artist sub-page is showing (the artist's canonical
+ // or raw name). The gates mirror the two Settings toggles: pages are
+ // local-only and default ON; the external-links row is opt-in.
+ artistPage: null,
+ artistReturnScroll: null, // scrollTop to restore on ← Song Library
+ artistPagesEnabled: true,
+ artistLinksEnabled: false,
+ // ── Windowed (virtualized) grid, stage 2 of #636 item 3 ──
+ // state.songs is a SPARSE array indexed by absolute library position
+ // (0..total-1); only the fetched pages are populated and only the visible
+ // window ± overscan is ever in the DOM. The sizer element gives the
+ // scrollbar the full-library geometry. See renderWindow / ensureWindow.
+ songs: [], // sparse: absoluteIndex → song row
+ pageCursors: {}, // pageIndex → next_cursor (keyset forward fast-path)
+ keysetOk: false, // did page 0 return a non-null cursor (local + keyset sort)?
+ pageProms: {}, // pageIndex → in-flight fetch promise (de-dupe + await)
+ epoch: 0, // bumped on every reset; a stale in-flight fetch checks it
+ geom: null, // { cols, rowH, gap } measured from the live grid
+ winRange: null, // { start, end } last rendered, to skip redundant renders
+ renderedSelectMode: null, // the selectMode the current window was rendered under
+ gridResizeBound: false,
+ };
+
+ // ── A–Z jump rail ───────────────────────────────────────────────────────
+ // Ordered buckets shown on the rail: '#' (non-alphabetic) first, then A–Z.
+ const RAIL_BUCKETS = ['#'].concat('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
+ // The rail only makes sense for the alphabetical sorts; for recent/year/
+ // tuning a letter jump is meaningless, so it's hidden. Returns the column
+ // the active sort keys on ('artist' | 'title') or null when not alphabetical.
+ function railSortColumn() {
+ if (state.sort === 'artist' || state.sort === 'artist-desc') return 'artist';
+ if (state.sort === 'title' || state.sort === 'title-desc') return 'title';
+ return null;
+ }
+ // The bucket a song falls in for the active sort: first char of the sort
+ // column, uppercased; anything non-A–Z (digits, symbols, accents, blank)
+ // buckets under '#'. Mirrors the server's letter grouping in query_stats —
+ // which keys on raw SUBSTR(col, 1, 1) with no trim, and the grid ORDER BY
+ // is likewise raw, so we must NOT trim here either: a leading-space title
+ // sorts (and buckets) under '#' on both sides, keeping the rail consistent.
+ function songBucket(song) {
+ const col = railSortColumn();
+ if (!col) return '';
+ const raw = String((col === 'title' ? song.title : song.artist) || '');
+ const ch = raw.charAt(0).toUpperCase();
+ return (ch >= 'A' && ch <= 'Z') ? ch : '#';
+ }
+
+ function activeFilterCount() {
+ const f = state.filters;
+ return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length +
+ (f.lyrics ? 1 : 0) + (f.tuningMatch === 'playable' ? 1 : f.tunings.length) + (f.mastery ? f.mastery.length : 0) +
+ (f.match ? f.match.length : 0) + (f.genre ? f.genre.length : 0) +
+ (state.artist ? 1 : 0) + (state.album ? 1 : 0);
+ }
+
+ function _getV3MainScroller() { return document.getElementById('v3-main'); }
+
+ function buildLibraryStateHash(st) {
+ const f = (st && st.filters) || {};
+ return JSON.stringify({
+ view: st.view || 'grid',
+ q: st.q || '',
+ sort: st.sort || 'artist',
+ provider: st.provider || 'local',
+ format: st.format || '',
+ artist: st.artist || '',
+ album: st.album || '',
+ filters: {
+ arr_has: [...(f.arr_has || [])].sort(),
+ arr_lacks: [...(f.arr_lacks || [])].sort(),
+ stem_has: [...(f.stem_has || [])].sort(),
+ stem_lacks: [...(f.stem_lacks || [])].sort(),
+ lyrics: f.lyrics || '',
+ tunings: [...(f.tunings || [])].sort(),
+ mastery: [...(f.mastery || [])].sort(),
+ match: [...(f.match || [])].sort(),
+ genre: [...(f.genre || [])].sort(),
+ },
+ });
+ }
+
+ function _libraryStateHash() { return buildLibraryStateHash(state); }
+
+ // Restore the persisted view prefs once per page load, before the first
+ // toolbar build so the selects render with the saved values. Every value is
+ // validated against its known option list, so a stale/removed setting can
+ // never wedge the UI — it just falls back to the default.
+ let _prefsRestored = false;
+ function applySavedPrefs() {
+ let saved;
+ try { saved = JSON.parse(localStorage.getItem(PREFS_KEY)); } catch (_) { return; }
+ if (!saved || typeof saved !== 'object') return;
+ if (SORTS.some(([v]) => v === saved.sort)) state.sort = saved.sort;
+ if (FORMATS.some(([v]) => v === saved.format)) state.format = saved.format;
+ if (saved.view === 'grid' || saved.view === 'tree' || saved.view === 'folder' || saved.view === 'albums') state.view = saved.view;
+ if (typeof saved.grouping === 'boolean') state.grouping = saved.grouping;
+ const f = saved.filters;
+ if (f && typeof f === 'object') {
+ const arr = (x) => (Array.isArray(x) ? x.slice() : []);
+ // mastery + match + genre are session-only facets (deliberately not
+ // persisted), but the restored object must still CARRY the keys —
+ // the filter drawer indexes f.mastery/f.match/f.genre unconditionally,
+ // so dropping them here breaks the drawer for anyone with saved prefs.
+ state.filters = {
+ arr_has: arr(f.arr_has), arr_lacks: arr(f.arr_lacks),
+ stem_has: arr(f.stem_has), stem_lacks: arr(f.stem_lacks),
+ lyrics: f.lyrics || '', tunings: arr(f.tunings),
+ mastery: [], match: [], genre: [],
+ };
+ }
+ }
+ // Persist the current view prefs (best-effort; storage may be full/disabled).
+ // Called from reload(), which every sort/format/filter/view change funnels
+ // through — so this is the single write point.
+ function saveLibraryPrefs() {
+ try {
+ const f = state.filters;
+ localStorage.setItem(PREFS_KEY, JSON.stringify({
+ sort: state.sort, format: state.format, view: state.view,
+ grouping: state.grouping !== false,
+ filters: {
+ arr_has: [...f.arr_has], arr_lacks: [...f.arr_lacks],
+ stem_has: [...f.stem_has], stem_lacks: [...f.stem_lacks],
+ lyrics: f.lyrics || '', tunings: [...f.tunings],
+ },
+ }));
+ } catch (_) { /* best-effort */ }
+ }
+
+ function _saveLibraryScrollSnapshot() {
+ const main = _getV3MainScroller();
+ // Geometry is now stable (the sizer reserves the full scroll height
+ // regardless of how many cards are actually in the DOM), so the scroll
+ // position alone is enough to restore — no page-depth bookkeeping.
+ const snap = {
+ hash: _libraryStateHash(),
+ scrollTop: main ? main.scrollTop : 0,
+ view: state.view,
+ };
+ try { sessionStorage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } catch (e) { /* quota / private mode */ }
+ }
+
+ function _readLibraryScrollSnapshot() {
+ try {
+ const raw = sessionStorage.getItem(SCROLL_STATE_KEY);
+ if (!raw) return null;
+ const snap = JSON.parse(raw);
+ return (snap && typeof snap === 'object') ? snap : null;
+ } catch (e) { return null; }
+ }
+
+ function _clearLibraryScrollSnapshot() {
+ try { sessionStorage.removeItem(SCROLL_STATE_KEY); } catch (e) { /* */ }
+ }
+
+ function _applyMainScrollTop(scrollTop) {
+ const main = _getV3MainScroller();
+ if (!main) return;
+ const top = Math.max(0, Number(scrollTop) || 0);
+ const apply = () => { main.scrollTop = top; };
+ apply();
+ requestAnimationFrame(apply);
+ setTimeout(apply, 0);
+ }
+
+ // The windowed grid keeps only a slice of cards in the DOM, so "intact" can no
+ // longer mean "has cards" — it means the grid + sizer chrome exist and page 0
+ // is loaded (state.total known, first rows present), so renderWindow() can
+ // repaint the right slice at any scroll position.
+ function _gridDomIntact() {
+ const grid = document.getElementById('v3-songs-grid');
+ const sizer = document.getElementById('v3-songs-gridsizer');
+ return !!grid && !!sizer && state.total > 0 && state.songs[0] !== undefined;
+ }
+
+ function _treeDomIntact() {
+ const tree = document.getElementById('v3-songs-tree');
+ if (!tree) return false;
+ return !!(tree.querySelector('[data-fn]') || tree.querySelector('details'));
+ }
+
+ // ── Multi-chart grouping (P5c, design §7.1) ─────────────────────────────
+ // The grid queries with group=1 so charts of the same work collapse to ONE
+ // card — the representative (preferred/auto-pick) chart — with rows carrying
+ // chart_count + work_key from the materialized work_display read-model
+ // (P5a/P5b). group must ride BOTH the page fetch and the rail's stats fetch
+ // so page, total and sort_letters all count works identically — a
+ // works-vs-charts mismatch would break the rail's cumulative-seek math and
+ // the sizer geometry. Grouping is default-ON per the design, with a
+ // persisted toggle in the filter drawer (P5e) — OFF falls back to today's
+ // one-card-per-chart. Only the local provider implements group=; smart
+ // collections and remote providers ignore it and stay flat (their rows
+ // then carry no chart_count, so no ⚑ chips render).
+ function groupingActive() { return state.grouping !== false; }
+
+ function queryParams(extra, opts) {
+ const f = state.filters;
+ const skipArtistAlbum = opts && opts.catalog;
+ const p = new URLSearchParams();
+ p.set('provider', state.provider);
+ p.set('sort', state.sort);
+ if (state.format) p.set('format', state.format);
+ if (state.q) p.set('q', state.q);
+ if (!skipArtistAlbum && state.artist) p.set('artist', state.artist);
+ if (!skipArtistAlbum && state.album) p.set('album', state.album);
+ if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(','));
+ if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(','));
+ if (f.stem_has.length) p.set('stems_has', f.stem_has.join(','));
+ if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
+ if (f.lyrics) p.set('has_lyrics', f.lyrics);
+ // The two modes answer different questions, so only one filters at a
+ // time: sending both would silently intersect them.
+ if (f.tunings.length && f.tuningMatch !== 'playable') p.set('tunings', f.tunings.join(','));
+ // Which perspective the `tunings` filter + the tuning sort read.
+ if (libInstrument() !== 'guitar-lead') p.set('instrument', libInstrument());
+ // "Playable without retuning": send the player's LIVE tuning and let the
+ // server do the pitch maths (the pitch tables live in lib/tunings.py —
+ // duplicating them here is how the two drift apart).
+ applyPlayableParams(p, f);
+ if (f.mastery && f.mastery.length) p.set('mastery', f.mastery.join(','));
+ if (f.match && f.match.length) p.set('match', f.match.join(','));
+ if (f.genre && f.genre.length) p.set('genre', f.genre.join(','));
+ Object.entries(extra || {}).forEach(([k, v]) => p.set(k, v));
+ return p;
+ }
+
+ // The active filter set as a smart-collection rule object (raw query-param
+ // format the backend stores). Mirrors queryParams' filter fields, minus
+ // provider/page/size. Empty object → nothing worth saving as a collection.
+ function currentFilterRules() {
+ const f = state.filters, r = {};
+ if (state.q) r.q = state.q;
+ if (state.format) r.format = state.format;
+ if (state.artist) r.artist = state.artist;
+ if (state.album) r.album = state.album;
+ if (f.arr_has.length) r.arrangements_has = f.arr_has.join(',');
+ if (f.arr_lacks.length) r.arrangements_lacks = f.arr_lacks.join(',');
+ if (f.stem_has.length) r.stems_has = f.stem_has.join(',');
+ if (f.stem_lacks.length) r.stems_lacks = f.stem_lacks.join(',');
+ if (f.lyrics) r.has_lyrics = f.lyrics;
+ if (f.tunings.length) r.tunings = f.tunings.join(',');
+ if (state.sort && state.sort !== 'artist') r.sort = state.sort;
+ return r;
+ }
+
+ // Save the current filter set as a smart collection (a saved live query that
+ // shows up as a source in the picker). #636 item 2.
+ async function saveCurrentAsCollection() {
+ const rules = currentFilterRules();
+ if (!Object.keys(rules).length) return;
+ const name = ((await window.uiPrompt({
+ title: 'Save as collection',
+ label: 'A live view of the current filters, in the source picker.',
+ okLabel: 'Save',
+ placeholder: 'Collection name',
+ })) || '').trim();
+ if (!name) return;
+ try {
+ const res = await fetch('/api/collections', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, rules }),
+ });
+ if (!res.ok) return;
+ const col = (await res.json()).collection;
+ closeDrawer();
+ if (col && col.id != null) state.provider = 'collection:' + col.id;
+ await render(); // rebuilds the toolbar (provider picker now lists + selects it)
+ } catch (e) { /* offline / aborted — leave the drawer as-is */ }
+ }
+
+ function albumsForArtist(name) {
+ const a = (state.artistCatalog || []).find((x) => x.name === name);
+ return a ? (a.albums || []) : [];
+ }
+
+ function _chromeIntact() {
+ return !!(document.getElementById('v3-songs-filters') &&
+ document.getElementById('v3-songs-artist') &&
+ document.getElementById('v3-songs-grid'));
+ }
+
+ function artistSelectHtml() {
+ const opts = ['All artists ']
+ .concat((state.artistCatalog || []).map((a) =>
+ '' + esc(a.name) + ' '));
+ return opts.join('');
+ }
+
+ function albumSelectHtml() {
+ if (!state.artist) {
+ return 'Choose artist first ';
+ }
+ const albums = albumsForArtist(state.artist);
+ const opts = ['All albums ']
+ .concat(albums.map((n) =>
+ '' + esc(n) + ' '));
+ return opts.join('');
+ }
+
+ function refreshArtistAlbumSelects() {
+ const artistEl = document.getElementById('v3-songs-artist');
+ const albumEl = document.getElementById('v3-songs-album');
+ if (artistEl) artistEl.innerHTML = artistSelectHtml();
+ if (albumEl) {
+ albumEl.innerHTML = albumSelectHtml();
+ albumEl.disabled = !state.artist;
+ }
+ }
+
+ function syncChromeFromState() {
+ const map = {
+ 'v3-songs-provider': state.provider,
+ 'v3-songs-sort': state.sort,
+ 'v3-songs-format': state.format,
+ };
+ Object.entries(map).forEach(([id, val]) => {
+ const el = document.getElementById(id);
+ if (el && el.value !== val) el.value = val;
+ });
+ refreshArtistAlbumSelects();
+ const gridBtn = document.getElementById('v3-songs-grid-btn');
+ const treeBtn = document.getElementById('v3-songs-tree-btn');
+ if (gridBtn) gridBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
+ if (treeBtn) treeBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
+ const folderBtn = document.getElementById('v3-songs-folder-btn');
+ if (folderBtn) folderBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
+ // Select button tracks state.selectMode — the screen-leave teardown clears
+ // select mode, so a cached-DOM re-entry must re-style the button (and the
+ // window re-renders without checkboxes via renderWindow's selectMode check).
+ const selBtn = document.getElementById('v3-songs-select');
+ if (selBtn) selBtn.className = btnCtrl + (state.selectMode ? ' bg-fb-primary text-white' : '');
+ updateFilterBadge();
+ }
+
+ async function loadArtistCatalog() {
+ const artists = [];
+ let page = 0, total = Infinity;
+ while (artists.length < total) {
+ const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }, { catalog: true }).toString());
+ if (!data || !Array.isArray(data.artists)) break;
+ artists.push(...data.artists);
+ total = (data.total_artists != null) ? data.total_artists : artists.length;
+ if (!data.artists.length || page > 1000) break;
+ page++;
+ }
+ state.artistCatalog = artists.map((a) => ({
+ name: a.name,
+ albums: (a.albums || []).map((al) => al.name),
+ }));
+ if (state.artist && !state.artistCatalog.some((a) => a.name === state.artist)) {
+ state.artist = '';
+ state.album = '';
+ } else if (state.album && !albumsForArtist(state.artist).includes(state.album)) {
+ state.album = '';
+ }
+ return state.artistCatalog;
+ }
+
+ function resetScrollToTop() {
+ _clearLibraryScrollSnapshot();
+ _applyMainScrollTop(0);
+ }
+
+ function setArtist(value) {
+ state.artist = value || '';
+ if (state.album && !albumsForArtist(state.artist).includes(state.album)) state.album = '';
+ resetScrollToTop();
+ refreshArtistAlbumSelects();
+ reload();
+ }
+
+ function setAlbum(value) {
+ if (!state.artist) { state.album = ''; return; }
+ state.album = value || '';
+ resetScrollToTop();
+ reload();
+ }
+
+ async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } }
+
+ // ── Provider-aware song helpers ────────────────────────────────────────
+ // Remote library providers (feedBack-plugin-remote-library-*) expose songs
+ // by provider-owned id with their own art/sync/play flow. Reuse the legacy
+ // app.js globals (the shared engine) so v3 behaves identically for remote
+ // providers instead of assuming every row is a local file. All degrade to
+ // the local path when the helpers/providers aren't present.
+ function songId(s) {
+ return (window._librarySongId ? window._librarySongId(s) : (s.filename || '')) || '';
+ }
+ function localFilename(s) {
+ return window._libraryLocalFilename ? window._libraryLocalFilename(s, state.provider) : (s.filename || '');
+ }
+ // Stable per-card key: the local filename when present (local song, or a
+ // synced remote one), else the provider song id.
+ function cardKey(s) { return localFilename(s) || songId(s); }
+
+ function artUrl(song) {
+ if (window._librarySongArtUrl) return window._librarySongArtUrl(song, state.provider);
+ const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : '';
+ return song.filename ? '/api/song/' + enc(song.filename) + '/art' + v : '';
+ }
+
+ // Play a card: local (or already-synced remote) → playSong the local file;
+ // an unsynced remote song → sync it first, then play when ready.
+ function playCard(song, arrIdx) {
+ if (!song) return;
+ _saveLibraryScrollSnapshot();
+ const lf = localFilename(song);
+ if (lf) { if (window.playSong) window.playSong(enc(lf), arrIdx); return; }
+ const sid = songId(song);
+ if (window.syncLibrarySong && sid) window.syncLibrarySong(state.provider, sid, { playWhenReady: true });
+ }
+
+ // Accuracy badge markup. `variant` is 'grid' (overlay pill on the card art,
+ // default) or 'tree' (inline percentage in the list row). Both carry the
+ // .fb-acc-badge class so a post-play refresh (repaintAccuracy) can find and
+ // replace them in place without re-rendering the whole list.
+ function accuracyBadge(filename, variant) {
+ const acc = state.accuracy[filename];
+ if (acc == null) return '';
+ // Floor, never round: 100% must mean every note hit.
+ const pct = Math.floor(acc * 100);
+ if (variant === 'tree') {
+ const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low';
+ return '' + pct + '% ';
+ }
+ const color = acc >= MASTERY_ACCURACY ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
+ const text = acc >= 0.5 && acc < MASTERY_ACCURACY ? 'text-black' : 'text-white';
+ return '' +
+ ' ' + pct + '% ';
+ }
+
+ // ── Metadata-refresh per-tile state (the "Refresh Metadata" batch) ─────────
+ // A transient badge painted ONLY while a metadata refresh is running: the
+ // songs actually being (re)matched animate queued → working → done. Keyed by
+ // the card's data-fn (= the local filename the enrichment cache keys on).
+ // Empty for every song outside a refresh, so an idle card is byte-identical
+ // to before (keeps the windowed grid's height math untouched). Honest state
+ // transitions, NOT a fake per-song %: a match is binary (design §11).
+ const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange'
+ // Cards whose enrichment landed 'failed' (from the grid payload) — tracked so
+ // the PERSISTENT "no match" badge survives a batch tile clearing (a
+ // _patchCardEnrich with no flag falls back to this instead of wiping it).
+ // Populated as cards render (enrichBadge is called per card with the flag).
+ const _unmatched = new Set();
+ function enrichBadge(fn, unmatched) {
+ if (unmatched !== undefined) { if (unmatched) _unmatched.add(fn); else _unmatched.delete(fn); }
+ // A live batch tile wins over the resting no-match marker (they never
+ // coexist — the batch clears its tiles when it finishes).
+ const st = _metaTile[fn] || (_unmatched.has(fn) ? 'nomatch' : null);
+ if (!st) return '';
+ const M = {
+ queued: ['bg-black/60 text-fb-textDim', '• Queued', ''],
+ working: ['bg-fb-primary text-white', '⟳ Matching…', ''],
+ done: ['bg-fb-good/90 text-black', '✓ Updated', ''],
+ nochange: ['bg-black/60 text-fb-textDim', '— No match', ''],
+ // Resting indicator: subtle, so a mostly-unmatched library isn't a
+ // wall of loud badges. Clickable — a one-click handoff into the
+ // Fix-metadata popup for this song (see the [data-meta-fix] wiring).
+ nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'Click to fix the metadata by hand'],
+ };
+ const conf = M[st] || M.queued;
+ const fixable = st === 'nomatch'; // resting badge → opens Fix-metadata
+ // top-10 clears the tuning chip (top-2) in both normal and select mode;
+ // z-20 sits it above the art. Batch states are non-interactive; the
+ // resting "no match" badge is the handoff into the popup.
+ const cls = 'v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
+ ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight ' +
+ (fixable ? 'pointer-events-auto cursor-pointer hover:bg-fb-primary hover:text-white transition-colors' : 'pointer-events-none');
+ return '' + conf[1] + ' ';
+ }
+
+ // After a song is scored, the badge for that card is stale until the next
+ // full render(). Refresh state.accuracy from the server and patch the badge
+ // of any currently-rendered card/row in place (grid + tree). `_dirtyScores`
+ // tracks filenames scored while the library was off-screen, applied on enter.
+ const _dirtyScores = new Set();
+
+ // Set when a library scan / DLC-folder change happened while this screen was
+ // off (or showing a stale, e.g. pre-DLC empty, grid). The grid's cached DOM /
+ // snapshot would otherwise survive a sidebar return, so we force a full
+ // re-fetch on the next entry. (feedBack — "No DLC until restart".)
+ let _libraryDirty = false;
+
+ function repaintAccuracy(key) {
+ const apply = (el, variant) => {
+ if (el.getAttribute('data-fn') !== key) return;
+ const old = el.querySelector('.fb-acc-badge');
+ const html = accuracyBadge(key, variant);
+ if (variant === 'grid') {
+ const art = el.querySelector('[data-v3-play]');
+ if (!art) return;
+ if (old) old.remove();
+ if (html) art.insertAdjacentHTML('beforeend', html);
+ } else if (old) {
+ if (html) old.outerHTML = html; else old.remove();
+ } else if (html) {
+ // No prior badge in this row — insert before the favorite button
+ // so it keeps its slot (after the format chip).
+ const fav = el.querySelector('[data-fav]');
+ if (fav) fav.insertAdjacentHTML('beforebegin', html);
+ else el.insertAdjacentHTML('beforeend', html);
+ }
+ };
+ document.querySelectorAll('#v3-songs-grid [data-fn]').forEach((el) => apply(el, 'grid'));
+ document.querySelectorAll('#v3-songs-tree [data-fn]').forEach((el) => apply(el, 'tree'));
+ }
+
+ async function applyScoreRefresh() {
+ if (!_dirtyScores.size) return;
+ const fresh = await jget('/api/stats/best');
+ // Fetch failed — keep the entries dirty so the next trigger (or screen
+ // enter) retries rather than silently dropping the badge update.
+ if (!fresh) return;
+ state.accuracy = fresh;
+ const keys = Array.from(_dirtyScores);
+ _dirtyScores.clear();
+ keys.forEach(repaintAccuracy);
+ // A new score shifts the repertoire meter + the keep-practicing shelf.
+ renderLibraryHome();
+ }
+
+ // ── Practice-aware library home (repertoire meter + "Keep practicing") ─────
+ // The meter reads state.accuracy (/api/stats/best = {filename: best_accuracy});
+ // the shelf reads the growth-edge recommender (/api/library/practice-suggestions,
+ // P3). A song is "in your repertoire" at the same threshold the green accuracy
+ // badge uses (>= 0.9); a started song below that is "in progress". This is
+ // descriptive encouragement — it never gates content, decays, or nags (the
+ // goal-gradient / endowed-progress idea, kept healthy).
+ const MASTERY_ACCURACY = 0.9;
+
+ function _repertoireCounts() {
+ let mastered = 0, learning = 0;
+ for (const v of Object.values(state.accuracy || {})) {
+ if (typeof v !== 'number') continue;
+ if (v >= MASTERY_ACCURACY) mastered++; else learning++;
+ }
+ return { mastered, learning };
+ }
+
+ // The home block is the unfiltered "front door": shown on the grid view when
+ // the user isn't running a focused query (search / filter) or selecting.
+ // Local provider only — the meter's mastered count and the shelf both read
+ // local practice stats (state.accuracy / the practice-suggestions endpoint),
+ // so on a remote provider they'd mix local numerators with a remote song total
+ // and play local files while browsing a remote library. Hide it there.
+ function libHomeVisible() {
+ return state.view === 'grid' && state.provider === 'local'
+ && !state.selectMode && !state.q && activeFilterCount() === 0;
+ }
+
+ let _homeToken = 0;
+ async function renderLibraryHome() {
+ const host = document.getElementById('v3-lib-home');
+ if (!host) return;
+ if (!libHomeVisible()) { host.classList.add('hidden'); return; }
+ // A newer render (view/filter/score change) supersedes this one so a
+ // slow response can't repaint a home the grid already moved past.
+ const myToken = ++_homeToken;
+ // Unfiltered library size for the meter denominator (the grid's
+ // state.total tracks the active filter; the meter is library-wide) +
+ // the growth-edge "practice next" shelf rows, fetched together.
+ const [stats, suggestions] = await Promise.all([
+ jget('/api/library/stats?provider=' + enc(state.provider)),
+ jget('/api/library/practice-suggestions?limit=8'),
+ ]);
+ if (_homeToken !== myToken || !libHomeVisible()) { // changed mid-fetch
+ if (_homeToken === myToken) host.classList.add('hidden');
+ return;
+ }
+ const total = (stats && (stats.total_songs ?? stats.total)) || 0;
+ if (total <= 0) { host.classList.add('hidden'); return; } // empty library
+ // Shelf = the growth-edge recommender (P3): attempted-but-not-mastered
+ // songs ordered by difficulty-appropriateness × mastery-proximity, so it
+ // points at the version worth practicing next rather than just the most
+ // recent. The server already gates (not-mastered) + aggregates per song,
+ // so the rows are the shelf as-is; each row's `arrangement` is the one
+ // closest to mastery (what a click should open).
+ const shelf = Array.isArray(suggestions) ? suggestions : [];
+
+ const { mastered, learning } = _repertoireCounts();
+ // Day-one zero-state (launch polish): no practice data and no real
+ // growth-edge rows → an invitational meter, never "0 of N". Starter
+ // rows are the server's no-attempts fallback, so they count as "no
+ // practice yet" too.
+ const starterShelf = shelf.length > 0 && !!shelf[0].starter;
+ const invitational = (mastered + learning) === 0 && (!shelf.length || starterShelf);
+ let meter;
+ if (invitational) {
+ meter =
+ '' +
+ '
' +
+ 'Repertoire ' +
+ 'grows as you master songs ' +
+ '
' +
+ '
' +
+ '
';
+ } else {
+ const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
+ meter =
+ '' +
+ '
' +
+ 'Repertoire ' +
+ '' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
+ (learning ? ' · ' + learning + ' in progress' : '') + ' ' +
+ '
' +
+ '
' +
+ '
';
+ }
+
+ let shelfHtml = '';
+ if (shelf.length) {
+ const cards = shelf.map((r) =>
+ '' +
+ '' +
+ '
' +
+ accuracyBadge(r.filename) +
+ '
' +
+ '' + esc(r.title) + '
' +
+ '' + esc(r.artist) + '
' +
+ ' ').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 per
+ // arrangement. wireCards() binds the click to playCard(song, index), which
+ // opens THAT arrangement in the highway via playSong(filename, index).
+ // Shared by the grid card and the tree row so both views render the same
+ // badges with the same click-to-open behaviour. Capped at 4 to match the
+ // card layout.
+ function arrChipsHtml(song) {
+ return (song.arrangements || []).slice(0, 4).map((a) =>
+ '' + esc(a.name) + ' ').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 '⚑ ' + n + ' charts ';
+ }
+
+ // ── Tuning-match flags (working-tuning PR 6) ───────────────────────────────
+ // Colour each song's tuning chip by whether your CURRENT working tuning covers
+ // it: green = play it now, amber = needs a retune. Uses the tuner plugin's
+ // coverage check (async) + the host workingTuning state — BOTH feature-detected,
+ // so without them the chips render exactly as before. Decoration runs AFTER the
+ // (sync) window paint so scrolling stays snappy; a token cancels a superseded pass.
+ let _tuningDecorToken = 0;
+ // The instrument the current grid was queried/painted for, so a
+ // working-tuning change can tell a guitar<->bass SWITCH (re-query) from a
+ // retune within the same instrument (re-colour only).
+ let _lastRenderInstrument = null;
+ function _applyChipMatch(chip, stateName) {
+ chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400');
+ chip.classList.add(stateName === 'match' ? 'bg-emerald-500'
+ : stateName === 'retune' ? 'bg-amber-400' : 'bg-fb-mid');
+ if (!chip.dataset.baseTitle) chip.dataset.baseTitle = chip.getAttribute('title') || '';
+ chip.setAttribute('title', chip.dataset.baseTitle + (stateName === 'match'
+ ? ' — matches your tuning' : stateName === 'retune' ? ' — needs a retune' : ''));
+ }
+ async function decorateTuningChips(grid) {
+ if (!grid) return;
+ const cov = window._tunerAutoOpen && window._tunerAutoOpen.coverageReport;
+ const hasWT = window.feedBack && window.feedBack.workingTuning
+ && typeof window.feedBack.workingTuning.get === 'function';
+ if (typeof cov !== 'function' || !hasWT) return; // feature-detect → no flags
+ const token = ++_tuningDecorToken;
+ const chips = grid.querySelectorAll('[data-tuning-chip][data-tuning-offsets]');
+ for (const chip of chips) {
+ const offs = chip.getAttribute('data-tuning-offsets').split(',').map(Number);
+ if (!offs.length || offs.some((n) => !isFinite(n))) continue;
+ // Pass the instrument so coverage uses the right base pitches (bass vs guitar).
+ const arrangement = chip.dataset.tuningBass === '1' ? 'Bass' : 'Lead';
+ let rep = null;
+ try { rep = await cov({ tuning: offs, stringCount: offs.length, arrangement: arrangement }); } catch (_) { rep = null; }
+ if (token !== _tuningDecorToken) return; // superseded by a re-paint / tuning change
+ if (rep) _applyChipMatch(chip, rep.covered ? 'match' : 'retune');
+ }
+ }
+
+ function songCard(song) {
+ const fav = song.favorite;
+ const key = cardKey(song);
+ // §7.1 display-chart switch (P5e): under a chart-intrinsic filter the
+ // server may attach `display_chart` — the member that MATCHES the
+ // filter when the representative doesn't. The card SHOWS and PLAYS
+ // that chart, while the row identity (sort keys, data-fn, the
+ // accuracy/heart anchor = the preferred chart) stays the rep's.
+ const shown = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
+ // In select mode the checkbox occupies top-2 left-2, so shift the
+ // tuning chip right (left-9) to avoid overlapping it.
+ // Bass players see the bass chart's tuning (guitar fallback) — the card
+ // must agree with the facet/filter or the grid contradicts the pills.
+ const shownTuning = shownTuningName(shown);
+ const tuningLabel = (typeof window.displayTuningName === 'function')
+ ? window.displayTuningName(shownTuning)
+ : (shownTuning || '');
+ let tuning = '';
+ if (tuningLabel) {
+ const rawOffsets = (typeof window.parseRawTuningOffsets === 'function')
+ ? (window.parseRawTuningOffsets(shownTuningOffsets(shown))
+ || window.parseRawTuningOffsets(shownTuning))
+ : null;
+ const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets
+ && typeof window.displayTuningTargets === 'function')
+ ? window.displayTuningTargets(rawOffsets, { tuningName: tuningLabel })
+ : '';
+ // Mark a tuning we INFERRED from the guitar chart (this song has no
+ // bass arrangement) so a bass player isn't shown a borrowed tuning
+ // as if it were their part's. `~` keeps the chip compact; the title
+ // spells it out.
+ const inferred = shown.tuning_inferred === true;
+ const badgeTitle = (targetNotes
+ ? ('Custom Tuning: ' + targetNotes)
+ : tuningLabel)
+ + (inferred ? ' — from the guitar chart (no bass arrangement)' : '');
+ const pos = 'absolute top-2 ' + (state.selectMode ? 'left-9' : 'left-2');
+ // Tag the chip with its offsets so decorateTuningChips() can colour it
+ // green (matches your current tuning) / amber (needs a retune) after paint.
+ // Also flag a bass-only song (every arrangement is a bass part) so coverage
+ // scores its bass tuning against the bass base pitches, not guitar — otherwise
+ // a 4-string bass tuning read as guitar can false-match a guitar player.
+ const chipArrs = shown.arrangements || [];
+ // Bass either because the chip is SHOWING the bass chart's tuning
+ // (a bass player on a song that has one), or because every
+ // arrangement is a bass part. Checked via libInstrument() rather
+ // than comparing the two names — they are EQUAL for most songs, so
+ // a value comparison would flag a guitarist's chip as bass.
+ const chipIsBass = (libInstrument() === 'bass' && !!shown.bass_tuning_name)
+ || (chipArrs.length > 0
+ && chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || '')));
+ const matchAttr = (rawOffsets && rawOffsets.length)
+ ? ' data-tuning-chip data-tuning-offsets="' + esc(rawOffsets.join(',')) + '"'
+ + (chipIsBass ? ' data-tuning-bass="1"' : '') : '';
+ if (targetNotes) {
+ tuning = ''
+ + esc('Custom Tuning') + '' + esc(targetNotes) + ' ';
+ } else {
+ tuning = '' + esc(tuningLabel) + (inferred ? ' ~ ' : '') + ' ';
+ }
+ }
+ // Display-only (pointer-events-none) so a click falls through to the
+ // card's data-v3-play handler, which owns the toggle — avoids double-toggle.
+ const checkbox = state.selectMode
+ ? ' '
+ : '';
+ const arrChips = arrChipsHtml(shown);
+ const chartsChip = chartsChipHtml(song);
+ // Plugin-contributed card actions placed 'inline' (in the hover action
+ // row) or 'overlay' (centered over the art). Menu-placed actions live in
+ // the ⋮ menu (openCardMenu); rendering these here means plugins using
+ // those placements are no longer silently dropped. No bundled action
+ // uses them, so for the stock library both strings are empty — the card
+ // renders exactly as before.
+ const reg = sm && sm.libraryCardActions;
+ const acts = (reg && typeof reg.list === 'function') ? reg.list(song) : [];
+ const actBtn = (a) =>
+ '' + esc(a.icon || a.label || '•') + ' ';
+ 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 +
+ '' + (fav ? '♥' : '♡') + ' ' +
+ '🔖 ' +
+ '⋮ ' +
+ '
' +
+ '
' + 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) + ' '
+ : '
' + esc(song.artist) + '
') +
+ // Always emit the chip row (even when empty) at a FIXED single-line
+ // height — uniform card height is what makes the windowed grid's
+ // absolute-position math exact (.v3-card-chips in v3.css).
+ '
' + chartsChip + arrChips + '
' +
+ '
';
+ }
+
+ // Per-card action menu, built from the ui.library-card-injection registry
+ // (core Edit/Retune + any plugin-registered actions).
+ let _closeCardMenu = null; // tears down the currently-open card menu + its document closer
+ function openCardMenu(cardEl, song, anchorBtn, pos) {
+ // Fully close any already-open menu first — removing just the DOM node
+ // (as before) would orphan its document-level click closer.
+ if (_closeCardMenu) _closeCardMenu();
+ const reg = sm && sm.libraryCardActions;
+ // Only show actions intended for the overflow menu — actions placed
+ // 'inline'/'overlay' get their own affordances on the card (see songCard).
+ // Undefined placement defaults to the menu.
+ const items = (reg ? reg.list(song) : []).filter((a) => !a.placement || a.placement === 'menu');
+ const menu = document.createElement('div');
+ // pos (right-click) positions the menu at the pointer via `fixed`;
+ // the ⋮ button keeps the absolute top-right anchor.
+ menu.className = 'v3-card-menu z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm ' + (pos ? 'fixed' : 'absolute top-10 right-2');
+ // Multi-chart entries (P5d): a grouped grid row carries chart_count +
+ // work_key (P5a annotation), so the menu knows inline whether this card
+ // stands for versions. Local library only — the work API is local.
+ const canCharts = state.provider === 'local' && song.work_key && song.chart_count >= 2;
+ // Play follows the DISPLAYED chart (see wireCards) — under an intrinsic
+ // filter that's the matching member, not the representative.
+ const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
+ const rows = [
+ { id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(playTarget.filename)); } },
+ ...(canCharts ? [
+ { id: '__charts', label: 'Charts (' + song.chart_count + ')…' },
+ { id: '__playver', label: 'Play version ▸' },
+ ] : []),
+ // Undo for the drawer's "Split out" (P5e) — a split chart is its
+ // own singleton card (no ⚑ chip), so this is its only way back.
+ ...(state.provider === 'local' && song.is_split
+ ? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []),
+ { id: '__playlist', label: 'Add to playlist' },
+ { id: '__save', label: 'Save for later' },
+ // Artist page (PR-B, entry point 1) — local library only (the
+ // page reads the local DB) and gated on the Settings toggle.
+ ...(state.provider === 'local' && song.artist && state.artistPagesEnabled !== false
+ ? [{ id: '__artist', label: 'Go to artist' }] : []),
+ ...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
+ // Metadata + file actions (R2) — local library only (they all
+ // address the local DB / filesystem). Both openers (⋮ and
+ // right-click) share this list, so parity is structural.
+ ...(state.provider === 'local' && song.filename ? [
+ { id: '__fixmatch', label: 'Fix metadata…' },
+ { id: '__cover', label: 'Change cover…' },
+ { id: '__refreshmeta', label: 'Refresh metadata' },
+ { id: '__getinfo', label: 'Get info…' },
+ { id: '__remove', label: 'Remove from library', destructive: true },
+ ] : []),
+ ];
+ menu.innerHTML = rows.map((r) =>
+ '' + esc(r.label) +
+ (r.plugin && r.plugin !== 'core' ? '' + esc(r.plugin) + ' ' : '') + ' ').join('');
+ if (pos) {
+ // Right-click: position the menu at the pointer (fixed, viewport-
+ // relative), clamped so it never spills off the right/bottom edge.
+ document.body.appendChild(menu);
+ const x = Math.min(pos.x, window.innerWidth - menu.offsetWidth - 8);
+ const y = Math.min(pos.y, window.innerHeight - menu.offsetHeight - 8);
+ menu.style.left = Math.max(8, x) + 'px';
+ menu.style.top = Math.max(8, y) + 'px';
+ } else {
+ cardEl.appendChild(menu);
+ }
+ // Tear down BOTH the menu and its document-level closer together, so a
+ // menu-item click doesn't leave the closer attached (it would otherwise
+ // leak, retaining this menu's closures until the next document click).
+ const closer = (e) => { if (!menu.contains(e.target) && e.target !== anchorBtn) closeMenu(); };
+ function closeMenu() { menu.remove(); document.removeEventListener('click', closer); if (_closeCardMenu === closeMenu) _closeCardMenu = null; }
+ _closeCardMenu = closeMenu;
+ menu.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const id = b.getAttribute('data-act');
+ // 'Play version ▸' swaps THIS menu's rows for the work's charts —
+ // it must expand in place, so it's the one entry that doesn't close.
+ if (id === '__playver') { await _expandPlayVersions(menu, song, closeMenu); return; }
+ closeMenu();
+ if (id === '__play') { playCard(playTarget); return; }
+ if (id === '__charts') { openChartsDrawer(song.work_key, song); return; }
+ if (id === '__unsplit') {
+ if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged();
+ return;
+ }
+ if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
+ if (id === '__save') { if (window.v3Saved) await window.v3Saved.toggle(song.filename); return; }
+ if (id === '__artist') { openArtistPage(song.artist); return; }
+ // Per-chart metadata actions follow the DISPLAYED chart (playTarget),
+ // like Play — under an intrinsic filter that's the matching member,
+ // not the group representative. (__remove stays on `song`: it needs
+ // the group's work_key/chart_count and pre-ticks the shown chart.)
+ if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
+ if (id === '__cover') {
+ if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album });
+ return;
+ }
+ if (id === '__refreshmeta') {
+ // Silent on success (hearing-safe, like the rest of the match
+ // layer) — the re-match trickles in through the normal pass.
+ await jsend('POST', '/api/enrichment/refresh/' + enc(playTarget.filename));
+ return;
+ }
+ if (id === '__getinfo') { openGetInfo(playTarget); return; }
+ if (id === '__remove') { await removeSongsFlow(song); return; }
+ if (reg) await reg.run(id, song, { source: 'v3-songs' });
+ }));
+ // Tree rows ride the (ungrouped) artists endpoint, so they don't carry
+ // chart_count/work_key — resolve the work lazily and slot a
+ // "Charts (N)…" entry into the still-open menu when versions exist.
+ // Grid rows already carry both (chart_count defined ⇒ no fetch).
+ if (state.provider === 'local' && song.chart_count === undefined && song.filename) {
+ jget('/api/chart/' + enc(song.filename) + '/work').then((w) => {
+ if (!w || !menu.isConnected) return;
+ const addEntry = (label, run) => {
+ const b = document.createElement('button');
+ b.className = 'w-full text-left px-3 py-1.5 hover:bg-fb-card/60 text-fb-text';
+ b.textContent = label;
+ b.addEventListener('click', (e) => { e.stopPropagation(); closeMenu(); run(); });
+ menu.appendChild(b);
+ };
+ if (w.chart_count >= 2 && w.work_key) {
+ addEntry('Charts (' + w.chart_count + ')…', () => openChartsDrawer(w.work_key, song));
+ }
+ if (w.is_split) {
+ addEntry('Rejoin other versions', async () => {
+ if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged();
+ });
+ }
+ });
+ }
+ setTimeout(() => document.addEventListener('click', closer), 0);
+ }
+
+ // 'Play version ▸' (P5d): swap the ⋮ menu's rows for the work's charts;
+ // picking one plays that chart directly. A one-off alternate play — the
+ // keeper/headline doesn't move (stats record to the played chart's own
+ // filename), which is the design's "casual try ≠ deliberate adopt".
+ async function _expandPlayVersions(menu, song, closeMenu) {
+ const data = await jget('/api/work/' + enc(song.work_key) + '/charts');
+ if (!data || !Array.isArray(data.charts) || !data.charts.length || !menu.isConnected) return;
+ menu.innerHTML = data.charts.map((c) => {
+ const tl = (typeof window.displayTuningName === 'function')
+ ? window.displayTuningName(c.tuning_name || c.tuning) : (c.tuning_name || '');
+ return '' +
+ (c.is_representative ? '● ' : '') + esc(c.title) +
+ (tl ? '' + esc(tl) + ' ' : '') +
+ ' ';
+ }).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 '' +
+ ' ' +
+ '' + esc(c.title) +
+ (c.is_representative ? ' ● ' : '') + ' ' +
+ (meta ? '' + esc(meta) + ' ' : '') +
+ '' + esc(c.filename) + ' ';
+ }).join('');
+ const n = sel.size;
+ overlay.innerHTML =
+ '';
+ 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 =
+ '';
+ document.body.appendChild(overlay);
+ overlay.querySelector('[data-gi-x]')?.addEventListener('click', done);
+ }
+
+ // ── Charts drawer (P5d, design §7.1 UX-2/3) ────────────────────────────────
+ // The single deep-management surface for a work's charts. A body-appended
+ // slide-in panel (the filter-drawer idiom; body-appended like the playlist
+ // picker so it opens from any view) listing every chart of the work as a
+ // radiogroup — the checked row is the keeper the grid card plays. Clicking
+ // an unchecked row (or Enter/Space on it) = Set as preferred, one tap;
+ // "Reset to auto pick" appears when the keeper is your explicit pick.
+ // Writes go through the work-charts API and the drawer re-renders from the
+ // response (there's no server-side library event bus — the drawer is its
+ // own refresh); the grid re-fetches because the representative may have
+ // flipped. Global mode only — the slot-scoped (curated-album) mode is P6;
+ // the per-row Split escape hatch is P5e.
+ let _chartsPrevFocus = null; // focus to restore when the drawer closes
+
+ function _chartsDrawerEls() {
+ let ov = document.getElementById('v3-charts-overlay');
+ let dr = document.getElementById('v3-charts-drawer');
+ if (!ov) {
+ ov = document.createElement('div');
+ ov.id = 'v3-charts-overlay';
+ ov.className = 'fixed inset-0 bg-black/50 z-40 hidden';
+ ov.addEventListener('click', closeChartsDrawer);
+ document.body.appendChild(ov);
+ }
+ if (!dr) {
+ dr = document.createElement('aside');
+ dr.id = 'v3-charts-drawer';
+ dr.className = 'fixed top-0 right-0 h-full w-full sm:w-96 bg-fb-sidebar border-l border-fb-border/50 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll';
+ dr.setAttribute('role', 'dialog');
+ dr.setAttribute('aria-modal', 'true');
+ dr.setAttribute('aria-label', 'Charts of this song');
+ // a11y: Escape closes; Tab is trapped inside the open drawer
+ // (aria-modal alone doesn't trap for keyboard users); ArrowUp/Down
+ // move focus between the chart rows (focus only — selection stays
+ // on Enter/Space, since a native-radio "arrow = select" would fire
+ // a preferred write on every keystroke).
+ dr.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); closeChartsDrawer(); return; }
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ const rows = [...dr.querySelectorAll('[role="radio"]')];
+ if (!rows.length) return;
+ const i = rows.indexOf(document.activeElement);
+ if (i === -1) return;
+ e.preventDefault();
+ rows[(i + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length].focus();
+ return;
+ }
+ if (e.key !== 'Tab') return;
+ const foci = dr.querySelectorAll('button, [tabindex]:not([tabindex="-1"])');
+ if (!foci.length) return;
+ const first = foci[0], last = foci[foci.length - 1];
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
+ });
+ document.body.appendChild(dr);
+ }
+ return { ov: ov, dr: dr };
+ }
+
+ // One chart row. `checked` = the current keeper (pref or auto-pick). The
+ // filename line is deliberate: duplicate charts usually share title/artist,
+ // so the pack filename is often the only human-readable distinguisher.
+ function _chartRowHtml(c, data) {
+ const checked = c.is_representative;
+ const prefLabel = checked
+ ? (data.preferred_source === 'user' ? 'Preferred — your pick' : 'Preferred (auto)')
+ : '';
+ const tuningLabel = (typeof window.displayTuningName === 'function')
+ ? window.displayTuningName(c.tuning_name || c.tuning)
+ : (c.tuning_name || '');
+ const meta = [fmtLabel(c), tuningLabel,
+ (c.arrangements || []).map((a) => a.name).join('/'),
+ c.year ? String(c.year) : '']
+ .filter(Boolean).join(' · ');
+ const acc = (typeof c.best_accuracy === 'number')
+ ? '' + Math.floor(c.best_accuracy * 100) + '% '
+ : 'not played ';
+ return '' +
+ '
' +
+ '
' +
+ '
' + esc(c.title) + '
' +
+ '
' + esc(meta) + ' · ' + acc + '
' +
+ '
' + esc(c.filename) + '
' +
+ (prefLabel ? '
' + esc(prefLabel) + '
' : '') +
+ '
' +
+ '
▶ ' +
+ '
' +
+ '
' +
+ '+ Playlist ' +
+ // Split escape hatch (P5e, §7.1): "these aren't the same song".
+ // Only offered while the work still has 2+ charts — splitting the
+ // last member is meaningless. Undo lives in the split-out card's
+ // ⋮ menu ("Rejoin other versions").
+ (data.count >= 2
+ ? 'Split out '
+ : '') +
+ '
' +
+ '
';
+ }
+
+ // 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'
+ ? '
Reset to auto pick '
+ : '
Auto pick sticks with a chart you\'ve practised; otherwise most complete → newest. Tap a chart to pin your keeper.
') +
+ '
';
+
+ dr.querySelector('[data-charts-close]').addEventListener('click', closeChartsDrawer);
+ dr.querySelector('[data-charts-auto]')?.addEventListener('click', async () => {
+ const fresh = await jsend('DELETE', '/api/work/' + enc(data.work_key) + '/preferred');
+ if (!fresh) return;
+ _renderChartsDrawer(fresh);
+ _groupChanged();
+ dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
+ });
+ dr.querySelectorAll('[data-ch]').forEach((row) => {
+ const fn = row.getAttribute('data-ch');
+ const setPreferred = async () => {
+ if (row.getAttribute('aria-checked') === 'true') return; // already the keeper
+ const fresh = await jsend('PUT', '/api/work/' + enc(data.work_key) + '/preferred', { filename: fn });
+ if (!fresh) return;
+ _renderChartsDrawer(fresh, { switched: true });
+ _groupChanged();
+ dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
+ };
+ row.addEventListener('click', setPreferred);
+ row.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setPreferred(); }
+ });
+ row.querySelector('[data-ch-play]')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ closeChartsDrawer();
+ _saveLibraryScrollSnapshot();
+ if (window.playSong) window.playSong(enc(fn));
+ });
+ row.querySelector('[data-ch-pl]')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ addFilenamesToPlaylist([fn]);
+ });
+ row.querySelector('[data-ch-split]')?.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const ok = await jsend('POST', '/api/chart/' + enc(fn) + '/split');
+ if (!ok) return;
+ _groupChanged();
+ // Re-read the ORIGINAL work — the split chart is gone from it
+ // (now its own card in the grid; its ⋮ menu offers the rejoin).
+ const fresh = await jget('/api/work/' + enc(data.work_key) + '/charts');
+ if (fresh && Array.isArray(fresh.charts) && fresh.charts.length) _renderChartsDrawer(fresh);
+ else closeChartsDrawer();
+ });
+ });
+ }
+
+ async function openChartsDrawer(workKey, _song) {
+ if (!workKey) return;
+ const data = await jget('/api/work/' + enc(workKey) + '/charts');
+ if (!data || !Array.isArray(data.charts) || !data.charts.length) return;
+ const els = _chartsDrawerEls();
+ _chartsPrevFocus = document.activeElement;
+ _renderChartsDrawer(data);
+ els.ov.classList.remove('hidden');
+ els.dr.classList.remove('translate-x-full');
+ els.dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
+ }
+
+ function closeChartsDrawer() {
+ document.getElementById('v3-charts-overlay')?.classList.add('hidden');
+ document.getElementById('v3-charts-drawer')?.classList.add('translate-x-full');
+ if (_chartsPrevFocus && typeof _chartsPrevFocus.focus === 'function' && _chartsPrevFocus.isConnected) {
+ _chartsPrevFocus.focus();
+ }
+ _chartsPrevFocus = null;
+ }
+
+ // Global opener — the ⚑ chip routes through this, and other views/plugins
+ // (P2's details drawer, dashboards) can open the drawer without reaching
+ // into this module.
+ window.__fbOpenChartsDrawer = openChartsDrawer;
+
+ function wireCards(scope) {
+ scope.querySelectorAll('[data-fn]').forEach((el) => {
+ if (el.dataset.wired) return; // don't double-bind on append/auto-fill
+ el.dataset.wired = '1';
+ const fn = el.getAttribute('data-fn');
+ const song = state.songsById[fn] || { filename: fn };
+ // §7.1: when a chart-intrinsic filter attached a display_chart,
+ // the card SHOWS that member — so play actions target it too
+ // (its arrangement indices match the rendered chips). Identity
+ // actions (heart/save/playlist/menu registry) stay on the rep row.
+ const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
+ el.querySelectorAll('[data-v3-play]').forEach((pe) => pe.addEventListener('click', (e) => {
+ if (state.selectMode) { e.preventDefault(); toggleSelect(fn, el); return; }
+ playCard(playTarget); // local → play; unsynced remote → sync then play
+ }));
+ el.querySelector('[data-menu]')?.addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(el, song, e.currentTarget); });
+ // Native right-click opens the same overflow menu at the pointer.
+ el.addEventListener('contextmenu', (e) => { e.preventDefault(); openCardMenu(el, song, null, { x: e.clientX, y: e.clientY }); });
+ el.querySelectorAll('[data-arr]').forEach((ab) => ab.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const idx = ab.getAttribute('data-arr');
+ playCard(playTarget, idx === '' ? undefined : Number(idx));
+ }));
+ // ⚑ charts chip → the Charts drawer (P5d). Never a card play — the
+ // chip row sits outside [data-v3-play]; stopPropagation is
+ // belt-and-braces.
+ el.querySelector('[data-charts]')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
+ });
+ // "No match" badge → straight into the Fix-metadata popup for this
+ // song (the batch → fix handoff). stopPropagation so it doesn't also
+ // trigger the card's play. Follows the displayed chart, like the menu.
+ el.querySelector('[data-meta-fix]')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (window.__fbFixMatch) window.__fbFixMatch(playTarget);
+ });
+ // Artist line → the artist page (PR-B). In select mode the grid's
+ // capture-phase toggle intercepts first, so selection still wins.
+ el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ openArtistPage(song.artist);
+ });
+ el.querySelector('[data-fav]')?.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const btn = e.currentTarget;
+ try {
+ const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) });
+ const d = await r.json();
+ btn.textContent = d.favorite ? '♥' : '♡';
+ btn.setAttribute('aria-pressed', d.favorite ? 'true' : 'false');
+ // Swap exactly the idle colour this button was rendered with
+ // (grid = text-white, tree/List view = text-fb-textDim). The old
+ // hardcoded text-white toggle never removed the list view's
+ // text-fb-textDim, so the heart changed glyph but stayed dim
+ // (never turned red) until a re-search re-rendered the row.
+ const idle = btn.getAttribute('data-fav-idle') || 'text-white';
+ btn.classList.toggle('text-fb-accent', d.favorite);
+ btn.classList.toggle(idle, !d.favorite);
+ song.favorite = d.favorite; // keep the model in sync for a re-render/recycle
+ } catch (err) { /* */ }
+ });
+ el.querySelector('[data-save]')?.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ if (window.v3Saved) { const saved = await window.v3Saved.toggle(fn); e.currentTarget.classList.toggle('text-fb-primary', !!saved); }
+ });
+ // Inline/overlay plugin card actions → run via the shared registry.
+ el.querySelectorAll('[data-act-card]').forEach((ab) => ab.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const reg = sm && sm.libraryCardActions;
+ if (reg) await reg.run(ab.getAttribute('data-act-card'), song, { source: 'v3-songs' });
+ }));
+ });
+ }
+
+ // ── Multi-select + batch actions ──────────────────────────────────────--
+ function toggleSelect(fn, el) {
+ if (state.selected.has(fn)) state.selected.delete(fn); else state.selected.add(fn);
+ const on = state.selected.has(fn);
+ const cb = el.querySelector('[data-select]'); if (cb) cb.checked = on;
+ el.querySelector('[data-v3-play]')?.classList.toggle('ring-2', on);
+ el.querySelector('[data-v3-play]')?.classList.toggle('ring-fb-primary', on);
+ renderBatchBar();
+ }
+
+ // Bulletproof multi-select: in select mode a capture-phase click anywhere
+ // inside a [data-fn] row toggles the card and STOPS the event, so nothing (a
+ // per-card handler, a stray/legacy listener, an arrangement chip) can start
+ // playback. Attached ONCE to each persistent host (grid / tree / artist page)
+ // — their innerHTML is replaced on re-render but the host element survives,
+ // so a single bind never double-fires. Group headers / non-song chrome sit
+ // outside any [data-fn], so closest() is null and their native clicks pass
+ // through untouched.
+ function bindSelectGuard(hostEl) {
+ if (!hostEl) return;
+ hostEl.addEventListener('click', (e) => {
+ if (!state.selectMode) return;
+ const card = e.target.closest('[data-fn]');
+ if (!card || !hostEl.contains(card)) return;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ toggleSelect(card.getAttribute('data-fn'), card);
+ }, true);
+ }
+
+ function setSelectMode(on) {
+ state.selectMode = on;
+ if (!on) state.selected.clear();
+ const btn = document.getElementById('v3-songs-select');
+ if (btn) btn.className = btnCtrl + (on ? ' bg-fb-primary text-white' : '');
+ reload(); // re-render cards with/without checkboxes
+ renderBatchBar();
+ }
+
+ function renderBatchBar() {
+ let bar = document.getElementById('v3-songs-batch');
+ if (!state.selectMode || state.selected.size === 0) { if (bar) bar.remove(); return; }
+ if (!bar) {
+ bar = document.createElement('div');
+ bar.id = 'v3-songs-batch';
+ bar.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-fb-card border border-fb-border/60 rounded-full shadow-xl px-4 py-2';
+ document.body.appendChild(bar);
+ }
+ bar.innerHTML =
+ '' + state.selected.size + ' selected ' +
+ 'Add to playlist ' +
+ 'Edit details ' +
+ 'Save for Later ' +
+ 'Clear ';
+ 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 '' +
+ '' + boxFor(r) + ' ' +
+ '' + esc(r.name) + ' ' +
+ '' + (r.count || 0) + ' ';
+ }
+ 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 =
+ '' +
+ '
Add ' + fns.length + ' song' + (fns.length === 1 ? '' : 's') + ' to playlist ' +
+ '✕ ' +
+ '
Create
' +
+ (rows.length > 8 ? '
' : '') +
+ '
' + listHtml() + '
' +
+ '
Done
' +
+ '
';
+ 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) =>
+ '' + label + ' ';
+ const addChips = bulk.add.map((t) => '' + esc(t) +
+ '× ').join('');
+ const rmChips = [...present].sort((a, b) => a.localeCompare(b)).map((t) =>
+ '' + esc(t) + ' ').join('');
+ const nothing = bulk.diff === 'keep' && !bulk.add.length && !bulk.remove.size;
+ overlay.innerHTML =
+ '';
+
+ 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.
' +
+ '
Open Settings ' +
+ '
';
+ }
+
+ 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 ''
+ + esc(L) + ' ';
+ }).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) =>
+ '
' +
+ '' +
+ (a.cover ? '
' : '') +
+ '
' +
+ '' + esc(a.album) + '
' +
+ '' + esc(a.artist) + ' · ' + (a.count || 0) + ' track' + (a.count === 1 ? '' : 's') + '
' +
+ ' ').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(backLabel) + ' ' +
+ '' +
+ '
' + esc(a.album) + ' ' +
+ '
' + esc(a.artist) + ' · ' + songs.length + ' track' + (songs.length === 1 ? '' : 's') + '
' +
+ (songs.length ? '
▶ Play album ' : '') +
+ '
' +
+ '' + songs.map((s, i) =>
+ '' +
+ '' + (i + 1) + ' ' +
+ '' + esc(s.title || s.filename) + ' ').join('') + ' ';
+ 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 ? '
' + 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].
+ '
' +
+ '' + (s.favorite ? '♥' : '♡') + ' ' +
+ '🔖 ' +
+ '⋮ ' +
+ '
' +
+ '
');
+ }
+
+ // ── Artist page (PR-B, artist-pages launch charrette) ──────────────────────
+ // An in-place sub-render like openAlbum(): the artist "in your library" — a
+ // shelf plus your relationship to it, never a discography browser (locked
+ // position 1). Renders 100% from the local /page payload; the external
+ // links row is the one decorated extra, gated on the opt-in Settings toggle
+ // AND a MusicBrainz match, fetched lazily and cached server-side. Every
+ // count obeys the DENOMINATOR LAW (locked position 2): songs YOU OWN.
+
+ function _artistHostEl() { return document.getElementById('v3-songs-artistpage'); }
+
+ // ── Instrument-aware tuning (the bass-player tuning-filter report) ────────
+ // A song's bass chart is often in a different tuning from its guitar chart,
+ // so the tuning facet/filter/sort and the card chip must speak for the
+ // instrument the player actually plays. The host's working-tuning
+ // capability already holds the live selection (seeded from /api/settings on
+ // boot, updated when the player switches) — read it rather than adding
+ // another settings fetch. `state.settingsInstrument` is the fallback for
+ // hosts where the capability isn't mounted.
+ // Three perspectives, matching `active_instrument_profile`: lead and rhythm
+ // guitar charts can be tuned differently too, so a rhythm player hits the
+ // same bug a bassist did. The PROFILE is the only three-valued source (the
+ // working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm),
+ // so it wins; the capability is the live fallback for hosts where the
+ // profile hasn't loaded.
+ const PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass'];
+ function libInstrument() {
+ if (PERSPECTIVES.indexOf(state.settingsProfile) >= 0) return state.settingsProfile;
+ try {
+ const wt = window.feedBack && window.feedBack.workingTuning;
+ if (wt && typeof wt.get === 'function') {
+ const cur = wt.get();
+ if (cur && cur.instrument === 'bass') return 'bass';
+ }
+ } catch (_) { /* capability absent/erroring — fall through to settings */ }
+ return state.settingsInstrument === 'bass' ? 'bass' : 'guitar-lead';
+ }
+
+ // "Playable without retuning" mode reads the player's CURRENT tuning from
+ // the working-tuning capability (the live session state the tuner writes),
+ // not a separate setting. No capability => we cannot know the current
+ // tuning, so the mode is unavailable rather than guessed.
+ function currentWorkingTuning() {
+ try {
+ const wt = window.feedBack && window.feedBack.workingTuning;
+ if (!wt || typeof wt.get !== 'function') return null;
+ const cur = wt.get();
+ if (!cur || !Array.isArray(cur.offsets) || !cur.offsets.length) return null;
+ return cur;
+ } catch (_) { return null; }
+ }
+
+ function playableAvailable() { return !!currentWorkingTuning(); }
+
+ function applyPlayableParams(p, f) {
+ if (f.tuningMatch !== 'playable') return;
+ const cur = currentWorkingTuning();
+ if (!cur) return;
+ p.set('tuning_match', 'playable');
+ p.set('playable_offsets', cur.offsets.join(','));
+ p.set('playable_instrument', cur.instrument === 'bass' ? 'bass' : 'guitar');
+ p.set('playable_string_count', String(cur.stringCount || cur.offsets.length));
+ }
+
+ // Short human label for the perspective, for the facet/sort headers.
+ function libInstrumentLabel() {
+ const p = libInstrument();
+ return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead';
+ }
+
+ // The column a row's tuning lives in for the active perspective.
+ function perspectiveTuningField() {
+ const p = libInstrument();
+ return p === 'bass' ? 'bass_tuning_name'
+ : p === 'guitar-rhythm' ? 'rhythm_tuning_name' : '';
+ }
+
+ // The tuning a card should SHOW: bass players see the bass chart's tuning,
+ // falling back to the song (guitar-derived) tuning when the song has no
+ // bass arrangement — the common case, so the fallback is not an edge path.
+ function shownTuningName(song) {
+ const f = perspectiveTuningField();
+ if (f && song[f]) return song[f];
+ return song.tuning_name || song.tuning;
+ }
+
+ function shownTuningOffsets(song) {
+ const f = perspectiveTuningField();
+ if (f && song[f]) {
+ return song[f.replace('_name', '_offsets')] || song.tuning_offsets;
+ }
+ return song.tuning_offsets;
+ }
+
+ // Sync the two Settings gates into module state (fire-and-forget — the
+ // cached flags gate entry-point rendering; openArtistPage re-checks).
+ function refreshArtistPageGates() {
+ return jget('/api/settings').then((cfg) => {
+ if (!cfg) return;
+ state.artistPagesEnabled = cfg.artist_pages_enabled !== false;
+ state.artistLinksEnabled = cfg.artist_external_links === true;
+ // Fallback instrument for hosts without the working-tuning capability.
+ state.settingsInstrument = cfg.instrument === 'bass' ? 'bass' : 'guitar';
+ // The three-valued perspective source (lead / rhythm / bass).
+ state.settingsProfile = cfg.active_instrument_profile || '';
+ });
+ }
+
+ // 2×2 mosaic of the artist's OWN album art — the playlist-cover grammar
+ // (#626 playlistCoverHtml) adapted to the page payload's art_urls. Never a
+ // broken-image tile: no art → a quiet glyph.
+ function artistMosaicHtml(arts) {
+ const box = 'w-32 h-32 sm:w-40 sm:h-40 shrink-0 rounded-xl overflow-hidden bg-fb-card';
+ const img = (u) => ' ';
+ if (!arts || !arts.length) return '🎤
';
+ if (arts.length < 4) return '' + img(arts[0]) + '
';
+ return '' + arts.slice(0, 4).map(img).join('') + '
';
+ }
+
+ function _linkDomain(u) {
+ try { return new URL(u).hostname.replace(/^www\./, ''); } catch (_) { return ''; }
+ }
+
+ // Toggle the browse hosts (grid/tree/albums/folder + home + rail) so the
+ // artist page can own the scroller, and back again on close.
+ function _setBrowseHostsHidden(hidden) {
+ if (hidden) {
+ ['v3-songs-gridsizer', 'v3-songs-tree', 'v3-songs-albums', 'lib-folder-tree',
+ 'v3-lib-home', 'v3-songs-azrail', 'v3-songs-azbubble']
+ .forEach((id) => document.getElementById(id)?.classList.add('hidden'));
+ const fc = document.getElementById('lib-folder-controls');
+ if (fc) fc.style.display = 'none';
+ } else {
+ document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
+ document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
+ document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums');
+ document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
+ const fc = document.getElementById('lib-folder-controls');
+ if (fc) fc.style.display = state.view === 'folder' ? 'flex' : 'none';
+ refreshRail();
+ updateLibraryHome();
+ }
+ }
+
+ // The one exported opener — every entry point (card ⋮ / right-click "Go to
+ // artist", the grid card's artist line, the Details drawer link, a
+ // similar-artist chip) funnels through here.
+ async function openArtistPage(artistName) {
+ const host = _artistHostEl();
+ if (!host || !artistName) return;
+ if (state.provider !== 'local' || state.artistPagesEnabled === false) return;
+ const main = _getV3MainScroller();
+ // Remember where browsing left off ONCE — chip-hopping between artist
+ // pages keeps the original return point.
+ if (!state.artistPage) state.artistReturnScroll = main ? main.scrollTop : 0;
+ state.artistPage = artistName;
+ _setBrowseHostsHidden(true);
+ host.classList.remove('hidden');
+ host.innerHTML = 'Loading…
';
+ _applyMainScrollTop(0);
+ const page = await jget('/api/artist/' + enc(artistName) + '/page');
+ if (state.artistPage !== artistName) return; // superseded
+ if (!page) { closeArtistPage(); return; }
+ await renderArtistPage(page);
+ }
+
+ function closeArtistPage() {
+ const host = _artistHostEl();
+ if (host) { host.classList.add('hidden'); host.innerHTML = ''; }
+ if (!state.artistPage) return;
+ state.artistPage = null;
+ _setBrowseHostsHidden(false);
+ const top = state.artistReturnScroll;
+ state.artistReturnScroll = null;
+ _applyMainScrollTop(top || 0);
+ if (state.view === 'grid') requestWindowRender();
+ }
+
+ // reload() (any toolbar-driven change) leaves the sub-page without the
+ // scroll restore — the new state describes a fresh browse from the top.
+ function _dropArtistPageSilently() {
+ if (!state.artistPage) return;
+ state.artistPage = null;
+ state.artistReturnScroll = null;
+ const host = _artistHostEl();
+ if (host) { host.classList.add('hidden'); host.innerHTML = ''; }
+ }
+
+ async function renderArtistPage(page) {
+ const host = _artistHostEl();
+ if (!host) return;
+ const me = state.artistPage;
+ const name = page.artist || me || '';
+ // Songs list: page through /api/library with the artist filter (locked
+ // position 6 — query_page, keyset-safe; never the DISTINCT+OFFSET
+ // query_artists path). Unfiltered on purpose: the page is the artist's
+ // whole shelf, not the grid's current filter view.
+ const songs = [];
+ let p = 0, total = Infinity;
+ while (songs.length < total) {
+ const q = new URLSearchParams({
+ provider: 'local', artist: name, sort: 'artist',
+ size: '100', page: String(p),
+ });
+ const data = await jget('/api/library?' + q.toString());
+ if (!data || !Array.isArray(data.songs)) break;
+ songs.push(...data.songs);
+ total = (data.total != null) ? data.total : songs.length;
+ if (!data.songs.length || p > 50) break; // safety: no progress / runaway
+ p++;
+ }
+ if (state.artistPage !== me || !host.isConnected) return; // superseded mid-fetch
+ songs.forEach((s) => { state.songsById[cardKey(s)] = s; });
+
+ const aliasLine = (page.variants || []).length
+ ? 'also shown as: ' +
+ page.variants.map((v) => esc(v.name) + ' ×' + v.count).join(' · ') + '
'
+ : '';
+ // Provenance pill — only when the artist is actually matched (drawer/
+ // Get-info grammar: say where the tidy names come from, ≤2 taps away).
+ const pill = page.mb_artist_id
+ ? 'Matched · MusicBrainz
'
+ : '';
+ // Stats strip. DENOMINATOR LAW: every number is songs in YOUR library;
+ // the mastered segment is omitted entirely until one exists —
+ // invitational, never "0 mastered" (launch blind-spot #3).
+ const bits = [
+ page.song_count + ' song' + (page.song_count === 1 ? '' : 's'),
+ page.album_count + ' album' + (page.album_count === 1 ? '' : 's'),
+ ];
+ if (page.mastered_count > 0) bits.push(page.mastered_count + ' mastered');
+
+ const albumsHtml = (page.albums || []).length
+ ? 'Albums ' +
+ '' +
+ page.albums.map((al, i) =>
+ '
' +
+ '' +
+ (al.cover ? '
' : '') +
+ '
' +
+ '' + esc(al.name) + '
' +
+ '' + (al.year ? esc(al.year) + ' · ' : '') + (al.count || 0) + ' track' + (al.count === 1 ? '' : 's') + '
' +
+ ' ').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) =>
+ '' + esc(s.artist) + ' ').join('') +
+ '
'
+ : '';
+
+ host.innerHTML =
+ '← Song Library ' +
+ '' +
+ artistMosaicHtml(page.art_urls) +
+ '
' +
+ '
' + esc(name) + ' ' +
+ aliasLine + pill +
+ '
' + bits.join(' · ') + '
' +
+ '
' +
+ (songs.length
+ ? '▶ Play all ' +
+ '⇄ Shuffle '
+ : '') +
+ 'Save as smart playlist ' +
+ '
' +
+ '
' +
+ 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
' +
+ '
';
+ }
+
+ // Global opener — the drawer link, plugins, and other views reach the page
+ // without touching this module's internals.
+ window.__fbOpenArtistPage = openArtistPage;
+
+ async function loadTree() {
+ const host = document.getElementById('v3-songs-tree');
+ if (!host) return;
+ // The list view always groups artist -> album (query_artists has no
+ // free sort) — when the picked sort is something else, say so instead
+ // of silently ignoring it ("why does the sort do nothing here?").
+ const _treeSortNote = railSortColumn() === 'artist' ? ''
+ : 'List view groups by artist — the selected sort applies to the card grid.
';
+ // Capture expanded groups BEFORE the "Loading…" wipe below, so a reload
+ // (e.g. toggling select mode) restores them instead of collapsing all.
+ const openArtists = new Set(
+ [...host.querySelectorAll('details[open]')].map((d) => d.getAttribute('data-artist')));
+ host.innerHTML = 'Loading…
';
+ // Page through ALL artists — the endpoint clamps size to 100, so a
+ // single request would silently truncate libraries with >100 artists.
+ const artists = [];
+ let page = 0, total = Infinity;
+ while (artists.length < total) {
+ const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }).toString());
+ if (!data || !Array.isArray(data.artists)) break;
+ artists.push(...data.artists);
+ total = (data.total_artists != null) ? data.total_artists : artists.length;
+ if (!data.artists.length || page > 1000) break; // safety: no progress / runaway guard
+ page++;
+ }
+ if (!artists.length) { host.innerHTML = 'Nothing here.
'; return; }
+ artists.forEach((a) => (a.albums || []).forEach((al) => (al.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; })));
+ host.innerHTML = _treeSortNote + artists.map((a) =>
+ '' +
+ '' + esc(a.name) + ' ' + esc(a.song_count) + ' ' +
+ '' + (a.albums || []).map((al) =>
+ '
' + esc(al.name || 'Unknown') + '
' +
+ (al.songs || []).map(treeSongRowHtml).join('') + '
').join('') + '
').join('');
+ wireCards(host);
+ }
+
+ // ── Filter drawer ─────────────────────────────────────────────────────--
+ function triState(list_has, list_lacks, value) {
+ if (list_has.includes(value)) return 'has';
+ if (list_lacks.includes(value)) return 'lacks';
+ return 'any';
+ }
+ function cycleTri(hasArr, lacksArr, value) {
+ const s = triState(hasArr, lacksArr, value);
+ const rm = (a) => { const i = a.indexOf(value); if (i >= 0) a.splice(i, 1); };
+ rm(hasArr); rm(lacksArr);
+ if (s === 'any') hasArr.push(value);
+ else if (s === 'has') lacksArr.push(value);
+ // 'lacks' → cycles back to any (already removed)
+ }
+ function triPill(group, value, label, st, title) {
+ const cls = st === 'has' ? 'bg-fb-good/30 text-fb-good border-fb-good/40'
+ : st === 'lacks' ? 'bg-fb-low/30 text-fb-low border-fb-low/40'
+ : 'bg-gray-800/50 text-fb-textDim border-gray-700';
+ const mark = st === 'has' ? '✓ ' : st === 'lacks' ? '✕ ' : '';
+ const tip = title ? ' title="' + esc(title) + '"' : '';
+ return '' + mark + esc(label) + ' ';
+ }
+ function renderDrawer() {
+ const d = document.getElementById('v3-songs-drawer');
+ if (!d) return;
+ const f = state.filters;
+ d.innerHTML =
+ '' +
+ '
Filters ' +
+ '✕ ' +
+ section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) +
+ section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('') +
+ (() => {
+ // One-click "which songs still need splitting": lacks EVERY
+ // instrument stem — the same query Stem Splitter's own
+ // missing-stems view runs. Toggles off if already active.
+ const on = STEMS.every((s) => f.stem_lacks.includes(s)) && !f.stem_has.length;
+ return '
'
+ + (on ? '✕ ' : '') + 'Not split ';
+ })()) +
+ section('Lyrics', ['', '1', '0'].map((v) => '
' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + ' ').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) => '
' + it[1] + ' ').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) => '
' + it[1] + ' ').join('')) +
+ // Genre facet — dynamic list from /api/library/genres (primary genre).
+ (state.genres && state.genres.length ? section('Genre', state.genres.map((g) => '
' + esc(g) + ' ').join('')) : '') +
+ // The facet header NAMES the perspective. Silent instrument-following
+ // is the original bug in a new place: the user must be able to tell
+ // which instrument these tunings describe.
+ section('Tuning (' + libInstrumentLabel() + ')',
+ // MODE toggle. Exact match answers "which tuning is this
+ // labelled"; Playable answers "will this cost me a retune" —
+ // which is what a player actually wants. Both are offered;
+ // exact stays the default so nothing changes unasked.
+ '
'
+ + [['exact', 'Exact tuning'], ['playable', 'Playable without retuning']].map((m) => {
+ const on = (f.tuningMatch || 'exact') === m[0];
+ const dis = m[0] === 'playable' && !playableAvailable();
+ return ''
+ + esc(m[1]) + ' ';
+ }).join('')
+ + '
'
+ + (f.tuningMatch === 'playable'
+ ? '
Charts you can play in your current tuning, no retune. Songs whose lowest string sits below yours are excluded.
'
+ : '')
+ + ((state.tuningNames || []).map((t) => {
+ // Filter on the server's grouping key (raw offsets for customs)
+ // so two "Custom Tuning" entries are distinct; show their target
+ // notes in the label so they're distinguishable.
+ const val = t.key || t.name;
+ let label = t.name;
+ if (t.name === 'Custom Tuning' && t.offsets
+ && typeof window.parseRawTuningOffsets === 'function'
+ && typeof window.displayTuningTargets === 'function') {
+ const offs = window.parseRawTuningOffsets(t.offsets);
+ const notes = offs ? window.displayTuningTargets(offs, { tuningName: t.name }) : '';
+ if (notes) label = 'Custom · ' + notes;
+ }
+ // Be honest about the fallback: when some of a row's songs have
+ // no bass chart and are borrowing the guitar tuning, say so
+ // rather than presenting a borrowed tuning as a measured one.
+ const inf = t.inferred_count || 0;
+ const title = inf
+ ? inf + ' of ' + t.count + ' inferred from the guitar chart (no bass arrangement)'
+ : '';
+ const countLabel = inf ? t.count + ', ' + inf + ' inferred' : String(t.count);
+ return triPill('tuning', val, label + ' (' + countLabel + ')',
+ f.tunings.includes(val) ? 'has' : 'any', title);
+ }).join('') || '
No tunings ')) +
+ // Multi-chart grouping toggle (P5e) — a VIEW mode, not a filter
+ // (never counted in the badge, never saved into collection rules).
+ // Local provider only: it's the one that implements group=.
+ (state.provider === 'local'
+ ? section('Grouping', '
' +
+ (state.grouping !== false ? '✓ ' : '') + 'One card per song ')
+ : '') +
+ // 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
+ ? '
+ Save as collection
'
+ : '') +
+ // 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'
+ ? '
Tidy up artists…
'
+ : '') +
+ '
Clear all ' +
+ 'Done
';
+
+ d.querySelectorAll('[data-tri]').forEach((b) => b.addEventListener('click', () => {
+ const g = b.getAttribute('data-tri'), v = b.getAttribute('data-val');
+ if (g === 'arr') cycleTri(f.arr_has, f.arr_lacks, v);
+ else if (g === 'stem') cycleTri(f.stem_has, f.stem_lacks, v);
+ else if (g === 'tuning') { const i = f.tunings.indexOf(v); if (i >= 0) f.tunings.splice(i, 1); else f.tunings.push(v); }
+ renderDrawer();
+ }));
+ d.querySelectorAll('[data-tuning-match]').forEach((b) => b.addEventListener('click', () => {
+ if (b.disabled) return;
+ f.tuningMatch = b.getAttribute('data-tuning-match');
+ renderDrawer();
+ }));
+ d.querySelector('[data-stem-unsplit]')?.addEventListener('click', () => {
+ const on = STEMS.every((s) => f.stem_lacks.includes(s)) && !f.stem_has.length;
+ f.stem_has.length = 0;
+ f.stem_lacks.length = 0;
+ if (!on) f.stem_lacks.push(...STEMS);
+ renderDrawer();
+ });
+ d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); }));
+ d.querySelectorAll('[data-mastery]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-mastery'); const i = f.mastery.indexOf(v); if (i >= 0) f.mastery.splice(i, 1); else f.mastery.push(v); renderDrawer(); }));
+ d.querySelector('[data-grouping]')?.addEventListener('click', () => {
+ state.grouping = !(state.grouping !== false);
+ renderDrawer();
+ reload(); // re-fetches grid + rail with/without group=1, saves prefs
+ });
+ d.querySelectorAll('[data-match]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-match'); const i = f.match.indexOf(v); if (i >= 0) f.match.splice(i, 1); else f.match.push(v); renderDrawer(); }));
+ d.querySelectorAll('[data-genre]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-genre'); const i = f.genre.indexOf(v); if (i >= 0) f.genre.splice(i, 1); else f.genre.push(v); renderDrawer(); }));
+ d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection);
+ d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp);
+ d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
+ d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => {
+ state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' };
+ state.artist = '';
+ state.album = '';
+ renderDrawer();
+ await loadArtistCatalog();
+ refreshArtistAlbumSelects();
+ reload();
+ });
+ d.querySelector('[data-drawer-apply]')?.addEventListener('click', async () => {
+ closeDrawer();
+ await loadArtistCatalog();
+ refreshArtistAlbumSelects();
+ reload();
+ });
+ }
+ function section(label, inner) {
+ return '' + label + '
' + inner + '
';
+ }
+ function openDrawer() { renderDrawer(); document.getElementById('v3-songs-drawer')?.classList.remove('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.remove('hidden'); }
+ function closeDrawer() { document.getElementById('v3-songs-drawer')?.classList.add('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.add('hidden'); updateFilterBadge(); }
+ function updateFilterBadge() { const b = document.getElementById('v3-songs-filter-count'); if (b) { const n = activeFilterCount(); b.textContent = n; b.classList.toggle('hidden', n === 0); } }
+
+ // ── Song Details drawer (P2) ───────────────────────────────────────────--
+ // Evolves the legacy edit-metadata modal into a v3 slide-in drawer that
+ // unifies catalog identity (writes back into the feedpak FILE), the personal
+ // practice layer (difficulty / tags / notes — local, never shared), the like
+ // heart, and remove-from-library. Reuses the filter-drawer slide idiom
+ // (fixed-right panel + overlay + translate-x-full) but is built on demand and
+ // body-appended, so it opens from any card context. The Charts drawer (P5d)
+ // reuses this pattern.
+ const DIFF_LABELS = ['', 'Very easy', 'Easy', 'Medium', 'Hard', 'Very hard'];
+ let _detailsEls = null; // {overlay, drawer, opener, onKey} while open
+
+ function closeDetails() {
+ if (!_detailsEls) return;
+ const { overlay, drawer, opener, onKey } = _detailsEls;
+ _detailsEls = null;
+ document.removeEventListener('keydown', onKey);
+ drawer.classList.add('translate-x-full');
+ overlay.classList.add('opacity-0');
+ setTimeout(() => { drawer.remove(); overlay.remove(); }, 200);
+ if (opener && document.body.contains(opener)) { try { opener.focus({ preventScroll: true }); } catch (_) { /* */ } }
+ }
+
+ async function openDetails(song) {
+ if (!song || !song.filename) return;
+ closeDetails();
+ const fn = song.filename;
+ const opener = document.activeElement;
+ // Notes aren't in the grid row payload; difficulty/tags are, but re-read
+ // for authority (another tab / a bulk edit may have changed them).
+ let meta = { user_difficulty: (song.user_difficulty != null ? song.user_difficulty : null), notes: '', tags: song.tags || [] };
+ try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ }
+ let vocab = [];
+ try { const r = await fetch('/api/tags'); if (r.ok) vocab = (await r.json()).tags || []; } catch (_) { /* */ }
+ // Match provenance (launch polish): the drawer names what this chart
+ // matched, so a silently-wrong first match is visible where the
+ // metadata lives. 404 (no row yet) / offline → no line.
+ let enrich = null;
+ try { const r = await fetch('/api/enrichment/song/' + enc(fn)); if (r.ok) enrich = await r.json(); } catch (_) { /* offline → no provenance line */ }
+ if (_detailsEls) closeDetails(); // a concurrent open resolved first
+
+ const st = {
+ t: song.title || '', a: song.artist || '', al: song.album || '', y: song.year != null ? String(song.year) : '',
+ diff: (meta.user_difficulty != null ? meta.user_difficulty : null),
+ notes: meta.notes || '', tags: (meta.tags || []).slice(),
+ fav: !!song.favorite, artDataUrl: null,
+ gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys
+ enrich: enrich, // match provenance for the Identity section
+ };
+
+ const overlay = document.createElement('div');
+ overlay.className = 'fixed inset-0 bg-black/50 z-[60] opacity-0 transition-opacity duration-200';
+ const drawer = document.createElement('aside');
+ drawer.id = 'v3-song-details-drawer';
+ drawer.className = 'fixed top-0 right-0 h-full w-full sm:w-[26rem] bg-fb-sidebar border-l border-fb-border/50 z-[61] transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll';
+ drawer.setAttribute('role', 'dialog');
+ drawer.setAttribute('aria-modal', 'true');
+ drawer.setAttribute('aria-label', 'Song details');
+ document.body.appendChild(overlay);
+ document.body.appendChild(drawer);
+
+ const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeDetails(); } };
+ document.addEventListener('keydown', onKey);
+ overlay.addEventListener('click', closeDetails);
+ _detailsEls = { overlay, drawer, opener, onKey };
+
+ const render = () => { drawer.innerHTML = detailsHtml(song, st, vocab); wireDetails(drawer, song, st, vocab, render); };
+ render();
+ requestAnimationFrame(() => { drawer.classList.remove('translate-x-full'); overlay.classList.remove('opacity-0'); });
+ if (window._trapFocusInModal) { try { window._trapFocusInModal(drawer); } catch (_) { /* */ } }
+ const first = drawer.querySelector('#det-title');
+ if (first) { try { first.focus({ preventScroll: true }); const n = first.value.length; first.setSelectionRange(n, n); } catch (_) { /* */ } }
+ }
+
+ // Gap-fill (R4a) block inside the drawer's Identity section: preview →
+ // per-key confirm → written. Adds ABSENT keys only; the server re-checks
+ // under its io lock, so this UI can never replace an author-set value.
+ const GAP_KEY_LABELS = { album: 'Album', year: 'Year', genres: 'Genres', mbid: 'MusicBrainz ID', isrc: 'ISRC' };
+ function gapFillHtml(st) {
+ const g = st.gap;
+ if (!g) return 'Write missing info to file… ';
+ 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 '' +
+ ' ' +
+ '' + esc(GAP_KEY_LABELS[m.key] || m.key) + ' ' +
+ '' + esc(val) + ' ';
+ }).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.
' +
+ '
Write to file ' +
+ 'Cancel
';
+ }
+
+ // 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) + ') ' +
+ 'Fix match
';
+ }
+
+ function detailsHtml(song, st, vocab) {
+ const art = st.artDataUrl || artUrl(song);
+ const diffBtns = [1, 2, 3, 4, 5].map((n) =>
+ '' + 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) =>
+ '' + esc(v.tag) + ' ').join('');
+ const field = (id, label, val) =>
+ '' + label + ' ' +
+ '
';
+ return '' +
+ '
Song details ' +
+ '✕ ' +
+
+ '
' +
+ '
' +
+ '
' +
+ '
Change
' +
+ '
' +
+ '
' + esc(song.title || song.filename) + '
' +
+ '
' + (st.fav ? '♥ Liked' : '♡ Like') + ' ' +
+ 'a like, not a rating
' +
+
+ // Identity — writes back into the feedpak FILE
+ '
' +
+ 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)
+ ? '
View artist page → '
+ : '') +
+ field('det-album', 'Album', st.al) +
+ '
Year
' +
+ provenanceHtml(st) +
+ '
' + gapFillHtml(st) + '
' +
+
+ // Personal practice layer — local, never shared
+ '
Your practice · stays on this device
' +
+ '
Difficulty (for you) ' +
+ 'Clear
' +
+ '
' + diffBtns + '' + esc(st.diff ? DIFF_LABELS[st.diff] : 'Not set') + '
' +
+ '
Tags ' +
+ '
' + tagChips + '
' +
+ '
' +
+ 'Add
' +
+ (suggest ? '
' + suggest + '
' : '') + '
' +
+ '
Notes ' +
+ '
' +
+
+ '
Save ' +
+ 'Cancel
' +
+ '
Remove from library
' +
+ '
';
+ }
+
+ function wireDetails(drawer, song, st, vocab, render) {
+ const $ = (s) => drawer.querySelector(s);
+ drawer.querySelectorAll('[data-det-close]').forEach((b) => b.addEventListener('click', closeDetails));
+ // Catalog inputs update state without a re-render (keep caret / focus).
+ const bindInput = (id, key) => { const el = $('#' + id); if (el) el.addEventListener('input', () => { st[key] = el.value; }); };
+ bindInput('det-title', 't'); bindInput('det-artist', 'a'); bindInput('det-album', 'al'); bindInput('det-year', 'y');
+ const notes = $('#det-notes'); if (notes) notes.addEventListener('input', () => { st.notes = notes.value; });
+
+ drawer.querySelectorAll('[data-diff]').forEach((b) => b.addEventListener('click', () => {
+ const n = Number(b.getAttribute('data-diff'));
+ st.diff = (st.diff === n ? null : n); // click the active one to unset
+ render();
+ }));
+ $('[data-diff-clear]')?.addEventListener('click', () => { st.diff = null; render(); });
+
+ drawer.querySelectorAll('[data-tag-rm]').forEach((b) => b.addEventListener('click', () => {
+ const t = b.getAttribute('data-tag-rm'); const i = st.tags.indexOf(t); if (i >= 0) st.tags.splice(i, 1); render();
+ }));
+ drawer.querySelectorAll('[data-tag-add]').forEach((b) => b.addEventListener('click', () => { if (addDetailTag(st, b.getAttribute('data-tag-add'))) render(); }));
+ const addFromInput = () => { const el = $('#det-tag-input'); if (!el) return; if (addDetailTag(st, el.value)) { render(); drawer.querySelector('#det-tag-input')?.focus(); } };
+ $('[data-tag-addbtn]')?.addEventListener('click', addFromInput);
+ $('#det-tag-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addFromInput(); } });
+
+ const artWrap = $('[data-det-art]'); const artFile = $('#det-art-file');
+ if (artWrap && artFile) {
+ // Art click opens the cover PICKER (PR-C) — the old direct file
+ // dialog lives on inside it as the Upload tile. The picker applies
+ // immediately (its own routes + refresh), so it bypasses the
+ // drawer's Save; the file-input path below stays as the fallback
+ // when image-picker.js isn't loaded.
+ artWrap.addEventListener('click', () => {
+ if (window.__fbOpenImagePicker) {
+ window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
+ } else {
+ artFile.click();
+ }
+ });
+ artFile.addEventListener('change', () => {
+ const f = artFile.files && artFile.files[0]; if (!f) return;
+ const rd = new FileReader();
+ rd.onload = (e) => { st.artDataUrl = e.target.result; const img = $('#det-art-preview'); if (img) { img.src = st.artDataUrl; img.style.visibility = 'visible'; } };
+ rd.readAsDataURL(f);
+ });
+ }
+
+ $('[data-det-fav]')?.addEventListener('click', async (e) => {
+ try {
+ const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: song.filename }) });
+ const d = await r.json(); st.fav = !!d.favorite;
+ const btn = e.currentTarget; btn.textContent = st.fav ? '♥ Liked' : '♡ Like';
+ btn.classList.toggle('text-fb-accent', st.fav); btn.classList.toggle('text-fb-textDim', !st.fav);
+ _patchCardFav(song.filename, st.fav);
+ } catch (_) { /* */ }
+ });
+
+ $('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st));
+ $('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song));
+ // Fix match → the exact flow the card ⋮ menu uses (match-review.js).
+ // The drawer closes first: the match modal sits below the drawer's
+ // z-index, and the fix supersedes the edit anyway.
+ $('[data-det-fixmatch]')?.addEventListener('click', () => {
+ closeDetails();
+ if (window.__fbFixMatch) window.__fbFixMatch(song);
+ });
+ // "View artist page →" — uses the field's CURRENT text (an in-progress
+ // rename still lands on the right page once saved; unsaved text simply
+ // canonicalizes server-side), falling back to the row's artist.
+ $('[data-det-artist-page]')?.addEventListener('click', () => {
+ const a = (st.a || '').trim() || song.artist || '';
+ if (!a) return;
+ closeDetails();
+ openArtistPage(a);
+ });
+
+ // Gap-fill (R4a): user-initiated write of CONFIRMED missing info into
+ // the pack file. The server recomputes proposals under its io lock, so
+ // a key that gained an author value since the preview is skipped.
+ $('[data-gapfill-check]')?.addEventListener('click', async () => {
+ st.gap = { loading: true }; render();
+ let d = null;
+ try { const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill'); if (r.ok) d = await r.json(); } catch (_) { /* offline */ }
+ st.gap = d || { eligible: false, reason: 'error' };
+ st.gapSel = new Set(((d && d.missing) || []).map((m) => m.key));
+ render();
+ });
+ drawer.querySelectorAll('[data-gapfill-key]').forEach((cb) => cb.addEventListener('change', () => {
+ const k = cb.getAttribute('data-gapfill-key');
+ if (!st.gapSel) st.gapSel = new Set();
+ if (cb.checked) st.gapSel.add(k); else st.gapSel.delete(k);
+ }));
+ $('[data-gapfill-cancel]')?.addEventListener('click', () => { st.gap = null; render(); });
+ $('[data-gapfill-write]')?.addEventListener('click', async () => {
+ const keys = st.gapSel ? Array.from(st.gapSel) : [];
+ if (!keys.length) return;
+ let d = null, ok = false;
+ try {
+ const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys }) });
+ ok = r.ok; d = await r.json();
+ } catch (_) { /* offline */ }
+ if (!ok || !d || !d.written) {
+ if (window.fbNotify) { try { window.fbNotify.show({ title: 'Write failed', message: 'Could not write to the file. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } }
+ st.gap = null; render(); return;
+ }
+ // Reflect what landed in the open drawer (and keep saveDetails'
+ // changed-detection honest by updating both sides), then refresh
+ // the grid quietly.
+ if (d.written.album != null) { st.al = String(d.written.album); song.album = st.al; }
+ if (d.written.year != null) { st.y = String(d.written.year); song.year = d.written.year; }
+ st.gap = { written: d.written }; render();
+ try { reload(); } catch (_) { /* not on the songs grid */ }
+ });
+ }
+
+ // Normalize + append a tag to the drawer's working set (mirrors the server's
+ // _normalize_tag). Returns whether it was actually added (new + non-blank).
+ function addDetailTag(st, raw) {
+ const t = String(raw || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 60);
+ if (!t || st.tags.includes(t)) return false;
+ st.tags.push(t); st.tags.sort((a, b) => a.localeCompare(b)); return true;
+ }
+
+ // Keep a rendered card's heart in sync when the drawer toggles the like (the
+ // heart is instant, not part of Save — matches the on-card heart).
+ function _patchCardFav(fn, fav) {
+ const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
+ document.querySelectorAll('[data-fn="' + sel + '"] [data-fav]').forEach((btn) => {
+ btn.textContent = fav ? '♥' : '♡';
+ btn.setAttribute('aria-pressed', fav ? 'true' : 'false');
+ // Swap exactly the idle colour this heart was rendered with (grid =
+ // text-white, tree/List view = text-fb-textDim) — mirrors wireCards.
+ // A hardcoded text-white toggle would leave List-View rows' text-fb-textDim
+ // in place, so the heart changed glyph but stayed dim (never turned red).
+ const idle = btn.getAttribute('data-fav-idle') || 'text-white';
+ btn.classList.toggle('text-fb-accent', fav); btn.classList.toggle(idle, !fav);
+ });
+ }
+
+ async function saveDetails(song, st) {
+ const fn = song.filename;
+ const catChanged = st.t.trim() !== (song.title || '') || st.a.trim() !== (song.artist || '')
+ || st.al.trim() !== (song.album || '') || String(st.y).trim() !== (song.year != null ? String(song.year) : '');
+ const ops = [];
+ if (catChanged) ops.push(fetch('/api/song/' + enc(fn) + '/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: st.t.trim(), artist: st.a.trim(), album: st.al.trim(), year: String(st.y).trim() }) }));
+ ops.push(fetch('/api/song/' + enc(fn) + '/user-meta', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_difficulty: st.diff, notes: st.notes, tags: st.tags }) }));
+ if (st.artDataUrl) ops.push(fetch('/api/song/' + enc(fn) + '/art/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: st.artDataUrl }) }));
+ // Fire the writes concurrently but know each outcome — a failed write must not
+ // look like a save. allSettled keeps partial results; treat a rejection OR a
+ // non-ok HTTP response as failure and keep the drawer open so the user can retry.
+ const results = await Promise.allSettled(ops);
+ const failed = results.some((r) => r.status === 'rejected' || (r.value && r.value.ok === false));
+ if (failed) {
+ if (window.fbNotify) { try { window.fbNotify.show({ title: 'Save failed', message: 'Could not save your changes. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } }
+ return;
+ }
+ closeDetails();
+ try { reload(); } catch (_) { /* not on the songs grid */ }
+ }
+
+ async function removeFromLibrary(song) {
+ const title = song.title || song.filename;
+ let ok;
+ if (window._confirmDialog) {
+ ok = await window._confirmDialog({
+ title: 'Remove from library?',
+ body: 'Remove ' + esc(title) + ' from your library?
' +
+ 'This permanently deletes the file from disk. This cannot be undone.
',
+ confirmText: 'Remove', cancelText: 'Cancel', danger: true,
+ });
+ } else { ok = window.confirm('Remove "' + title + '" from your library? This deletes the file from disk.'); }
+ if (!ok) return;
+ try { const r = await fetch('/api/song/' + enc(song.filename), { method: 'DELETE' }); if (!r.ok) return; } catch (_) { return; }
+ closeDetails();
+ try { reload(); } catch (_) { /* */ }
+ }
+
+ // Expose the opener so the core `edit-metadata` card action opens this drawer
+ // (it falls back to the legacy modal where this isn't defined).
+ window.__fbOpenSongDetails = openDetails;
+
+ // ── Artist Tidy-up (P4) ────────────────────────────────────────────────--
+ // Merge messy artist variants ("ACDC" + "AC/DC") into one canonical name.
+ // Aliases apply AT DISPLAY only (no file rewrite); the dropdown/tree/grid pick
+ // up the change on the next load. Self-contained centered modal.
+ function openArtistTidyUp() {
+ const st = { raw: [], aliases: [], sel: new Set(), query: '', mergeInto: '', mergeTouched: false, busy: false };
+
+ const overlay = document.createElement('div');
+ overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
+ const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } };
+ function done() { overlay.remove(); document.removeEventListener('keydown', onKey); }
+ document.addEventListener('keydown', onKey);
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); });
+ document.body.appendChild(overlay);
+
+ async function refresh() {
+ const [raw, aliases] = await Promise.all([jget('/api/artists/raw'), jget('/api/artist-aliases')]);
+ st.raw = (raw && raw.artists) || [];
+ st.aliases = (aliases && aliases.aliases) || [];
+ render();
+ }
+
+ // Default canonical = the highest-count selected variant (the one most of
+ // your library already uses), unless the user typed their own.
+ function defaultCanonical() {
+ let best = '', bestC = -1;
+ st.raw.forEach((a) => { if (st.sel.has(a.name) && a.count > bestC) { bestC = a.count; best = a.name; } });
+ return best;
+ }
+
+ function render() {
+ if (!st.mergeTouched) st.mergeInto = defaultCanonical();
+ const q = st.query.trim().toLowerCase();
+ const list = st.raw.filter((a) => !q || (a.name || '').toLowerCase().includes(q));
+ const rows = list.map((a) => {
+ const checked = st.sel.has(a.name) ? ' checked' : '';
+ const mapped = (a.canonical && a.canonical.toLowerCase() !== (a.name || '').toLowerCase())
+ ? '→ ' + esc(a.canonical) + ' ' : '';
+ return '' +
+ ' ' +
+ '' + esc(a.name) + mapped + ' ' +
+ '' + a.count + ' ';
+ }).join('') || 'No artists
';
+
+ const canReady = st.mergeInto.trim() && st.sel.size && !st.busy;
+ const mergeBar = st.sel.size
+ ? '' +
+ '
' + st.sel.size + ' selected — merge into:
' +
+ '
' +
+ 'Merge
'
+ : '';
+
+ const aliasRows = st.aliases.length
+ ? st.aliases.map((al) => '' + esc(al.raw_name) + ' → ' + esc(al.canonical_name) + ' ' +
+ 'un-merge
').join('')
+ : 'No merges yet
';
+
+ overlay.innerHTML =
+ '';
+
+ overlay.querySelectorAll('[data-tidy-x]').forEach((b) => b.addEventListener('click', done));
+ const search = overlay.querySelector('[data-tidy-search]');
+ if (search) search.addEventListener('input', () => { st.query = search.value; render(); const s = overlay.querySelector('[data-tidy-search]'); if (s) { s.focus(); const n = s.value.length; try { s.setSelectionRange(n, n); } catch (_) { /* */ } } });
+ overlay.querySelectorAll('[data-tidy-sel]').forEach((cb) => cb.addEventListener('change', () => {
+ const n = cb.getAttribute('data-tidy-sel');
+ if (cb.checked) st.sel.add(n); else st.sel.delete(n);
+ render();
+ }));
+ const canon = overlay.querySelector('[data-tidy-canon]');
+ if (canon) canon.addEventListener('input', () => {
+ st.mergeInto = canon.value; st.mergeTouched = true;
+ const btn = overlay.querySelector('[data-tidy-merge]');
+ if (btn) btn.disabled = !(st.mergeInto.trim() && st.sel.size && !st.busy);
+ });
+ overlay.querySelector('[data-tidy-merge]')?.addEventListener('click', doMerge);
+ overlay.querySelectorAll('[data-tidy-unmerge]').forEach((b) => b.addEventListener('click', () => doUnmerge(b.getAttribute('data-tidy-unmerge'))));
+ }
+
+ async function doMerge() {
+ const canonical = st.mergeInto.trim();
+ if (!canonical || !st.sel.size || st.busy) return;
+ st.busy = true;
+ await jsend('POST', '/api/artist-aliases/merge', { raw_names: [...st.sel], canonical_name: canonical });
+ st.sel.clear(); st.mergeTouched = false; st.busy = false;
+ await afterChange();
+ }
+
+ async function doUnmerge(raw) {
+ if (!raw) return;
+ try { await fetch('/api/artist-aliases/' + enc(raw), { method: 'DELETE' }); } catch (_) { /* */ }
+ await afterChange();
+ }
+
+ async function afterChange() {
+ await refresh();
+ // Reflect canonical names in the toolbar dropdown + grid immediately.
+ try { await loadArtistCatalog(); refreshArtistAlbumSelects(); reload(); } catch (_) { /* not on the songs grid */ }
+ }
+
+ refresh();
+ }
+
+ // The host loads the Folder Library plugin's screen.js at startup (defining
+ // window.folderLibrary). If it isn't present yet, inject it once; the
+ // plugin's IIFEs are idempotent so a redundant evaluation is a no-op. The
+ // promise is memoised so concurrent folder-view switches don't double-inject.
+ let _flLoadPromise = null;
+ function _ensureFolderLibrary() {
+ if (window.folderLibrary) return Promise.resolve();
+ if (_flLoadPromise) return _flLoadPromise;
+ _flLoadPromise = new Promise((resolve, reject) => {
+ const s = document.createElement('script');
+ s.src = '/api/plugins/folder_library/screen.js';
+ s.onload = () => resolve();
+ s.onerror = () => { _flLoadPromise = null; reject(new Error('Failed to load Folder Library')); };
+ document.head.appendChild(s);
+ });
+ return _flLoadPromise;
+ }
+
+ function reload() {
+ _clearLibraryScrollSnapshot();
+ // Any toolbar-driven change backs out of the artist sub-page — the new
+ // state describes a fresh browse, and the host toggles below re-show
+ // the picked view (mirrors how openAlbum's detail yields to a reload).
+ _dropArtistPageSilently();
+ // Record the state this fetch reflects so a later sidebar return can
+ // tell whether the grid is stale (e.g. an off-screen search changed
+ // state.q) and needs a refresh rather than a scroll-preserving no-op.
+ state.renderedHash = _libraryStateHash();
+ saveLibraryPrefs();
+ updateFilterBadge();
+ // A sort/filter/search/view change rebuilds the grid from page 0, so any
+ // in-flight letter jump is now paging through a dataset that's about to
+ // be discarded — supersede it so it can't scroll the rebuilt grid.
+ _jumpToken++;
+ // Keep a handle on the load so callers (notably the scroll restore on
+ // screen re-entry) can await page-0 actually landing before paging
+ // deeper. The visibility/scroll resets below stay synchronous.
+ // Hide the SIZER (not the inner grid) for non-grid views, so its reserved
+ // scroll height collapses and the tree/folder content sits at the top.
+ document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
+ document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
+ document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
+ document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums');
+ // Refresh the A–Z jump rail (shows only for the grid + alphabetical
+ // sorts; hides itself otherwise). Independent of the grid load.
+ refreshRail();
+ // Refresh the practice-aware home (repertoire meter + keep-practicing
+ // shelf); hides itself when searching/filtering/selecting or off-grid.
+ updateLibraryHome();
+ { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
+ if (state.view === 'folder') {
+ _applyMainScrollTop(0);
+ return _ensureFolderLibrary().then(() => window.folderLibrary?.load());
+ }
+ const loaded = state.view === 'grid' ? loadGrid(true) : state.view === 'albums' ? loadAlbums() : loadTree();
+ _applyMainScrollTop(0);
+ return loaded;
+ }
+
+ // ── Chrome ────────────────────────────────────────────────────────────--
+ async function loadProviders() {
+ try {
+ const lp = sm && sm.libraryProviders;
+ // refresh() re-fetches /api/library/providers so REMOTE providers
+ // (registered by feedBack-plugin-remote-library-*) appear — list()
+ // returns only the capability's initial local-only snapshot.
+ const fn = lp && (typeof lp.refresh === 'function' ? lp.refresh : (typeof lp.list === 'function' ? lp.list : null));
+ if (fn) {
+ const snap = await fn.call(lp);
+ if (snap && Array.isArray(snap.providers)) {
+ state.provider = snap.current || (snap.providers[0] && snap.providers[0].id) || 'local';
+ return snap.providers;
+ }
+ }
+ } catch (e) { /* */ }
+ const data = await jget('/api/library/providers');
+ return (data && data.providers) || [{ id: 'local', label: 'My Library' }];
+ }
+
+ async function render() {
+ const root = document.getElementById('v3-songs');
+ if (!root) return;
+ // A full re-render (filter/scan refresh) replaces the DOM the card menu
+ // anchored to; dismiss any open menu first so it can't float orphaned.
+ if (_closeCardMenu) _closeCardMenu();
+ // Restore last-used sort/format/view/filters once, before building the
+ // toolbar so its selects reflect the saved choice (default: Artist A–Z).
+ if (!_prefsRestored) { applySavedPrefs(); _prefsRestored = true; }
+ const providers = await loadProviders();
+ const [, tn] = await Promise.all([
+ (async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(),
+ jget('/api/library/tuning-names?provider=' + enc(state.provider)
+ + '&instrument=' + enc(libInstrument())),
+ loadArtistCatalog(),
+ // Artist-page gates (PR-B) ride the initial fetch batch so the
+ // first card paint already knows whether artist lines are links.
+ refreshArtistPageGates(),
+ ]);
+ state.tuningNames = (tn && tn.tunings) || [];
+ _lastRenderInstrument = libInstrument();
+ try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; }
+
+ const opt = (arr, sel) => arr.map(([v, l]) => '' + esc(l) + ' ').join('');
+ const provOpts = providers.map((p) => '' + esc(p.label || p.id) + ' ').join('');
+ const ctrl = btnCtrl;
+
+ root.innerHTML =
+ '' +
+ '' +
+ // Practice-aware library home: a repertoire progress meter + a
+ // "Keep practicing" shelf of started-but-not-mastered songs. Shown
+ // only on the grid view when not searching/filtering/selecting
+ // (renderLibraryHome + updateLibraryHome). Empty/absent → collapses.
+ '
' +
+ // Windowed grid: the sizer reserves the full-library scroll height;
+ // #v3-songs-grid is absolutely positioned inside it and holds only the
+ // visible window's cards (.v3-grid-window in v3.css).
+ '
' +
+ '
' +
+ '
' +
+ // Artist page host (PR-B) — an openAlbum-style in-place sub-render;
+ // populated + shown by openArtistPage, cleared on close/reload.
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ // A–Z jump rail (grid + alphabetical sorts only; populated by
+ // refreshRail). The bubble shows the current letter while dragging.
+ '
' +
+ '
' +
+ // Filter drawer + overlay
+ '
' +
+ '' +
+ '
';
+
+ // Wire toolbar.
+ const byId = (id) => document.getElementById(id);
+ byId('v3-songs-provider')?.addEventListener('change', async (e) => {
+ state.provider = e.target.value;
+ state.artist = '';
+ state.album = '';
+ try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ }
+ _updateMetaBtnVisibility(); // enrichment is local-only
+ await loadArtistCatalog();
+ refreshArtistAlbumSelects();
+ reload();
+ });
+ byId('v3-songs-artist')?.addEventListener('change', (e) => setArtist(e.target.value));
+ byId('v3-songs-album')?.addEventListener('change', (e) => setAlbum(e.target.value));
+ byId('v3-songs-sort').addEventListener('change', (e) => { state.sort = e.target.value; reload(); });
+ byId('v3-songs-format').addEventListener('change', async (e) => {
+ state.format = e.target.value;
+ await loadArtistCatalog();
+ refreshArtistAlbumSelects();
+ reload();
+ });
+ byId('v3-songs-filters').addEventListener('click', openDrawer);
+ byId('v3-songs-overlay').addEventListener('click', closeDrawer);
+ // Match-review chip: feature-detected (match-review.js owns the
+ // drawer + the count; absent → the chip just stays hidden).
+ byId('v3-songs-match-review')?.addEventListener('click', () => { if (window.__fbOpenMatchReview) window.__fbOpenMatchReview(); });
+ if (window.__fbMatchReviewChip) window.__fbMatchReviewChip();
+ byId('v3-songs-upload').addEventListener('click', () => {
+ const legacy = document.getElementById('upload-songs-file');
+ // Upload targets the LOCAL library + scan; watchUploadScan refreshes
+ // the grid for the local provider. Uploading while browsing a remote
+ // provider won't surface the new local songs — switching the grid to
+ // local on upload is a P23 remote-provider follow-up.
+ if (legacy) { legacy.click(); watchUploadScan(); }
+ });
+ byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));
+ byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary);
+ // Refresh Metadata: local-only, so hide it for remote providers. The
+ // button doubles as its own Stop while a pass runs (see onMetaBtnClick).
+ byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick);
+ byId('v3-songs-unmatched')?.addEventListener('click', toggleUnmatchedFilter);
+ _updateMetaBtnVisibility();
+ // Reflect a scan already in progress (Settings button or a background
+ // pass) on the Refresh button, so its state isn't just tied to clicks here.
+ (async () => {
+ try {
+ const r = await fetch('/api/scan-status');
+ const sd = r.ok ? await r.json() : null;
+ if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); }
+ } catch (e) { /* */ }
+ })();
+ // Reflect an enrichment pass already running (Settings "Match now" or a
+ // post-scan background pass) on the Metadata button + bar.
+ (async () => {
+ try {
+ const r = await fetch('/api/enrichment/status');
+ const es = r.ok ? await r.json() : null;
+ if (es && es.running) { _setMetaState(es); _watchEnrich({ announce: false }); }
+ } catch (e) { /* */ }
+ })();
+
+ // Capture-phase select-mode guard on each persistent list host. Without
+ // it, clicking a card/row (or its arrangement chip) in select mode falls
+ // through to the per-card play handler and starts playback instead of
+ // selecting ("checkbox click opens the song / access-denied"). The artist
+ // page renders the same [data-fn] song rows into its own host, so it
+ // needs the guard too — otherwise a row click there plays instead of
+ // toggling when select mode is already on.
+ bindSelectGuard(byId('v3-songs-grid'));
+ bindSelectGuard(byId('v3-songs-tree'));
+ bindSelectGuard(byId('v3-songs-artistpage'));
+ const setView = async (v) => {
+ state.view = v;
+ byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
+ byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
+ byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
+ { const ab = byId('v3-songs-albums-btn'); if (ab) ab.className = 'px-3 py-2 text-sm ' + (v === 'albums' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); }
+ if (v === 'folder') await _ensureFolderLibrary();
+ return reload();
+ };
+ byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid'));
+ byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree'));
+ byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder'));
+ byId('v3-songs-albums-btn')?.addEventListener('click', () => setView('albums'));
+ // Await the initial load so a caller awaiting render() (the scroll
+ // restore on screen re-entry) sees a populated grid + real state.total
+ // before it tries to page deeper.
+ await setView(state.view);
+ bindScroll();
+ bindGridResize();
+ positionToolbar();
+ bindToolbarReflow();
+ updateFilterBadge();
+ state.built = true;
+ }
+
+ async function onV3SongsScreenEnter() {
+ // A library scan / DLC-folder change marked the grid stale — re-fetch
+ // from scratch instead of restoring a cached (possibly empty, pre-DLC)
+ // snapshot. Must win over every fast-path below.
+ if (_libraryDirty) { _libraryDirty = false; await reload(); return; }
+ // Keep the entry-point gates current (a Settings visit may have
+ // toggled artist pages / external links). Fire-and-forget.
+ refreshArtistPageGates();
+ // An open artist sub-page survives a screen bounce as-is — its DOM is
+ // self-contained. A torn-down/hidden host means the state is stale;
+ // clear it and fall through to the normal restore paths.
+ if (state.artistPage) {
+ const ah = document.getElementById('v3-songs-artistpage');
+ if (ah && !ah.classList.contains('hidden') && ah.childElementCount) return;
+ state.artistPage = null;
+ state.artistReturnScroll = null;
+ }
+ // Pull in any scores recorded while the library was off-screen (the usual
+ // play→return flow) before the fast-paths below restore the cached DOM,
+ // so the just-played song's badge is current. The full render() path
+ // re-fetches accuracy itself, so this is a no-op cost there.
+ await applyScoreRefresh();
+ const snap = _readLibraryScrollSnapshot();
+ const hashMatch = !!(snap && snap.hash === _libraryStateHash());
+ const domReady = state.built && !!document.getElementById('v3-songs-grid');
+ const chromeOk = _chromeIntact();
+ const viewOk = state.view === (snap && snap.view ? snap.view : state.view);
+
+ if (snap && hashMatch && domReady && chromeOk && viewOk) {
+ if (state.view === 'grid' && _gridDomIntact()) {
+ // Geometry is stable (the sizer still holds the full height from
+ // the prior session), so restore is just: restore scrollTop, then
+ // repaint the window that maps to it. No more page-through.
+ document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', false);
+ document.getElementById('v3-songs-tree')?.classList.toggle('hidden', true);
+ syncChromeFromState();
+ updateLibraryHome(); // select-mode clear on leave re-shows the home block
+ _applyMainScrollTop(snap.scrollTop || 0);
+ requestWindowRender();
+ _clearLibraryScrollSnapshot();
+ return;
+ }
+ if (state.view === 'tree' && _treeDomIntact()) {
+ document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', true);
+ document.getElementById('v3-songs-tree')?.classList.toggle('hidden', false);
+ syncChromeFromState();
+ _applyMainScrollTop(snap.scrollTop || 0);
+ _clearLibraryScrollSnapshot();
+ return;
+ }
+ }
+
+ // Sidebar return without a player snapshot — keep grid, refresh chrome.
+ if (!snap && domReady && chromeOk && state.built) {
+ syncChromeFromState();
+ // If state drifted while we were away (notably an off-screen topbar
+ // search updating state.q), the persisted grid is stale — refetch
+ // instead of silently showing the old results. Unchanged state keeps
+ // the scroll-preserving no-op.
+ if (state.renderedHash !== _libraryStateHash()) { reload(); return; }
+ document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
+ document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
+ document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
+ { const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
+ updateLibraryHome(); // select-mode clear on leave re-shows the home block
+ // Re-render in case the viewport resized while we were away (column
+ // count / row height may have changed) or select mode was cleared.
+ if (state.view === 'grid') requestWindowRender();
+ return;
+ }
+
+ const snapToRestore = hashMatch ? snap : null;
+ if (snap && !hashMatch) _clearLibraryScrollSnapshot();
+ await render();
+ if (snapToRestore && snapToRestore.hash === _libraryStateHash()) {
+ // render() built + sized the sizer at scrollTop 0; move to the saved
+ // position and let the scroll handler repaint that window.
+ _applyMainScrollTop(snapToRestore.scrollTop || 0);
+ if (state.view === 'grid') requestWindowRender();
+ }
+ _clearLibraryScrollSnapshot();
+ }
+
+ // After an upload click-through (which reuses the legacy uploader +
+ // background scan), poll /api/scan-status and reload the v3 grid once the
+ // scan we triggered finishes — the legacy uploader only refreshes the
+ // legacy screens, so without this newly-uploaded songs wouldn't appear in
+ // v3 until a manual refresh. Bounded so a no-op upload can't poll forever.
+ let _uploadScanTimer = null;
+ // ── Library refresh (rescan) from the Songs toolbar ───────────────────────
+ // The tester ask: a media-server-style "I dropped files in my folder, hit
+ // refresh" button, with live progress. Reuses the SAME machinery the Settings
+ // Rescan buttons drive (/api/rescan + /api/scan-status) and emits
+ // library:changed so the grid reloads. A scan already running (Settings or a
+ // background pass) is reflected on the button too.
+ let _refreshPoll = null;
+ function _setRefreshState(sd) {
+ const btn = document.getElementById('v3-songs-refresh');
+ if (!btn) return;
+ if (sd && sd.running) {
+ // Determinate count only exists in the 'scanning' stage; 'listing' is
+ // indeterminate (total 0), so show a plain "Scanning…" then.
+ const det = (sd.stage === 'scanning' && sd.total) ? ' ' + sd.done + '/' + sd.total : '';
+ btn.textContent = '⟳ Scanning' + det + '…';
+ btn.disabled = true;
+ btn.classList.add('opacity-70');
+ const pct = sd.total ? Math.round((sd.done / sd.total) * 100) + '% · ' : '';
+ btn.title = 'Scanning new/changed songs… ' + pct + (sd.current || '');
+ } else {
+ btn.textContent = '⟳ Refresh';
+ btn.disabled = false;
+ btn.classList.remove('opacity-70');
+ btn.title = 'Refresh library (scan for new songs)';
+ }
+ }
+ // Show a completion toast (reuses the shared fbNotify surface), suppressed
+ // while in a song. Honest + never-punishing copy: shows the "N added / M
+ // removed" delta from the scan when there is one, else "up to date".
+ function _scanCompleteToast(sd) {
+ if (document.querySelector('.screen.active') && document.querySelector('.screen.active').id === 'player') return;
+ if (!window.fbNotify) return;
+ const added = (sd && sd.added) || 0, removed = (sd && sd.removed) || 0;
+ let msg;
+ if (sd && sd.error) msg = 'Scan finished with an error';
+ else if (added || removed) {
+ const parts = [];
+ if (added) parts.push(added + ' song' + (added === 1 ? '' : 's') + ' added');
+ if (removed) parts.push(removed + ' removed');
+ msg = parts.join(' · ');
+ } else msg = 'Your library is up to date';
+ try { window.fbNotify.show({ title: 'Library scan complete', message: msg, icon: '🔄', accent: '#22C55E' }); } catch (e) { /* */ }
+ }
+ // Poll scan-status until the scan finishes, driving the button state. On
+ // completion, emit library:changed (grid reloads via the listener below) and,
+ // for a user-initiated refresh (announce), show the toast. announce:false is
+ // used when we only attached to a scan we didn't start.
+ function _watchScan(opts) {
+ if (_refreshPoll) return;
+ const announce = !opts || opts.announce !== false;
+ let sawRunning = false, ticks = 0;
+ _refreshPoll = setInterval(async () => {
+ ticks++;
+ let sd = null;
+ try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ }
+ _setRefreshState(sd);
+ if (sd && sd.running) sawRunning = true;
+ // A user-initiated refresh that never saw a running scan = nothing to
+ // do (already up to date); give prompt feedback instead of waiting.
+ const noopDone = announce && !sawRunning && ticks >= 3;
+ if ((sawRunning && sd && !sd.running) || noopDone || ticks >= 180) {
+ clearInterval(_refreshPoll); _refreshPoll = null;
+ _setRefreshState(null);
+ const hasDelta = sd && (((sd.added || 0) > 0) || ((sd.removed || 0) > 0));
+ if (sawRunning && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'rescan', added: (sd && sd.added) || 0, removed: (sd && sd.removed) || 0 }); } catch (e) { /* */ } }
+ // A user-initiated refresh always confirms; a scan we only attached
+ // to (background / Settings) toasts just when it changed something,
+ // so a periodic no-op pass stays silent.
+ if (announce || hasDelta) _scanCompleteToast(sd);
+ }
+ }, 1000);
+ }
+ async function refreshLibrary() {
+ if (_refreshPoll) return; // a scan is already in progress
+ try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* */ }
+ _watchScan({ announce: true });
+ }
+
+ function watchUploadScan() {
+ if (_uploadScanTimer) clearInterval(_uploadScanTimer);
+ let sawRunning = false, ticks = 0;
+ _uploadScanTimer = setInterval(async () => {
+ ticks++;
+ let sd = null;
+ try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ }
+ if (sd && sd.running) sawRunning = true;
+ if ((sawRunning && sd && !sd.running) || ticks >= 90) {
+ clearInterval(_uploadScanTimer); _uploadScanTimer = null;
+ if (sawRunning) reload();
+ }
+ }, 1000);
+ }
+
+ // ── Refresh Metadata (batch enrichment) from the Songs toolbar ─────────────
+ // The metadata counterpart to ⟳ Refresh (which scans FILES): matches
+ // titles/artist/album/artwork against MusicBrainz for the songs that still
+ // need it — the ambient background matcher, run on demand (a media-server's
+ // "Refresh Metadata" vs "Scan Files"). Mirrors the scan machinery: a 1 Hz
+ // poll of /api/enrichment/status drives the button + batch bar, while
+ // /api/enrichment/states drives per-tile badges on the visible window.
+ // Enrichment is local-only, so the button hides for remote providers.
+ let _metaPoll = null;
+ let _metaRunning = false;
+
+ function _updateMetaBtnVisibility() {
+ const local = state.provider === 'local'; // enrichment + its filter are local-only
+ const btn = document.getElementById('v3-songs-refresh-meta');
+ if (btn) btn.style.display = local ? '' : 'none';
+ const um = document.getElementById('v3-songs-unmatched');
+ if (um) um.style.display = local ? '' : 'none';
+ }
+
+ // Quick "Show unmatched" — the same filter as the drawer's Match → Unmatched,
+ // one click from the toolbar so the no-match pile is reachable right after a
+ // batch. Toggles the button + re-queries the grid.
+ function toggleUnmatchedFilter() {
+ const m = state.filters.match || (state.filters.match = []);
+ const i = m.indexOf('unmatched');
+ const on = i < 0;
+ if (on) m.push('unmatched'); else m.splice(i, 1);
+ const btn = document.getElementById('v3-songs-unmatched');
+ if (btn) { btn.classList.toggle('bg-fb-primary', on); btn.classList.toggle('text-white', on); }
+ reload();
+ }
+
+ // The local filenames the grid is currently SHOWING (data-fn is the local
+ // filename the enrichment cache keys on). The grid is windowed, so this is
+ // the visible slice only — exactly what the per-tile poll should cover.
+ function _visibleLocalFilenames() {
+ const grid = document.getElementById('v3-songs-grid');
+ if (!grid) return [];
+ return [...grid.querySelectorAll('[data-fn]')]
+ .map((el) => el.getAttribute('data-fn')).filter(Boolean);
+ }
+
+ // Set/clear one card's live badge (recycled cards re-derive from _metaTile on
+ // the next paint, so update the map too — mirrors _patchCardFav).
+ function _patchCardEnrich(fn, st) {
+ if (st) _metaTile[fn] = st; else delete _metaTile[fn];
+ const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
+ document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => {
+ const el = play.querySelector('.v3-meta-tile');
+ const html = enrichBadge(fn);
+ if (!html) { if (el) el.remove(); return; }
+ if (el) el.outerHTML = html; else play.insertAdjacentHTML('beforeend', html);
+ });
+ }
+
+ function _clearMetaTiles() {
+ Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; });
+ document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove());
+ // The persistent "No match" badge derives from _unmatched (not _metaTile),
+ // yet shares the .v3-meta-tile class — so the blanket remove above strips it.
+ // Repaint the resting indicator on any rendered card so a metadata rescan's
+ // tile-clear doesn't silently drop it until the next scroll/re-render.
+ _unmatched.forEach((fn) => {
+ const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
+ document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => {
+ if (play.querySelector('.v3-meta-tile')) return;
+ const html = enrichBadge(fn);
+ if (html) play.insertAdjacentHTML('beforeend', html);
+ });
+ });
+ }
+
+
+ // Drive the button (which doubles as Stop) + the batch bar from a status body.
+ function _setMetaState(es) {
+ const btn = document.getElementById('v3-songs-refresh-meta');
+ const prog = document.getElementById('v3-meta-progress');
+ const fill = document.getElementById('v3-meta-progress-fill');
+ const label = document.getElementById('v3-meta-progress-label');
+ if (!btn) return;
+ const running = !!(es && es.running);
+ _metaRunning = running;
+ if (running) {
+ const total = (es && es.total) || 0, done = (es && es.matched) || 0;
+ const cancelling = !!(es && es.cancelling);
+ btn.textContent = cancelling ? 'Stopping…' : ('⏹ Stop' + (total ? ' · ' + done + '/' + total : ''));
+ btn.disabled = cancelling;
+ btn.classList.toggle('opacity-70', cancelling);
+ btn.title = cancelling ? 'Stopping after the current song…' : 'Stop refreshing metadata';
+ if (prog) {
+ prog.classList.remove('hidden'); prog.classList.add('flex');
+ if (label) label.textContent = total ? ('Matching metadata ' + done + '/' + total) : 'Matching metadata…';
+ // Real songs-processed ratio; a tiny sliver while the queue size
+ // is still being computed (phase 1) so the bar isn't dead-empty.
+ if (fill) fill.style.width = (total ? Math.round((done / total) * 100) : 6) + '%';
+ }
+ } else {
+ btn.textContent = '🏷 Metadata';
+ btn.disabled = false;
+ btn.classList.remove('opacity-70');
+ btn.title = 'Refresh metadata for the songs shown (re-match titles, artwork & more)';
+ if (prog) { prog.classList.add('hidden'); prog.classList.remove('flex'); }
+ }
+ }
+
+ // Completion toast — reuse the shared fbNotify surface (visual-only, so
+ // hearing-safe for free). Honest + never-punishing copy, in-game suppressed.
+ function _metaCompleteToast(es) {
+ const active = document.querySelector('.screen.active');
+ if (active && active.id === 'player') return;
+ if (!window.fbNotify) return;
+ const matched = (es && es.matched) || 0;
+ const msg = matched
+ ? (matched + ' song' + (matched === 1 ? '' : 's') + ' matched')
+ : 'Your library metadata is up to date';
+ try { window.fbNotify.show({ title: 'Metadata refresh complete', message: msg, icon: '🏷️', accent: '#22C55E' }); } catch (e) { /* */ }
+ }
+
+ // Poll enrichment status (button + bar) AND the visible window's per-song
+ // states (tile badges) until the pass finishes. announce:false = we only
+ // attached to a pass we didn't start (no toast unless it actually changed
+ // something).
+ function _watchEnrich(opts) {
+ if (_metaPoll) return;
+ const announce = !opts || opts.announce !== false;
+ let sawRunning = false, ticks = 0, lastStatus = null;
+ _metaPoll = setInterval(async () => {
+ ticks++;
+ let es = null;
+ try { const r = await fetch('/api/enrichment/status'); if (r.ok) es = await r.json(); } catch (e) { /* */ }
+ if (es) { lastStatus = es; _setMetaState(es); if (es.running) sawRunning = true; }
+ // Per-tile badges: only songs we're tracking (seeded 'queued'). A
+ // tile flips to 'working' when it's the current song, then to
+ // 'done' (matched) / 'nochange' (failed) once it leaves unscanned.
+ if (Object.keys(_metaTile).length) {
+ const fns = _visibleLocalFilenames();
+ try {
+ const r = await fetch('/api/enrichment/states', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ filenames: fns }),
+ });
+ if (r.ok) {
+ const j = await r.json();
+ const states = j.states || {}, current = j.current;
+ fns.forEach((fn) => {
+ if (!(fn in _metaTile)) return;
+ if (fn === current) { _patchCardEnrich(fn, 'working'); return; }
+ const s = states[fn];
+ if (s && s !== 'unscanned' && s !== 'pending') {
+ _patchCardEnrich(fn, s === 'failed' ? 'nochange' : 'done');
+ }
+ });
+ }
+ } catch (e) { /* */ }
+ }
+ // Cap at 20 min (a ~1000-song trickle at ≤1/s is ~17 min); a
+ // user-initiated no-op that never saw a running pass ends quickly.
+ const noopDone = announce && !sawRunning && ticks >= 3;
+ if ((sawRunning && es && !es.running) || noopDone || ticks >= 1200) {
+ clearInterval(_metaPoll); _metaPoll = null;
+ _setMetaState(null);
+ const changed = sawRunning && lastStatus && (lastStatus.matched || 0) > 0;
+ if (announce || changed) _metaCompleteToast(lastStatus);
+ // Let the final 'done' badges register, then clear + (if anything
+ // matched) reload so new canonical titles/art show.
+ setTimeout(() => {
+ _clearMetaTiles();
+ if (changed && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'enrich', matched: lastStatus.matched }); } catch (e) { /* */ } }
+ }, 1600);
+ }
+ }, 1000);
+ }
+
+ // Force a fresh re-match of the songs currently SHOWN (the visible grid
+ // window) — a media-server-style per-view "Refresh Metadata". Resets those
+ // songs and re-fetches, so it's visible even on an already-matched library.
+ // Manual pins are skipped server-side; scoped to the visible set so it's
+ // fast + can't blow the whole rate budget.
+ async function refreshMetadata() {
+ if (_metaRunning || _metaPoll) return; // already running
+ const fns = _visibleLocalFilenames();
+ _clearMetaTiles();
+ if (!fns.length) { _metaCompleteToast({ matched: 0 }); return; }
+ let queued = [];
+ try {
+ const r = await fetch('/api/enrichment/rematch', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ filenames: fns }),
+ });
+ if (r.ok) queued = (await r.json()).queued || [];
+ } catch (e) { /* offline → nothing queued */ }
+ // Badge exactly what the server queued (everything visible except your
+ // manual pins). Nothing queued = all visible songs are pinned/unknown.
+ queued.forEach((fn) => _patchCardEnrich(fn, 'queued'));
+ if (!queued.length) { _metaCompleteToast({ matched: 0 }); return; }
+ _watchEnrich({ announce: true });
+ }
+
+ async function stopMetadata() {
+ try { await fetch('/api/enrichment/cancel', { method: 'POST' }); } catch (e) { /* */ }
+ _setMetaState({ running: true, cancelling: true }); // optimistic; the poll confirms
+ }
+
+ // The Metadata button toggles role: kick a refresh when idle, Stop when a
+ // pass is running.
+ function onMetaBtnClick() {
+ if (_metaRunning) stopMetadata(); else refreshMetadata();
+ }
+
+ // Topbar search drives this screen.
+ async function search(q) {
+ state.q = q || '';
+ if (songsActive()) {
+ await loadArtistCatalog();
+ refreshArtistAlbumSelects();
+ reload();
+ } else if (window.showScreen) {
+ window.showScreen('v3-songs');
+ }
+ }
+
+ window.v3Songs = {
+ render: render,
+ reload: reload,
+ search: search,
+ setQuery: (q) => { state.q = q || ''; },
+ getSort: () => state.sort,
+ getArtist: () => state.artist,
+ getAlbum: () => state.album,
+ // The grid is windowed: only a slice of cards is in the DOM at any time.
+ // A plugin that decorates cards should read THIS (not a global
+ // querySelectorAll that assumes every card is present) and re-run on each
+ // `v3:library-window-rendered` event rather than once at load.
+ visibleCards: () => document.querySelectorAll('#v3-songs-grid [data-fn]'),
+ filterParams: () => {
+ const f = state.filters;
+ const p = new URLSearchParams();
+ if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(','));
+ if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(','));
+ if (f.stem_has.length) p.set('stems_has', f.stem_has.join(','));
+ if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
+ if (f.lyrics) p.set('has_lyrics', f.lyrics);
+ if (f.tunings.length) p.set('tunings', f.tunings.join(','));
+ return p.toString();
+ },
+ _scrollHelpers: {
+ SCROLL_STATE_KEY,
+ buildLibraryStateHash,
+ readSnapshot: _readLibraryScrollSnapshot,
+ clearSnapshot: _clearLibraryScrollSnapshot,
+ },
+ };
+
+ if (sm && typeof sm.on === 'function') {
+ sm.on('screen:changed', (e) => {
+ const id = e && e.detail && e.detail.id;
+ if (id === 'v3-songs') { onV3SongsScreenEnter(); return; }
+ // Leaving Songs: tear down select mode + the body-mounted batch bar,
+ // so an active multi-selection doesn't leave a floating bar (and
+ // stale selection) visible on unrelated screens.
+ if (state.selectMode || state.selected.size) {
+ state.selectMode = false;
+ state.selected.clear();
+ const bar = document.getElementById('v3-songs-batch');
+ if (bar) bar.remove();
+ }
+ });
+ // stats-recorder POSTs the score asynchronously and emits this once the
+ // server has the new best — that's the correct moment to refresh the
+ // badge (song:stop fires before the POST resolves, so it's too early).
+ // If the library is visible right now, repaint immediately; otherwise
+ // mark it dirty and onV3SongsScreenEnter applies it on return.
+ sm.on('stats:recorded', (e) => {
+ // Decode to the library-card key space — the event carries the
+ // encodeURIComponent'd filename, but cards (data-fn) and
+ // state.accuracy key on the decoded library filename. Without this
+ // the repaint below (and applyScoreRefresh's repaintAccuracy) match
+ // no card, so a just-earned score stays invisible until a full
+ // render() (restart / search / re-enter), which is this bug.
+ const fn = decFn(e && e.detail && e.detail.filename);
+ if (!fn) return;
+ _dirtyScores.add(fn);
+ // Only repaint now if the library is the active screen; otherwise
+ // leave it dirty for onV3SongsScreenEnter (applyScoreRefresh clears
+ // the set, so repainting against a hidden grid would drop the update).
+ const active = document.querySelector('.screen.active');
+ if (active && active.id === 'v3-songs') applyScoreRefresh();
+ });
+ // A library scan (rescan / full rescan from Settings, or a DLC-folder
+ // change) can add or remove songs while this grid is cached — the
+ // Settings rescan only refreshed the classic library, so the v3 grid
+ // stayed on its pre-scan (e.g. empty, pre-DLC) state until an app
+ // restart. Reload now if we're showing; otherwise mark dirty so the next
+ // entry re-fetches instead of restoring the stale snapshot.
+ sm.on('library:changed', () => {
+ const active = document.querySelector('.screen.active');
+ if (active && active.id === 'v3-songs') { _libraryDirty = false; reload(); }
+ else _libraryDirty = true;
+ });
+ // Your live tuning changed (retune / instrument swap / reset) → re-colour the
+ // visible tuning chips against the new tuning. Cheap: re-decorates in place,
+ // no re-fetch or re-paint. No-op off the Songs grid or without the capability.
+ sm.on('working-tuning-changed', () => {
+ // A guitar<->bass SWITCH changes which tuning the facet, the filter,
+ // the sort and the card chip speak for, so the grid must re-query —
+ // re-colouring chips would leave the guitar tuning on screen and a
+ // guitar-keyed filter applied. A retune within one instrument still
+ // takes the cheap in-place path below.
+ const inst = libInstrument();
+ if (inst !== _lastRenderInstrument) {
+ _lastRenderInstrument = inst;
+ // A tuning selection keyed to the old instrument means nothing
+ // for the new one; clearing avoids an empty grid the user can't
+ // explain (the pills are re-rendered from the new facet).
+ state.filters.tunings = [];
+ const active = document.querySelector('.screen.active');
+ if (active && active.id === 'v3-songs') reload();
+ else _libraryDirty = true;
+ return;
+ }
+ if (typeof songsActive === 'function' && !songsActive()) return;
+ if (state.view !== 'grid') return;
+ decorateTuningChips(_gridEl());
+ });
+ }
+})();