mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-28 15:42:35 +00:00
* feat(input): add gamepad/controller support
Adds full gamepad/controller navigation and playback control, driven
by requests from players who use fee[dB]ack on a TV/console setup and
from wheelchair users for whom a controller is far more convenient
than a keyboard + mouse. Confirmed working end-to-end on a Steam Deck
across several rounds of on-device testing.
- static/v3/gamepad.js: polls navigator.getGamepads() and dispatches
synthetic keydown events (Arrow/Enter/Space/Escape) on the focused
element (falling back to document), reusing the app's existing
keyboard pipeline (static/js/shortcuts.js's scope-aware dispatcher,
player shortcuts, text-field/modal guards) instead of a parallel
action-mapping table. Only acts on gamepads reporting the W3C
"standard" mapping — which is what Steam Input presents for the
Deck's built-in controls, both in Gaming Mode and in Desktop Mode
via a non-Steam shortcut — so button order is guaranteed correct
and a non-standard/raw device safely no-ops instead of misfiring.
Handles Steam Input's virtual-pad duplicates (a real controller
plus 1-2 mirrored XInput slots) without spamming connect toasts or
losing input when the live pad isn't at index 0. Xbox-style face
button mapping: bottom face = Space (play/pause, and activates the
focused control), right face = Escape (back), top face reveals the
player screen's tool rail (focuses it into visibility via the
existing CSS :focus-within rule). D-pad/stick repeat while held,
mirroring OS keyboard auto-repeat.
- static/v3/gamepad-nav.js: fills the one real gap in that reuse
strategy — no screen but the song library grid had any arrow-key
navigation, and Chromium doesn't run native Enter/Space button
activation for untrusted synthetic events even when dispatched at
the focused element. Gated entirely on `!e.isTrusted`, so it only
ever reacts to gamepad-originated events and never touches real
keyboard/mouse users: emulates Tab-order (the sidebar + active
screen's real, already-focusable buttons/links) for Arrow keys,
explicitly .click()s the focused element for Enter/Space, and gives
Escape a consistent "go back" behavior — an existing in-screen back
button if one's visible (reusing each screen's own drill-down logic
for free), else the main menu. Every branch defers via
`e.defaultPrevented` to any screen that already handles the key
itself (the song grid, the player, settings), so nothing here
overrides existing behavior.
- static/v3/songs.js: adds real 2D d-pad/arrow-key navigation to the
song library's virtualized grid (only a slice of the library is
ever in the DOM), including fetching/scrolling off-screen rows into
view and correcting for the sticky filter toolbar's occlusion.
- static/v3/index.html: wires up the two new scripts.
* chore: regenerate stale tailwind.min.css
Rebuilt in a fresh clone (not the local working copy). Several plugin
directories (audio_engine, plugin_manager, community_charts, etc.) are
gitignored locally but present on disk from checking out plugin repos
for local dev/testing — Tailwind's content scan picks them up
regardless, so a rebuild against the contaminated local working copy
bakes in extra utility classes that don't belong in the real,
git-tracked build. A clean checkout reproduces CI's expected output
exactly.
* fix(gamepad): check all matching back buttons, not just the first
document.querySelector on the combined [data-ap-back], [data-albums-back],
#v3-pl-back selector only ever inspects the first match in DOM order —
since screens stay in the DOM (hidden, not removed) when you navigate
away, a hidden back button from an unrelated screen could sort before
the one that's actually visible, incorrectly falling through to
showScreen('v3-home') instead of clicking it. Uses querySelectorAll +
find(visible) instead.
* fix(gamepad): address CodeRabbit findings on connect/disconnect and grid nav
- gamepad.js: anyLiveConnectedPad -> anyLiveStandardPad, filtering by
mapping === 'standard' like firstLiveStandardPad already does, and
applied at the top of the gamepadconnected handler too. A still-
connected non-standard raw mirror could otherwise mask the real
pad's disconnect (toast never fires, polling never stops).
- songs.js _gpMove: an unset cursor now always seeds at index 0
before the first press, instead of applying that press's delta
immediately (ArrowDown/Right previously skipped straight past row
0; Left/Up only looked right by accident of clamping). Matches the
existing convention in shortcuts.js's legacy _handleLibArrowNav.
- songs.js _gpBlockedTarget: form-control/button blocking now
requires the element to be visible (offsetParent !== null), not
just present. Screens stay in the DOM hidden (not removed) when you
navigate away, so a real button focused on some other now-hidden
screen could leave document.activeElement pointing at it and block
all grid navigation indefinitely. (An el.closest('#v3-songs') scope
was tried first and reverted — it fixed that case but broke
blocking for the topbar search input, which lives outside
#v3-songs's DOM subtree even while v3-songs is active; visibility
is the distinction that actually matters, not DOM nesting.)
Skipped two CodeRabbit suggestions, verified against current code:
gating songs.js's grid keydown listener to synthetic-only events
would regress the real keyboard accessibility this PR intentionally
added (v3-songs' grid had none before); renaming the _gp* helpers to
drop their underscore prefix would break from this codebase's own
established module-private naming convention.
Verified in-browser: first arrow press lands on index 0, stale hidden
focus no longer blocks grid nav, the topbar search input still
correctly blocks it, and normal nav resumes after blur.
* test(gamepad): unit-cover the controller + nav state machines
- gamepad.test.js (10): standard-mapping filter, Steam Input duplicate-slot
dedup, disconnect masking, button edge-detection, d-pad/stick repeat timing,
analog deadzone — driven via a fake navigator + manual rAF queue.
- gamepad_nav.test.js (10): !isTrusted/defaultPrevented gating, arrow focus
traversal + clamping, hidden-element skipping, Enter/Space click activation
(not into text fields/body), Escape visible-back-button vs home fallback.
songs.js grid nav is left to on-device coverage (async + windowed-DOM heavy).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
---------
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4456 lines
280 KiB
JavaScript
4456 lines
280 KiB
JavaScript
/*
|
||
* 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 = ['<option value="">All artists</option>']
|
||
.concat((state.artistCatalog || []).map((a) =>
|
||
'<option value="' + esc(a.name) + '"' + (a.name === state.artist ? ' selected' : '') + '>' + esc(a.name) + '</option>'));
|
||
return opts.join('');
|
||
}
|
||
|
||
function albumSelectHtml() {
|
||
if (!state.artist) {
|
||
return '<option value="">Choose artist first</option>';
|
||
}
|
||
const albums = albumsForArtist(state.artist);
|
||
const opts = ['<option value="">All albums</option>']
|
||
.concat(albums.map((n) =>
|
||
'<option value="' + esc(n) + '"' + (n === state.album ? ' selected' : '') + '>' + esc(n) + '</option>'));
|
||
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 '<span class="fb-acc-badge text-xs font-bold ' + color + '">' + pct + '%</span>';
|
||
}
|
||
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 '<span class="fb-acc-badge absolute bottom-0 right-0 ' + color + '/90 ' + text + ' px-2 py-0.5 rounded-tl-md text-xs font-bold flex items-center gap-1">' +
|
||
'<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' + pct + '%</span>';
|
||
}
|
||
|
||
// ── 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 '<span class="' + cls + '"' +
|
||
(fixable ? ' data-meta-fix="1"' : '') +
|
||
(conf[2] ? ' title="' + conf[2] + '"' : '') +
|
||
'>' + conf[1] + '</span>';
|
||
}
|
||
|
||
// 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 =
|
||
'<div class="v3-rep-meter">' +
|
||
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
|
||
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
|
||
'<span class="text-xs text-fb-textDim">grows as you master songs</span>' +
|
||
'</div>' +
|
||
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:0%"></div></div>' +
|
||
'</div>';
|
||
} else {
|
||
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
|
||
meter =
|
||
'<div class="v3-rep-meter">' +
|
||
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
|
||
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
|
||
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
|
||
(learning ? ' · ' + learning + ' in progress' : '') + '</span>' +
|
||
'</div>' +
|
||
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
let shelfHtml = '';
|
||
if (shelf.length) {
|
||
const cards = shelf.map((r) =>
|
||
'<button class="v3-kp-card group text-left" data-kp="' + esc(r.filename) + '" data-arr="' + esc(r.arrangement != null ? r.arrangement : '') + '" title="' + esc(r.title) + '">' +
|
||
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card">' +
|
||
'<img src="' + esc(r.art_url) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
||
accuracyBadge(r.filename) +
|
||
'</div>' +
|
||
'<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' +
|
||
'<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div>' +
|
||
'</button>').join('');
|
||
// Starter rows → the invitational "Start here" framing; real
|
||
// growth-edge rows → the usual "Keep practicing". Same cards.
|
||
const header = starterShelf
|
||
? '<h3 class="text-sm font-semibold text-fb-text">Start here</h3>' +
|
||
'<div class="text-xs text-fb-textDim mb-2">a few approachable songs to kick things off</div>'
|
||
: '<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>';
|
||
shelfHtml =
|
||
'<section class="v3-kp-shelf mt-4">' + header +
|
||
'<div class="v3-kp-row">' + cards + '</div>' +
|
||
'</section>';
|
||
}
|
||
|
||
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 '<span class="absolute bottom-0 left-0 ' + c + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
|
||
}
|
||
|
||
// 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 = '<div class="absolute top-2 right-2 flex gap-1 opacity-100 group-hover:opacity-0 transition pointer-events-none">';
|
||
if (d != null) out += '<span class="bg-black/60 text-white text-[0.625rem] font-bold px-1.5 py-0.5 rounded" title="Your difficulty: ' + esc(DIFF_LABELS[d] || d) + '">◆' + esc(d) + '</span>';
|
||
if (tags.length) out += '<span class="bg-black/60 text-white text-[0.625rem] font-bold px-1.5 py-0.5 rounded" title="Tags: ' + esc(tags.join(', ')) + '">🏷' + tags.length + '</span>';
|
||
return out + '</div>';
|
||
}
|
||
|
||
// Clickable arrangement chips — one <button data-arr="<index>"> 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) =>
|
||
'<button data-arr="' + esc(a.index != null ? a.index : '') + '" title="Play ' + esc(a.name) + '" class="text-[0.625rem] px-1.5 py-0.5 rounded bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(a.name) + '</button>').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 '<button data-charts="' + esc(song.work_key) + '" title="' + n + ' charts of this song" aria-label="' + n + ' charts of this song" class="shrink-0 text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-primary/15 text-fb-primary border border-fb-primary/40 hover:bg-fb-primary hover:text-white transition">⚑ ' + n + ' charts</button>';
|
||
}
|
||
|
||
// ── 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 = '<span class="' + pos + ' bg-fb-mid text-black text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight max-w-[5.5rem] text-center"' + matchAttr + ' title="' + esc(badgeTitle) + '">'
|
||
+ esc('Custom Tuning') + '<br><span class="font-semibold tracking-wide">' + esc(targetNotes) + '</span></span>';
|
||
} else {
|
||
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[0.625rem] font-bold px-1.5 py-0.5 rounded-sm"' + matchAttr + ' title="' + esc(badgeTitle) + '">' + esc(tuningLabel) + (inferred ? '<span class="opacity-60"> ~</span>' : '') + '</span>';
|
||
}
|
||
}
|
||
// 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
|
||
? '<input type="checkbox" data-select class="absolute top-2 left-2 z-20 w-5 h-5 accent-fb-primary pointer-events-none"' + (state.selected.has(key) ? ' checked' : '') + '>'
|
||
: '';
|
||
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) =>
|
||
'<button data-act-card="' + esc(a.id) + '" title="' + esc(a.label || a.id) + '" aria-label="' + esc(a.label || a.id) + '"' +
|
||
(a.enabled === false ? ' disabled' : '') +
|
||
' class="px-2 h-7 min-w-[1.75rem] rounded-full bg-black/55 hover:bg-black/75 flex items-center justify-center text-xs leading-none ' +
|
||
(a.enabled === false ? 'opacity-40 cursor-not-allowed ' : '') +
|
||
(a.destructive ? 'text-fb-accent' : 'text-white') + '">' + esc(a.icon || a.label || '•') + '</button>';
|
||
const inlineBtns = acts.filter((a) => a.placement === 'inline').map(actBtn).join('');
|
||
const overlayActs = acts.filter((a) => a.placement === 'overlay');
|
||
const overlay = overlayActs.length
|
||
? '<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition pointer-events-none"><div class="flex flex-wrap gap-1 justify-center max-w-[90%] pointer-events-auto">' + overlayActs.map(actBtn).join('') + '</div></div>'
|
||
: '';
|
||
// 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 '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
||
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
|
||
'<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
||
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key, song.unmatched) + overlay +
|
||
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
|
||
inlineBtns +
|
||
'<button data-fav data-fav-idle="text-white" title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
|
||
'<button data-save title="Save for later" aria-label="Save for later" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm">🔖</button>' +
|
||
'<button data-menu title="More" aria-label="More actions" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm leading-none">⋮</button>' +
|
||
'</div></div>' +
|
||
'<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(shown.title) + '">' + esc(shown.title) + '</div>' +
|
||
// 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)
|
||
? '<button data-v3-artist class="block w-full text-left text-xs text-fb-textDim truncate hover:text-fb-primary transition" title="Go to ' + esc(song.artist) + '">' + esc(song.artist) + '</button>'
|
||
: '<div class="text-xs text-fb-textDim truncate">' + esc(song.artist) + '</div>') +
|
||
// 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).
|
||
'<div class="v3-card-chips flex gap-1 mt-1">' + chartsChip + arrChips + '</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// 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) =>
|
||
'<button data-act="' + esc(r.id) + '" class="w-full text-left px-3 py-1.5 hover:bg-fb-card/60 ' +
|
||
(r.enabled === false ? 'opacity-40 cursor-not-allowed ' : '') +
|
||
(r.destructive ? 'text-fb-accent' : 'text-fb-text') + '">' + esc(r.label) +
|
||
(r.plugin && r.plugin !== 'core' ? '<span class="text-[0.625rem] text-fb-textDim ml-1">' + esc(r.plugin) + '</span>' : '') + '</button>').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 '<button data-ver="' + esc(c.filename) + '" title="' + esc(c.filename) + '" class="w-full text-left px-3 py-1.5 hover:bg-fb-card/60 text-fb-text">' +
|
||
(c.is_representative ? '<span class="text-fb-primary">●</span> ' : '') + esc(c.title) +
|
||
(tl ? '<span class="text-[0.625rem] text-fb-textDim ml-1">' + esc(tl) + '</span>' : '') +
|
||
'</button>';
|
||
}).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?' +
|
||
'<p class="text-xs text-red-400/90 mt-2">This permanently deletes the file' + (n === 1 ? '' : 's') + ' from disk. This cannot be undone.</p>',
|
||
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 '<label class="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-fb-card/50 cursor-pointer">' +
|
||
'<input type="checkbox" data-rm="' + esc(c.filename) + '"' + (sel.has(c.filename) ? ' checked' : '') + ' class="w-4 h-4 mt-0.5 accent-fb-primary shrink-0">' +
|
||
'<span class="min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(c.title) +
|
||
(c.is_representative ? ' <span class="text-fb-primary">●</span>' : '') + '</span>' +
|
||
(meta ? '<span class="block text-xs text-fb-textDim truncate">' + esc(meta) + '</span>' : '') +
|
||
'<span class="block text-xs text-fb-textDim/70 truncate fb-selectable">' + esc(c.filename) + '</span></span></label>';
|
||
}).join('');
|
||
const n = sel.size;
|
||
overlay.innerHTML =
|
||
'<div class="bg-fb-sidebar border border-fb-border/60 rounded-2xl w-full max-w-md shadow-2xl max-h-[85vh] flex flex-col">' +
|
||
'<div class="p-5 pb-3"><h3 class="text-base font-semibold text-fb-text">Remove versions of “' + esc(song.title || '') + '”</h3>' +
|
||
'<p class="text-xs text-fb-textDim mt-1">This song has ' + charts.length + ' charts. Tick the ones to remove — files are deleted from disk and this cannot be undone.</p></div>' +
|
||
'<div class="px-3 overflow-y-auto v3-scroll flex-1 min-h-[6rem]">' + rows + '</div>' +
|
||
'<div class="p-5 pt-3 flex items-center justify-between gap-3">' +
|
||
'<button data-rm-cancel class="text-sm px-4 py-2 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-xl text-fb-text">Cancel</button>' +
|
||
'<button data-rm-go ' + (n ? '' : 'disabled') + ' class="text-sm px-4 py-2 rounded-xl ' + (n ? 'bg-red-900/60 hover:bg-red-900/80 text-red-100' : 'bg-fb-card/50 text-fb-textDim cursor-not-allowed') + '">Remove selected (' + n + ')</button>' +
|
||
'</div></div>';
|
||
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
|
||
? '<div class="flex gap-3 py-1"><span class="text-xs text-fb-textDim w-24 shrink-0 pt-0.5">' + label + '</span>' +
|
||
'<span class="text-sm text-fb-text min-w-0 break-all' + (selectable ? ' fb-selectable' : '') + '">' + esc(value) + '</span></div>'
|
||
: '';
|
||
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 =
|
||
'<div class="bg-fb-sidebar border border-fb-border/60 rounded-2xl w-full max-w-lg shadow-2xl max-h-[85vh] flex flex-col">' +
|
||
'<div class="p-5 pb-3 flex items-center justify-between gap-3">' +
|
||
'<h3 class="text-base font-semibold text-fb-text truncate">' + esc(song.title || info.filename) + '</h3>' +
|
||
'<button data-gi-x aria-label="Close" class="text-fb-textDim hover:text-fb-text text-xl leading-none shrink-0">✕</button></div>' +
|
||
'<div class="px-5 pb-5 overflow-y-auto v3-scroll space-y-1">' +
|
||
row('Location', info.path, true) +
|
||
row('Folder', info.folder, true) +
|
||
row('Format', info.format === 'sloppak' ? 'Feedpak' : info.format) +
|
||
row('Size', _fmtBytes(info.size)) +
|
||
row('Modified', info.mtime ? new Date(info.mtime * 1000).toLocaleString() : '') +
|
||
(info.manifest ? (
|
||
'<div class="pt-2 mt-2 border-t border-fb-border/50"></div>' +
|
||
row('Contents', contents) +
|
||
row('Authors', (m.authors || []).filter(Boolean).join(', ')) +
|
||
row('Identity', identLine || 'no identity keys authored', !!identLine)
|
||
) : '') +
|
||
'<div class="pt-2 mt-2 border-t border-fb-border/50"></div>' +
|
||
row('Match', matchLine) +
|
||
(match.canon_artist ? row('Canonical', [match.canon_artist, match.canon_title, match.canon_album, match.canon_year].filter(Boolean).join(' — '), true) : '') +
|
||
'</div></div>';
|
||
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')
|
||
? '<span class="font-bold ' + (c.best_accuracy >= MASTERY_ACCURACY ? 'text-fb-good' : c.best_accuracy >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.floor(c.best_accuracy * 100) + '%</span>'
|
||
: '<span class="text-fb-textDim/60">not played</span>';
|
||
return '<div role="radio" aria-checked="' + (checked ? 'true' : 'false') + '" tabindex="0" data-ch="' + esc(c.filename) + '"' +
|
||
' title="' + (checked ? esc(prefLabel) : 'Make this the preferred chart') + '"' +
|
||
' class="border rounded-lg p-3 cursor-pointer transition ' + (checked ? 'border-fb-primary/60 bg-fb-primary/5' : 'border-fb-border/40 hover:border-fb-border') + '">' +
|
||
'<div class="flex items-start justify-between gap-2">' +
|
||
'<div class="min-w-0">' +
|
||
'<div class="text-sm text-fb-text truncate" title="' + esc(c.title) + '">' + esc(c.title) + '</div>' +
|
||
'<div class="text-xs text-fb-textDim truncate">' + esc(meta) + ' · ' + acc + '</div>' +
|
||
'<div class="text-[0.625rem] text-fb-textDim/60 truncate" title="' + esc(c.filename) + '">' + esc(c.filename) + '</div>' +
|
||
(prefLabel ? '<div class="text-[0.625rem] font-semibold text-fb-primary mt-0.5">' + esc(prefLabel) + '</div>' : '') +
|
||
'</div>' +
|
||
'<button data-ch-play title="Play this chart" aria-label="Play this chart" class="shrink-0 w-8 h-8 rounded-full bg-fb-primary hover:bg-fb-primaryHi text-white text-sm leading-none">▶</button>' +
|
||
'</div>' +
|
||
'<div class="flex gap-2 mt-2">' +
|
||
'<button data-ch-pl class="text-xs px-2 py-1 rounded border border-fb-border/50 text-fb-textDim hover:text-fb-text">+ Playlist</button>' +
|
||
// 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
|
||
? '<button data-ch-split title="These aren\'t the same song — give this chart its own card" class="text-xs px-2 py-1 rounded border border-fb-border/50 text-fb-textDim hover:text-fb-accent hover:border-fb-accent/50">Split out</button>'
|
||
: '') +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// 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)
|
||
? '<div class="text-[0.6875rem] text-fb-primary/90 border border-fb-primary/30 rounded-md px-2 py-1.5">Practice history stays with each chart — your new pick starts from its own stats.</div>'
|
||
: '';
|
||
dr.innerHTML =
|
||
'<div class="p-5 space-y-4">' +
|
||
'<div class="flex items-start justify-between gap-2">' +
|
||
'<div class="min-w-0">' +
|
||
'<h3 class="text-lg font-semibold text-fb-text truncate" title="' + esc(head.title) + '">' + esc(head.title) + '</h3>' +
|
||
'<div class="text-xs text-fb-textDim truncate">' + esc(head.artist) + ' · ' + data.count + ' chart' + (data.count === 1 ? '' : 's') + '</div>' +
|
||
'</div>' +
|
||
'<button data-charts-close aria-label="Close" class="text-fb-textDim hover:text-fb-text text-xl leading-none">✕</button>' +
|
||
'</div>' +
|
||
switchNote +
|
||
'<div role="radiogroup" aria-label="Charts of this song" class="space-y-2">' +
|
||
data.charts.map((c) => _chartRowHtml(c, data)).join('') +
|
||
'</div>' +
|
||
(data.preferred_source === 'user'
|
||
? '<button data-charts-auto class="w-full text-sm text-fb-textDim hover:text-fb-text border border-fb-border/50 rounded-md py-2">Reset to auto pick</button>'
|
||
: '<div class="text-[0.6875rem] text-fb-textDim">Auto pick sticks with a chart you\'ve practised; otherwise most complete → newest. Tap a chart to pin your keeper.</div>') +
|
||
'</div>';
|
||
|
||
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 =
|
||
'<span class="text-sm text-fb-text">' + state.selected.size + ' selected</span>' +
|
||
'<button data-batch="playlist" class="text-sm bg-fb-primary hover:bg-fb-primaryHi text-white px-3 py-1 rounded-full">Add to playlist</button>' +
|
||
'<button data-batch="details" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text px-3 py-1 rounded-full">Edit details</button>' +
|
||
'<button data-batch="saved" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text px-3 py-1 rounded-full">Save for Later</button>' +
|
||
'<button data-batch="clear" class="text-sm text-fb-textDim hover:text-fb-text px-2">Clear</button>';
|
||
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 '<button type="button" data-pl="' + esc(String(r.id)) + '" class="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-white/5 text-left">' +
|
||
'<span class="text-lg leading-none w-5 text-fb-primary">' + boxFor(r) + '</span>' +
|
||
'<span class="flex-1 truncate text-sm text-fb-text">' + esc(r.name) + '</span>' +
|
||
'<span class="text-xs text-fb-textDim">' + (r.count || 0) + '</span></button>';
|
||
}
|
||
function listHtml() {
|
||
const q = query.trim().toLowerCase();
|
||
const shown = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows;
|
||
if (!shown.length) return '<p class="text-sm text-fb-textDim text-center py-6">' + (rows.length ? 'No matches.' : 'No playlists yet - create one above.') + '</p>';
|
||
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 =
|
||
'<div class="bg-fb-card rounded-xl border border-fb-border/50 w-full max-w-md p-5 space-y-4" role="dialog" aria-label="Add to playlist">' +
|
||
'<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Add ' + fns.length + ' song' + (fns.length === 1 ? '' : 's') + ' to playlist</h3>' +
|
||
'<button type="button" data-x class="text-fb-textDim hover:text-fb-text text-xl leading-none">✕</button></div>' +
|
||
'<div class="flex gap-2"><input data-new type="text" placeholder="+ New playlist name" class="' + btnCtrl + ' flex-1"><button type="button" data-create class="bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text px-3 rounded-md text-sm">Create</button></div>' +
|
||
(rows.length > 8 ? '<input data-search type="text" placeholder="Search playlists..." class="' + btnCtrl + ' w-full" value="' + esc(query) + '">' : '') +
|
||
'<div data-list class="max-h-72 overflow-y-auto v3-scroll -mx-1 px-1">' + listHtml() + '</div>' +
|
||
'<div class="flex justify-end"><button type="button" data-done class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-5 py-2 rounded-md">Done</button></div>' +
|
||
'</div>';
|
||
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) =>
|
||
'<button data-bd="' + val + '" class="px-2 h-8 rounded-md text-sm border ' +
|
||
(String(bulk.diff) === String(val) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700 hover:text-fb-text') + '">' + label + '</button>';
|
||
const addChips = bulk.add.map((t) => '<span class="inline-flex items-center gap-1 bg-fb-primary/20 text-fb-text border border-fb-primary/40 text-xs px-2 py-0.5 rounded-full">' + esc(t) +
|
||
'<button data-badd-rm="' + esc(t) + '" aria-label="Remove ' + esc(t) + '" class="text-fb-textDim hover:text-fb-accent leading-none">×</button></span>').join('');
|
||
const rmChips = [...present].sort((a, b) => a.localeCompare(b)).map((t) =>
|
||
'<button data-brm="' + esc(t) + '" class="text-xs px-2 py-0.5 rounded-full border ' +
|
||
(bulk.remove.has(t) ? 'bg-fb-low/30 text-fb-low border-fb-low/40 line-through' : 'bg-gray-800/50 text-fb-textDim border-gray-700 hover:text-fb-text') + '">' + esc(t) + '</button>').join('');
|
||
const nothing = bulk.diff === 'keep' && !bulk.add.length && !bulk.remove.size;
|
||
overlay.innerHTML =
|
||
'<div class="bg-fb-sidebar border border-fb-border/60 rounded-2xl w-full max-w-sm shadow-2xl max-h-[85vh] overflow-y-auto v3-scroll">' +
|
||
'<div class="p-5 space-y-4">' +
|
||
'<div class="flex items-center justify-between"><h3 class="text-base font-semibold text-fb-text">Edit ' + fns.length + ' songs</h3>' +
|
||
'<button data-bulk-x aria-label="Close" class="text-fb-textDim hover:text-fb-text text-xl leading-none">✕</button></div>' +
|
||
|
||
'<div><div class="text-xs text-fb-textDim mb-1">Difficulty (for you)</div>' +
|
||
'<div class="flex flex-wrap gap-1 items-center">' + diffBtn('keep', 'Leave') + diffBtn(1, '1') + diffBtn(2, '2') + diffBtn(3, '3') + diffBtn(4, '4') + diffBtn(5, '5') + diffBtn('clear', 'Clear') + '</div>' +
|
||
'<div class="text-[0.6875rem] text-fb-textDim mt-1">"Leave" keeps each song's own value; a number or Clear applies to all ' + fns.length + '.</div></div>' +
|
||
|
||
'<div><div class="text-xs text-fb-textDim mb-1">Add tags to all</div>' +
|
||
'<div class="flex flex-wrap gap-1 mb-2">' + (addChips || '<span class="text-xs text-fb-textDim">None</span>') + '</div>' +
|
||
'<div class="flex gap-1"><input type="text" data-badd-input placeholder="Add a tag…" class="flex-1 bg-fb-card border border-fb-border/60 rounded-lg px-3 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary/60">' +
|
||
'<button data-badd-btn class="text-sm px-3 rounded-lg bg-fb-card/60 border border-fb-border/50 text-fb-text hover:bg-fb-card">Add</button></div></div>' +
|
||
|
||
(present.size ? '<div><div class="text-xs text-fb-textDim mb-1">Remove tags (present in the selection)</div><div class="flex flex-wrap gap-1">' + rmChips + '</div></div>' : '') +
|
||
|
||
'<div class="flex gap-2 pt-1"><button data-bulk-apply ' + (nothing ? 'disabled' : '') + ' class="flex-1 px-4 py-2 rounded-xl text-sm font-semibold ' +
|
||
(nothing ? 'bg-fb-card/50 text-fb-textDim cursor-not-allowed' : 'bg-fb-primary hover:bg-fb-primaryHi text-white') + '">Apply</button>' +
|
||
'<button data-bulk-x class="px-4 py-2 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-xl text-sm text-fb-text">Cancel</button></div>' +
|
||
'</div></div>';
|
||
|
||
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 '<div class="v3-card-skel" aria-hidden="true">' +
|
||
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card animate-pulse"></div>' +
|
||
'<div class="mt-1 text-sm text-transparent truncate">·</div>' +
|
||
'<div class="text-xs text-transparent truncate">·</div>' +
|
||
'<div class="v3-card-chips flex gap-1 mt-1"></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// 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 '<div class="flex flex-col items-center justify-center text-center py-8 gap-2" style="grid-column:1/-1">' +
|
||
'<div class="text-lg font-semibold text-fb-text">Your library is empty</div>' +
|
||
'<div class="text-sm text-fb-textDim max-w-md">Drop .sloppak files into your library folder, or use Upload above.</div>' +
|
||
'<button data-lib-empty-settings class="mt-3 bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-xl text-sm font-semibold">Open Settings</button>' +
|
||
'</div>';
|
||
}
|
||
|
||
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 '<button type="button" class="v3-azrail-letter" data-letter="' + esc(L) + '"'
|
||
+ (present ? '' : ' disabled') + ' tabindex="' + (tabbable ? '0' : '-1') + '"'
|
||
+ ' aria-label="Jump to ' + esc(name) + (present ? ', ' + n + ' song' + (n === 1 ? '' : 's') : ' (none)') + '">'
|
||
+ esc(L) + '</button>';
|
||
}).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 = '<p class="text-fb-textDim text-sm">Loading…</p>';
|
||
const data = await jget('/api/library/albums?' + queryParams().toString());
|
||
const albums = (data && data.albums) || [];
|
||
if (!albums.length) { host.innerHTML = '<p class="text-fb-textDim text-sm py-8 text-center">No albums match.</p>'; return; }
|
||
host.innerHTML =
|
||
'<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">' +
|
||
albums.map((a, i) =>
|
||
'<button data-album="' + i + '" class="group text-left">' +
|
||
'<div class="aspect-square rounded-lg overflow-hidden bg-fb-card mb-2">' +
|
||
(a.cover ? '<img src="' + esc(artUrl({ filename: a.cover })) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' : '') +
|
||
'</div>' +
|
||
'<div class="text-sm text-fb-text truncate">' + esc(a.album) + '</div>' +
|
||
'<div class="text-xs text-fb-textDim truncate">' + esc(a.artist) + ' · ' + (a.count || 0) + ' track' + (a.count === 1 ? '' : 's') + '</div>' +
|
||
'</button>').join('') + '</div>';
|
||
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 = '<p class="text-fb-textDim text-sm">Loading…</p>';
|
||
// 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 =
|
||
'<button data-albums-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">' + esc(backLabel) + '</button>' +
|
||
'<div class="flex items-center justify-between gap-3 mb-4">' +
|
||
'<div class="min-w-0"><h2 class="text-2xl font-bold text-fb-text truncate">' + esc(a.album) + '</h2>' +
|
||
'<p class="text-sm text-fb-textDim truncate">' + esc(a.artist) + ' · ' + songs.length + ' track' + (songs.length === 1 ? '' : 's') + '</p></div>' +
|
||
(songs.length ? '<button data-album-playall class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md shrink-0">▶ Play album</button>' : '') +
|
||
'</div>' +
|
||
'<ul class="space-y-1">' + songs.map((s, i) =>
|
||
'<li><button data-album-track="' + i + '" class="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-white/5 text-left">' +
|
||
'<span class="text-xs text-fb-textDim w-6 text-right">' + (i + 1) + '</span>' +
|
||
'<span class="flex-1 truncate text-sm text-fb-text">' + esc(s.title || s.filename) + '</span></button></li>').join('') + '</ul>';
|
||
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
|
||
? '<input type="checkbox" data-select class="shrink-0 w-5 h-5 accent-fb-primary pointer-events-none"' + (sel ? ' checked' : '') + '>'
|
||
: '';
|
||
return (
|
||
'<div class="relative flex items-center gap-2 py-1 group" data-fn="' + esc(k) + '" data-library-song="' + esc(songId(s)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
||
checkbox +
|
||
'<img src="' + esc(artUrl(s)) + '" alt="" loading="lazy" decoding="async" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer' + (sel ? ' ring-2 ring-fb-primary' : '') + '" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
|
||
'<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
|
||
(chips ? '<span class="hidden sm:flex items-center gap-1 shrink-0">' + chips + '</span>' : '') +
|
||
(fl ? '<span class="text-[0.5625rem] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
|
||
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].
|
||
'<div class="flex items-center gap-0.5 shrink-0">' +
|
||
'<button data-fav data-fav-idle="text-fb-textDim" title="Favorite" aria-label="Favorite" aria-pressed="' + (s.favorite ? 'true' : 'false') + '" class="px-1 ' + (s.favorite ? 'text-fb-accent' : 'text-fb-textDim') + '">' + (s.favorite ? '♥' : '♡') + '</button>' +
|
||
'<button data-save title="Save for later" aria-label="Save for later" class="px-1 text-fb-textDim hover:text-fb-text">🔖</button>' +
|
||
'<button data-menu title="More" aria-label="More actions" class="px-1 text-fb-textDim hover:text-fb-text leading-none">⋮</button>' +
|
||
'</div>' +
|
||
'</div>');
|
||
}
|
||
|
||
// ── 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) => '<img src="' + esc(u) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover" onerror="this.style.visibility=\'hidden\'">';
|
||
if (!arts || !arts.length) return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">🎤</div>';
|
||
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0]) + '</div>';
|
||
return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' + arts.slice(0, 4).map(img).join('') + '</div>';
|
||
}
|
||
|
||
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 = '<p class="text-fb-textDim text-sm">Loading…</p>';
|
||
_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
|
||
? '<div class="text-xs text-fb-textDim mt-1">also shown as: ' +
|
||
page.variants.map((v) => esc(v.name) + ' ×' + v.count).join(' · ') + '</div>'
|
||
: '';
|
||
// 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
|
||
? '<div class="mt-2"><span class="inline-flex items-center text-[0.625rem] px-2 py-0.5 rounded-full bg-fb-primary/15 text-fb-primary border border-fb-primary/40" title="This artist is matched to MusicBrainz — the match lives in your local cache; your files are never modified">Matched · MusicBrainz</span></div>'
|
||
: '';
|
||
// 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
|
||
? '<section class="mt-6"><h3 class="text-sm font-semibold text-fb-text mb-2">Albums</h3>' +
|
||
'<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">' +
|
||
page.albums.map((al, i) =>
|
||
'<button data-ap-album="' + i + '" class="group text-left">' +
|
||
'<div class="aspect-square rounded-lg overflow-hidden bg-fb-card mb-2">' +
|
||
(al.cover ? '<img src="' + esc(artUrl({ filename: al.cover })) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' : '') +
|
||
'</div>' +
|
||
'<div class="text-sm text-fb-text truncate">' + esc(al.name) + '</div>' +
|
||
'<div class="text-xs text-fb-textDim truncate">' + (al.year ? esc(al.year) + ' · ' : '') + (al.count || 0) + ' track' + (al.count === 1 ? '' : 's') + '</div>' +
|
||
'</button>').join('') +
|
||
'</div></section>'
|
||
: '';
|
||
|
||
const songsHtml = songs.length
|
||
? '<section class="mt-6"><h3 class="text-sm font-semibold text-fb-text mb-2">Songs</h3>' +
|
||
'<div class="space-y-0.5">' + songs.map(treeSongRowHtml).join('') + '</div></section>'
|
||
: '<p class="text-sm text-fb-textDim mt-6">No songs by this artist are in your library.</p>';
|
||
|
||
// 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
|
||
? '<section class="mt-6"><h3 class="text-sm font-semibold text-fb-text mb-2">Similar in your library</h3>' +
|
||
'<div class="flex flex-wrap gap-2">' +
|
||
page.similar.map((s) =>
|
||
'<button data-ap-similar="' + esc(s.artist) + '" class="text-xs px-3 py-1.5 rounded-full bg-fb-card/60 border border-fb-border/50 text-fb-text hover:border-fb-primary/60 hover:text-fb-primary transition">' + esc(s.artist) + '</button>').join('') +
|
||
'</div></section>'
|
||
: '';
|
||
|
||
host.innerHTML =
|
||
'<button data-ap-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Song Library</button>' +
|
||
'<div class="flex items-start gap-4">' +
|
||
artistMosaicHtml(page.art_urls) +
|
||
'<div class="min-w-0 flex-1">' +
|
||
'<h2 class="text-2xl font-bold text-fb-text truncate" title="' + esc(name) + '">' + esc(name) + '</h2>' +
|
||
aliasLine + pill +
|
||
'<p class="text-sm text-fb-textDim mt-2">' + bits.join(' · ') + '</p>' +
|
||
'<div class="flex flex-wrap gap-2 mt-3">' +
|
||
(songs.length
|
||
? '<button data-ap-playall class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play all</button>' +
|
||
'<button data-ap-shuffle class="bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md">⇄ Shuffle</button>'
|
||
: '') +
|
||
'<button data-ap-smart class="bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md" title="A live playlist of everything by this artist — new songs join it automatically">Save as smart playlist</button>' +
|
||
'</div>' +
|
||
'</div></div>' +
|
||
albumsHtml +
|
||
songsHtml +
|
||
similarHtml +
|
||
// External links land here (lazy fetch) — hidden until they exist.
|
||
'<div data-ap-links></div>';
|
||
|
||
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 =
|
||
'<div class="mt-6 pt-4 border-t border-fb-border/40">' +
|
||
'<div class="text-xs text-fb-textDim mb-2">On the web · opens your browser</div>' +
|
||
'<div class="flex flex-wrap gap-2">' +
|
||
items.map((it) =>
|
||
'<a href="' + esc(it.url) + '" target="_blank" rel="noopener noreferrer" class="text-xs px-3 py-1.5 rounded-full bg-fb-card/60 border border-fb-border/50 text-fb-text hover:border-fb-primary/60 transition">' +
|
||
esc(it.label) + ' ↗ <span class="text-fb-textDim">' + esc(_linkDomain(it.url)) + '</span></a>').join('') +
|
||
'</div></div>';
|
||
}
|
||
|
||
// 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' ? ''
|
||
: '<p class="text-xs text-fb-textDim mb-3">List view groups by artist — the selected sort applies to the card grid.</p>';
|
||
// 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 = '<p class="text-fb-textDim text-sm">Loading…</p>';
|
||
// 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 = '<p class="text-fb-textDim text-sm">Nothing here.</p>'; return; }
|
||
artists.forEach((a) => (a.albums || []).forEach((al) => (al.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; })));
|
||
host.innerHTML = _treeSortNote + artists.map((a) =>
|
||
'<details data-artist="' + esc(a.name) + '"' + (openArtists.has(a.name) ? ' open' : '') + ' class="border-b border-fb-border/40"><summary class="cursor-pointer py-2 text-fb-text flex items-center justify-between">' +
|
||
'<span>' + esc(a.name) + '</span><span class="text-xs text-fb-textDim">' + esc(a.song_count) + '</span></summary>' +
|
||
'<div class="pl-3 pb-2 space-y-2">' + (a.albums || []).map((al) =>
|
||
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim/70 mt-2 mb-1">' + esc(al.name || 'Unknown') + '</div>' +
|
||
(al.songs || []).map(treeSongRowHtml).join('') + '</div>').join('') + '</div></details>').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 '<button data-tri="' + group + '" data-val="' + esc(value) + '" class="px-2 py-1 rounded-md text-xs border ' + cls + '"' + tip + '>' + mark + esc(label) + '</button>';
|
||
}
|
||
function renderDrawer() {
|
||
const d = document.getElementById('v3-songs-drawer');
|
||
if (!d) return;
|
||
const f = state.filters;
|
||
d.innerHTML =
|
||
'<div class="p-5 space-y-5">' +
|
||
'<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Filters</h3>' +
|
||
'<button data-drawer-close class="text-fb-textDim hover:text-fb-text">✕</button></div>' +
|
||
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 '<button data-stem-unsplit class="px-2 py-1 rounded-md text-xs border '
|
||
+ (on ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700')
|
||
+ '" title="Songs with no instrument stems — not yet split">'
|
||
+ (on ? '✕ ' : '') + 'Not split</button>';
|
||
})()) +
|
||
section('Lyrics', ['', '1', '0'].map((v) => '<button data-lyrics="' + v + '" class="px-2 py-1 rounded-md text-xs border ' + (f.lyrics === v ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + '</button>').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) => '<button data-mastery="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.mastery.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').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) => '<button data-match="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.match.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
|
||
// Genre facet — dynamic list from /api/library/genres (primary genre).
|
||
(state.genres && state.genres.length ? section('Genre', state.genres.map((g) => '<button data-genre="' + esc(g) + '" class="px-2 py-1 rounded-md text-xs border ' + (f.genre.includes(g) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + esc(g) + '</button>').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.
|
||
'<div class="flex gap-1 mb-2">'
|
||
+ [['exact', 'Exact tuning'], ['playable', 'Playable without retuning']].map((m) => {
|
||
const on = (f.tuningMatch || 'exact') === m[0];
|
||
const dis = m[0] === 'playable' && !playableAvailable();
|
||
return '<button data-tuning-match="' + m[0] + '"'
|
||
+ (dis ? ' disabled' : '')
|
||
+ (dis ? ' title="Needs your current tuning — open the tuner first"' : '')
|
||
+ ' class="px-2 py-1 rounded-md text-xs border '
|
||
+ (on ? 'bg-fb-primary text-white border-fb-primary'
|
||
: 'bg-gray-800/50 text-fb-textDim border-gray-700')
|
||
+ (dis ? ' opacity-40 cursor-not-allowed' : '') + '">'
|
||
+ esc(m[1]) + '</button>';
|
||
}).join('')
|
||
+ '</div>'
|
||
+ (f.tuningMatch === 'playable'
|
||
? '<div class="text-xs text-fb-textDim mb-2">Charts you can play in your current tuning, no retune. Songs whose lowest string sits below yours are excluded.</div>'
|
||
: '')
|
||
+ ((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('') || '<span class="text-xs text-fb-textDim">No tunings</span>')) +
|
||
// 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', '<button data-grouping class="px-2 py-1 rounded-md text-xs border ' +
|
||
(state.grouping !== false ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') +
|
||
'" title="Collapse charts of the same song to one card (the ⚑ chip lists the versions)">' +
|
||
(state.grouping !== false ? '✓ ' : '') + 'One card per song</button>')
|
||
: '') +
|
||
// 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
|
||
? '<div class="pt-3 border-t border-fb-border/50"><button data-drawer-save class="w-full text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md py-2">+ Save as collection</button></div>'
|
||
: '') +
|
||
// 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'
|
||
? '<div class="pt-3 border-t border-fb-border/50"><button data-drawer-tidy class="w-full text-sm text-fb-textDim hover:text-fb-text border border-fb-border/50 rounded-md py-2">Tidy up artists…</button></div>'
|
||
: '') +
|
||
'<div class="flex justify-between pt-3 border-t border-fb-border/50"><button data-drawer-clear class="text-sm text-fb-textDim hover:text-fb-text">Clear all</button>' +
|
||
'<button data-drawer-apply class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Done</button></div></div>';
|
||
|
||
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 '<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-2">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
|
||
}
|
||
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 '<button data-gapfill-check class="text-xs text-fb-textDim hover:text-fb-text">Write missing info to file…</button>';
|
||
if (g.loading) return '<div class="text-xs text-fb-textDim">Checking the file…</div>';
|
||
if (g.written) {
|
||
const names = Object.keys(g.written).map((k) => GAP_KEY_LABELS[k] || k).join(', ');
|
||
return '<div class="text-xs text-fb-text">✓ Added to file: ' + esc(names) + '</div>';
|
||
}
|
||
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 '<div class="text-xs text-fb-textDim">' + esc(why) + '</div>';
|
||
}
|
||
const rows = (g.missing || []).map((m) => {
|
||
const val = Array.isArray(m.value) ? m.value.join(', ') : String(m.value);
|
||
return '<label class="flex items-center gap-2 text-sm text-fb-text">' +
|
||
'<input type="checkbox" data-gapfill-key="' + esc(m.key) + '"' + (st.gapSel && st.gapSel.has(m.key) ? ' checked' : '') + '>' +
|
||
'<span class="text-fb-textDim shrink-0">' + esc(GAP_KEY_LABELS[m.key] || m.key) + '</span>' +
|
||
'<span class="truncate" title="' + esc(val) + '">' + esc(val) + '</span></label>';
|
||
}).join('');
|
||
return '<div class="space-y-2">' +
|
||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Write to file</div>' + rows +
|
||
'<div class="text-[0.6875rem] text-fb-textDim">Only adds what’s missing — nothing already in the file is changed. A backup (.bak) is kept beside the file.</div>' +
|
||
'<div class="flex gap-2"><button data-gapfill-write class="bg-fb-primary hover:bg-fb-primaryHi text-white px-3 py-1.5 rounded-lg text-xs font-semibold">Write to file</button>' +
|
||
'<button data-gapfill-cancel class="px-3 py-1.5 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-lg text-xs text-fb-text">Cancel</button></div></div>';
|
||
}
|
||
|
||
// 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 '<div class="flex items-baseline gap-2 text-xs text-fb-textDim">' +
|
||
'<span class="truncate">Matched: ' + esc(who) + ' (' + esc(src) + ')</span>' +
|
||
'<button data-det-fixmatch class="shrink-0 text-fb-primary hover:text-fb-primaryHi">Fix match</button></div>';
|
||
}
|
||
|
||
function detailsHtml(song, st, vocab) {
|
||
const art = st.artDataUrl || artUrl(song);
|
||
const diffBtns = [1, 2, 3, 4, 5].map((n) =>
|
||
'<button data-diff="' + n + '" aria-pressed="' + (st.diff === n ? 'true' : 'false') + '" class="w-8 h-8 rounded-md text-sm font-bold border ' +
|
||
(st.diff === n ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700 hover:text-fb-text') + '">' + n + '</button>').join('');
|
||
const applied = new Set(st.tags);
|
||
const tagChips = st.tags.length
|
||
? st.tags.map((t) => '<span class="inline-flex items-center gap-1 bg-fb-primary/20 text-fb-text border border-fb-primary/40 text-xs px-2 py-0.5 rounded-full">' + esc(t) +
|
||
'<button data-tag-rm="' + esc(t) + '" aria-label="Remove tag ' + esc(t) + '" class="text-fb-textDim hover:text-fb-accent leading-none">×</button></span>').join('')
|
||
: '<span class="text-xs text-fb-textDim">No tags yet</span>';
|
||
const suggest = (vocab || []).filter((v) => !applied.has(v.tag)).slice(0, 8).map((v) =>
|
||
'<button data-tag-add="' + esc(v.tag) + '" class="text-[0.6875rem] px-2 py-0.5 rounded-full bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(v.tag) + '</button>').join('');
|
||
const field = (id, label, val) =>
|
||
'<div><label for="' + id + '" class="text-xs text-fb-textDim mb-1 block">' + label + '</label>' +
|
||
'<input type="text" id="' + id + '" value="' + esc(val) + '" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div>';
|
||
return '<div class="p-5 space-y-5">' +
|
||
'<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Song details</h3>' +
|
||
'<button data-det-close aria-label="Close" class="text-fb-textDim hover:text-fb-text text-xl leading-none">✕</button></div>' +
|
||
|
||
'<div class="flex items-center gap-3">' +
|
||
'<div class="relative group cursor-pointer shrink-0" data-det-art title="Change album art">' +
|
||
'<img src="' + esc(art) + '" alt="" class="w-16 h-16 rounded-lg object-cover bg-fb-card" id="det-art-preview" onerror="this.style.visibility=\'hidden\'">' +
|
||
'<div class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition text-[0.625rem] text-white">Change</div>' +
|
||
'<input type="file" accept="image/*" id="det-art-file" class="hidden"></div>' +
|
||
'<div class="min-w-0"><div class="text-sm text-fb-text truncate" title="' + esc(song.title || song.filename) + '">' + esc(song.title || song.filename) + '</div>' +
|
||
'<div class="mt-1"><button data-det-fav class="text-xs ' + (st.fav ? 'text-fb-accent' : 'text-fb-textDim hover:text-fb-text') + '">' + (st.fav ? '♥ Liked' : '♡ Like') + '</button>' +
|
||
'<span class="text-[0.625rem] text-fb-textDim ml-1">a like, not a rating</span></div></div></div>' +
|
||
|
||
// Identity — writes back into the feedpak FILE
|
||
'<div class="space-y-3"><div class="flex items-center gap-2"><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Identity</div>' +
|
||
'<span class="text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700" title="These came from the song's feedpak. Editing them writes back to the file.">From pack</span></div>' +
|
||
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)
|
||
? '<button data-det-artist-page class="text-xs text-fb-primary hover:text-fb-primaryHi text-left">View artist page →</button>'
|
||
: '') +
|
||
field('det-album', 'Album', st.al) +
|
||
'<div><label for="det-year" class="text-xs text-fb-textDim mb-1 block">Year</label><input type="text" inputmode="numeric" id="det-year" value="' + esc(st.y) + '" placeholder="e.g. 2024" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div>' +
|
||
provenanceHtml(st) +
|
||
'<div data-det-gapfill>' + gapFillHtml(st) + '</div></div>' +
|
||
|
||
// Personal practice layer — local, never shared
|
||
'<div class="space-y-3 pt-1"><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Your practice <span class="normal-case font-normal text-fb-textDim/70">· stays on this device</span></div>' +
|
||
'<div><div class="flex items-center justify-between mb-1"><label class="text-xs text-fb-textDim">Difficulty (for you)</label>' +
|
||
'<button data-diff-clear class="text-[0.6875rem] text-fb-textDim hover:text-fb-text ' + (st.diff == null ? 'invisible' : '') + '">Clear</button></div>' +
|
||
'<div class="flex gap-1 items-center">' + diffBtns + '<span class="text-xs text-fb-textDim ml-2">' + esc(st.diff ? DIFF_LABELS[st.diff] : 'Not set') + '</span></div></div>' +
|
||
'<div><label for="det-tag-input" class="text-xs text-fb-textDim mb-1 block">Tags</label>' +
|
||
'<div class="flex flex-wrap gap-1 mb-2" data-det-tags>' + tagChips + '</div>' +
|
||
'<div class="flex gap-1"><input type="text" id="det-tag-input" placeholder="Add a tag…" class="flex-1 bg-fb-card border border-fb-border/60 rounded-lg px-3 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary/60">' +
|
||
'<button data-tag-addbtn class="text-sm px-3 rounded-lg bg-fb-card/60 border border-fb-border/50 text-fb-text hover:bg-fb-card">Add</button></div>' +
|
||
(suggest ? '<div class="flex flex-wrap gap-1 mt-2">' + suggest + '</div>' : '') + '</div>' +
|
||
'<div><label for="det-notes" class="text-xs text-fb-textDim mb-1 block">Notes</label>' +
|
||
'<textarea id="det-notes" rows="3" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60 resize-none" placeholder="Private practice notes…">' + esc(st.notes) + '</textarea></div></div>' +
|
||
|
||
'<div class="flex gap-3 pt-1"><button data-det-save class="flex-1 bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-xl text-sm font-semibold">Save</button>' +
|
||
'<button data-det-close class="px-4 py-2 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-xl text-sm text-fb-text">Cancel</button></div>' +
|
||
'<div class="pt-3 border-t border-fb-border/50"><button data-det-remove class="w-full px-4 py-2 bg-fb-low/15 hover:bg-fb-low/30 border border-fb-low/40 rounded-xl text-sm text-fb-low">Remove from library</button></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
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: '<p class="text-sm text-gray-300">Remove <span class="font-semibold text-white">' + esc(title) + '</span> from your library?</p>' +
|
||
'<p class="text-xs text-red-400/90 mt-2">This permanently deletes the file from disk. This cannot be undone.</p>',
|
||
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())
|
||
? '<span class="text-[0.6875rem] text-fb-primary ml-1">→ ' + esc(a.canonical) + '</span>' : '';
|
||
return '<label class="flex items-center gap-2 px-2 py-1 rounded hover:bg-fb-card/50 cursor-pointer">' +
|
||
'<input type="checkbox" data-tidy-sel="' + esc(a.name) + '"' + checked + ' class="w-4 h-4 accent-fb-primary shrink-0">' +
|
||
'<span class="text-sm text-fb-text truncate flex-1">' + esc(a.name) + mapped + '</span>' +
|
||
'<span class="text-xs text-fb-textDim shrink-0">' + a.count + '</span></label>';
|
||
}).join('') || '<div class="text-xs text-fb-textDim px-2 py-3">No artists</div>';
|
||
|
||
const canReady = st.mergeInto.trim() && st.sel.size && !st.busy;
|
||
const mergeBar = st.sel.size
|
||
? '<div class="pt-2 mt-2 border-t border-fb-border/50 space-y-2">' +
|
||
'<div class="text-xs text-fb-textDim">' + st.sel.size + ' selected — merge into:</div>' +
|
||
'<div class="flex gap-1"><input type="text" data-tidy-canon value="' + esc(st.mergeInto) + '" placeholder="Canonical name" class="flex-1 bg-fb-card border border-fb-border/60 rounded-lg px-3 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary/60">' +
|
||
'<button data-tidy-merge ' + (canReady ? '' : 'disabled') + ' class="text-sm px-3 rounded-lg ' + (canReady ? 'bg-fb-primary hover:bg-fb-primaryHi text-white' : 'bg-fb-card/50 text-fb-textDim cursor-not-allowed') + '">Merge</button></div></div>'
|
||
: '';
|
||
|
||
const aliasRows = st.aliases.length
|
||
? st.aliases.map((al) => '<div class="flex items-center gap-2 px-2 py-1 text-sm"><span class="text-fb-textDim truncate flex-1">' + esc(al.raw_name) + ' <span class="text-fb-textDim/60">→</span> ' + esc(al.canonical_name) + '</span>' +
|
||
'<button data-tidy-unmerge="' + esc(al.raw_name) + '" class="text-xs text-fb-textDim hover:text-fb-accent shrink-0">un-merge</button></div>').join('')
|
||
: '<div class="text-xs text-fb-textDim px-2 py-2">No merges yet</div>';
|
||
|
||
overlay.innerHTML =
|
||
'<div class="bg-fb-sidebar border border-fb-border/60 rounded-2xl w-full max-w-md shadow-2xl max-h-[85vh] flex flex-col">' +
|
||
'<div class="p-5 pb-3 flex items-center justify-between"><h3 class="text-base font-semibold text-fb-text">Tidy up artists</h3>' +
|
||
'<button data-tidy-x aria-label="Close" class="text-fb-textDim hover:text-fb-text text-xl leading-none">✕</button></div>' +
|
||
'<div class="px-5"><input type="text" data-tidy-search value="' + esc(st.query) + '" placeholder="Search artists…" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary/60 mb-1"></div>' +
|
||
'<div class="px-3 overflow-y-auto v3-scroll flex-1 min-h-[6rem]">' + rows + '</div>' +
|
||
'<div class="px-5">' + mergeBar + '</div>' +
|
||
'<div class="px-5 pt-2"><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Current merges</div><div class="max-h-32 overflow-y-auto v3-scroll">' + aliasRows + '</div></div>' +
|
||
'<div class="p-5 pt-3 flex justify-end"><button data-tidy-x class="text-sm px-4 py-2 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-xl text-fb-text">Done</button></div>' +
|
||
'</div>';
|
||
|
||
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]) => '<option value="' + esc(v) + '"' + (v === sel ? ' selected' : '') + '>' + esc(l) + '</option>').join('');
|
||
const provOpts = providers.map((p) => '<option value="' + esc(p.id) + '"' + (p.id === state.provider ? ' selected' : '') + '>' + esc(p.label || p.id) + '</option>').join('');
|
||
const ctrl = btnCtrl;
|
||
|
||
root.innerHTML =
|
||
'<div class="max-w-7xl mx-auto px-6 md:px-8 pb-8">' +
|
||
'<div id="v3-songs-toolbar" class="sticky z-20 -mx-6 md:-mx-8 px-6 md:px-8 py-3 mb-4 bg-fb-sidebar/95 backdrop-blur border-b border-fb-border/40">' +
|
||
'<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">' +
|
||
// The match-review chip is ambient tool-state (design §11): only
|
||
// rendered when matches are waiting, silent otherwise. Populated +
|
||
// shown by match-review.js (window.__fbMatchReviewChip), which
|
||
// also owns the drawer the click opens.
|
||
'<div class="flex items-baseline gap-3"><p class="text-fb-textDim text-sm" id="v3-songs-count"></p>' +
|
||
'<button id="v3-songs-match-review" class="hidden text-xs text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-full px-2.5 py-0.5"></button>' +
|
||
// Batch progress for the Refresh Metadata button (shown only while a
|
||
// pass runs). A real songs-processed ratio, not a fake per-song %.
|
||
'<span id="v3-meta-progress" class="hidden items-center gap-2 text-xs text-fb-textDim">' +
|
||
'<span id="v3-meta-progress-label"></span>' +
|
||
'<span class="inline-block w-24 rounded-full bg-fb-border/40 overflow-hidden align-middle" style="height:6px"><span id="v3-meta-progress-fill" class="block h-full bg-fb-primary transition-all" style="width:0%"></span></span>' +
|
||
'</span></div>' +
|
||
'<div class="flex flex-wrap gap-2">' +
|
||
(providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') +
|
||
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
|
||
'<select id="v3-songs-album" class="' + ctrl + ' max-w-[11rem]" aria-label="Album"' + (state.artist ? '' : ' disabled') + '>' + albumSelectHtml() + '</select>' +
|
||
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button><button id="v3-songs-albums-btn" title="Albums" class="px-3 py-2 text-sm">💿</button><button id="v3-songs-folder-btn" class="px-3 py-2 text-sm" style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.25rem"><svg fill="currentColor" viewBox="0 0 16 16" style="width:12px;height:12px;flex-shrink:0"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg></button></div>' +
|
||
// Name the perspective on the SORT too, not just the filter: tuning
|
||
// sort orders by musical distance from standard, and for a bass
|
||
// player that distance is measured on the bass tuning. Unlabelled,
|
||
// the grid silently reorders with no visible cause.
|
||
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(
|
||
SORTS.map(([v, l]) => [v, v === 'tuning' ? l + ' (' + libInstrumentLabel() + ')' : l]),
|
||
state.sort) + '</select>' +
|
||
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
|
||
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
|
||
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
|
||
'<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' +
|
||
'<button id="v3-songs-refresh-meta" title="Refresh metadata for the songs shown (re-match titles, artwork & more)" class="' + ctrl + '">🏷 Metadata</button>' +
|
||
'<button id="v3-songs-unmatched" title="Show only songs with no metadata match" class="' + ctrl + ((state.filters.match || []).includes('unmatched') ? ' bg-fb-primary text-white' : '') + '">Unmatched</button>' +
|
||
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
|
||
'</div></div></div>' +
|
||
// 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.
|
||
'<div id="v3-lib-home" class="hidden mb-5"></div>' +
|
||
// 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).
|
||
'<div id="v3-songs-gridsizer" class="relative">' +
|
||
'<div id="v3-songs-grid" class="v3-grid-window grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"></div>' +
|
||
'</div>' +
|
||
'<div id="v3-songs-tree" class="hidden"></div>' +
|
||
'<div id="v3-songs-albums" class="hidden"></div>' +
|
||
// Artist page host (PR-B) — an openAlbum-style in-place sub-render;
|
||
// populated + shown by openArtistPage, cleared on close/reload.
|
||
'<div id="v3-songs-artistpage" class="hidden"></div>' +
|
||
'<div id="lib-folder-controls" style="display:none"></div>' +
|
||
'<div id="lib-folder-tree" class="space-y-1 hidden"></div>' +
|
||
'<div id="v3-songs-sentinel" class="h-8"></div>' +
|
||
// A–Z jump rail (grid + alphabetical sorts only; populated by
|
||
// refreshRail). The bubble shows the current letter while dragging.
|
||
'<nav id="v3-songs-azrail" class="v3-azrail hidden" aria-label="Jump to letter"></nav>' +
|
||
'<div id="v3-songs-azbubble" class="v3-azbubble hidden" aria-hidden="true"></div>' +
|
||
// Filter drawer + overlay
|
||
'<div id="v3-songs-overlay" class="fixed inset-0 bg-black/50 z-40 hidden"></div>' +
|
||
'<aside id="v3-songs-drawer" class="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"></aside>' +
|
||
'</div>';
|
||
|
||
// 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,
|
||
},
|
||
};
|
||
|
||
// ── Grid cursor navigation (arrow keys / gamepad d-pad) ─────────────────
|
||
// shortcuts.js's generic library arrow-nav (_handleLibArrowNav) can't reach
|
||
// this screen: it assumes every navigable item is already a DOM node, but
|
||
// this grid is windowed — most of the library isn't in the DOM at any given
|
||
// scroll position. Cursor state here is an absolute index into state.songs,
|
||
// and moving it may need to fetch a page and/or scroll the window before the
|
||
// target card exists to highlight or activate.
|
||
let _gpIdx = null;
|
||
|
||
function _gpCardEl(idx) {
|
||
return document.querySelector('#v3-songs-grid [data-idx="' + idx + '"]');
|
||
}
|
||
|
||
function _gpApplyHighlight() {
|
||
const prev = document.querySelector('#v3-songs-grid [data-gp-cursor]');
|
||
if (prev) {
|
||
prev.removeAttribute('data-gp-cursor');
|
||
prev.querySelector('[data-v3-play]')?.classList.remove('ring-2', 'ring-fb-primary');
|
||
}
|
||
if (_gpIdx == null) return;
|
||
const el = _gpCardEl(_gpIdx);
|
||
if (!el) return;
|
||
el.setAttribute('data-gp-cursor', '1');
|
||
el.querySelector('[data-v3-play]')?.classList.add('ring-2', 'ring-fb-primary');
|
||
}
|
||
|
||
async function _gpEnsureVisible(idx) {
|
||
const main = document.getElementById('v3-main'), sizer = _sizerEl();
|
||
if (!main || !sizer) return;
|
||
const { cols, rowH } = measureGeom();
|
||
const row = Math.floor(idx / Math.max(1, cols));
|
||
const sizerTop = _sizerTopInScroller(main, sizer);
|
||
const rowTop = sizerTop + row * rowH;
|
||
const rowBottom = rowTop + rowH;
|
||
const viewTop = main.scrollTop, viewBottom = viewTop + main.clientHeight;
|
||
if (rowTop < viewTop) main.scrollTop = rowTop;
|
||
else if (rowBottom > viewBottom) main.scrollTop = rowBottom - main.clientHeight;
|
||
await renderWindow();
|
||
|
||
// #v3-songs-toolbar is sticky, but only pins to the top once scrolled
|
||
// PAST its natural in-flow position — before that point it isn't
|
||
// covering anything, after it covers a fixed band. That makes its
|
||
// occlusion scroll-dependent in a way no single precomputed offset
|
||
// captures, so measure the real rendered overlap and correct for it,
|
||
// rather than assuming the toolbar's height is always "lost" space.
|
||
const toolbar = document.getElementById('v3-songs-toolbar');
|
||
const cardEl = _gpCardEl(idx);
|
||
if (toolbar && cardEl) {
|
||
const overlap = toolbar.getBoundingClientRect().bottom - cardEl.getBoundingClientRect().top;
|
||
if (overlap > 0) {
|
||
// Push the row DOWN the screen to clear the toolbar — that means
|
||
// scrolling the content back UP, i.e. decreasing scrollTop (screen
|
||
// position = content position - scrollTop).
|
||
main.scrollTop -= overlap;
|
||
await renderWindow();
|
||
}
|
||
}
|
||
}
|
||
|
||
async function _gpMove(delta) {
|
||
if (!state.total) return;
|
||
if (_gpIdx == null) {
|
||
// Nothing selected yet — land on the first card and stop, same as
|
||
// shortcuts.js's legacy _handleLibArrowNav does for an empty
|
||
// selection: the first press establishes a cursor, it doesn't also
|
||
// move it (Right/Down previously skipped straight past row 0).
|
||
_gpIdx = 0;
|
||
} else {
|
||
const next = Math.max(0, Math.min(state.total - 1, _gpIdx + delta));
|
||
if (next === _gpIdx) return;
|
||
_gpIdx = next;
|
||
}
|
||
await ensureWindow(Math.max(0, _gpIdx - 1), Math.min(state.total, _gpIdx + 2));
|
||
await _gpEnsureVisible(_gpIdx);
|
||
_gpApplyHighlight();
|
||
}
|
||
|
||
function _gpActivate() {
|
||
if (_gpIdx == null) return;
|
||
_gpCardEl(_gpIdx)?.querySelector('[data-v3-play]')?.click();
|
||
}
|
||
|
||
// Bails on focus inside anything with its own keyboard semantics — form
|
||
// controls, buttons, and dialog/drawer overlays — same intent as
|
||
// shortcuts.js's _isInsideInteractiveControl, reimplemented locally since
|
||
// this is a plain script (not an ES module) and can't import it.
|
||
//
|
||
// The form-control/button check requires the element to be VISIBLE, not
|
||
// merely present: screens stay in the DOM (hidden, not removed) when you
|
||
// navigate away, so a real <button> focused on some OTHER now-hidden
|
||
// screen (dashboard, etc.) can leave document.activeElement pointing at
|
||
// it — an el.closest('#v3-songs') scope check would let that stale focus
|
||
// through fine, but would ALSO wrongly stop blocking genuinely-focused
|
||
// shared chrome like the topbar search input (#v3-search lives outside
|
||
// #v3-songs's DOM subtree even while v3-songs is the active screen).
|
||
// Visibility is the actual distinction that matters here, not DOM
|
||
// nesting. The dialog/drawer-overlay and contentEditable checks stay as
|
||
// they were — overlay-level concerns regardless of which screen sits
|
||
// underneath.
|
||
function _gpBlockedTarget(el) {
|
||
if (!el) return false;
|
||
if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(el.tagName) && el.offsetParent !== null) return true;
|
||
if (el.isContentEditable) return true;
|
||
if (el.closest && el.closest('[role="dialog"], .feedBack-modal, #lib-filter-drawer')) return true;
|
||
return false;
|
||
}
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (!songsActive() || state.view !== 'grid') return;
|
||
if (_gpBlockedTarget(document.activeElement)) return;
|
||
const isActivate = e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar';
|
||
if (!isActivate && !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
|
||
e.preventDefault();
|
||
if (isActivate) { _gpActivate(); return; }
|
||
const { cols } = measureGeom();
|
||
if (e.key === 'ArrowRight') _gpMove(1);
|
||
else if (e.key === 'ArrowLeft') _gpMove(-1);
|
||
else if (e.key === 'ArrowDown') _gpMove(cols);
|
||
else if (e.key === 'ArrowUp') _gpMove(-cols);
|
||
});
|
||
|
||
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());
|
||
});
|
||
}
|
||
})();
|