// Demo analytics — real impl set by demo.js; no-op in normal builds window.feedBackDemoTrack = window.feedBackDemoTrack ?? null; // Sync the play/pause button's icon and accessible state in one place so // screen readers, tooltips, and aria-pressed stay aligned with playback. // Updates the existing child's src in place rather than rewriting // innerHTML, so any future children (fallback label, loading spinner, …) // survive state changes. function setPlayButtonState(isPlaying) { const btn = document.getElementById('btn-play'); if (!btn) return; const label = isPlaying ? 'Pause' : 'Play'; const icon = isPlaying ? 'pause' : 'play'; let img = btn.querySelector('img.button-icon-svg'); if (!img) { img = document.createElement('img'); img.className = 'button-icon-svg'; img.alt = ''; img.setAttribute('aria-hidden', 'true'); btn.appendChild(img); } img.src = `/static/svg/${icon}.svg`; btn.setAttribute('aria-label', label); btn.setAttribute('aria-pressed', isPlaying ? 'true' : 'false'); btn.title = label; } // ── Global keyboard shortcuts ───────────────────────────────────────────── // // `/` focuses the active screen's search input (Library / Favorites); // `Esc` while focused blurs and clears it. Mirrors the GitHub / Gmail // convention. The listener bails when the user is already typing in // any text-accepting element so it can't intercept normal typing — // including inputs inside the filters drawer, plugin settings, or // modal dialogs. function _isTextInput(el) { if (!el) return false; const tag = el.tagName; if (tag === 'INPUT') { // Some types (button, checkbox, radio, range, ...) don't // accept text; only intercept the ones that do. const t = (el.type || 'text').toLowerCase(); return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t); } if (tag === 'TEXTAREA') return true; if (tag === 'SELECT') return true; if (el.isContentEditable) return true; return false; } function _isShortcutHelpKey(e) { return e.key === '?' || (e.shiftKey && (e.code === 'Slash' || e.key === '/')); } function _isShortcutHelpSuppressedTarget(el) { if (!el) return false; const tag = el.tagName; if (tag === 'INPUT') { const t = (el.type || 'text').toLowerCase(); return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t); } if (tag === 'TEXTAREA') return true; if (el.isContentEditable) return true; if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal, .feedBack-modal')) return true; return false; } function _activeSearchInput() { // Pick the search field for whichever screen is currently active. // No match (e.g. on the player or settings screen) means `/` does // nothing — the shortcut only fires where a search box exists. const active = document.querySelector('.screen.active'); if (!active) return null; if (active.id === 'home') return document.getElementById('lib-filter'); if (active.id === 'favorites') return document.getElementById('fav-filter'); return null; } // ── Library keyboard navigation ────────────────────────────────────────── // // Arrow keys move a single "selected" item among the visible cards // (grid view) or song rows (tree view). Enter plays the selected // song. The selected element gets: // - native keyboard focus via .focus() so :focus-visible draws the // accessible ring (announced by screen readers, follows scroll) // - a `.selected` class that persists when focus drifts elsewhere // so the user can glance back and still see their place. // // Grid columns are inferred from the live computed grid template at // the moment of navigation, so up/down works correctly across all // breakpoints (1 / 2 / 3 / 4 cols depending on viewport). function _isElementVisible(el) { // Walk ancestors looking for display:none. Handles collapsed // `.album-body` / `.artist-body` subtrees (hidden via CSS class // rules). Using a DOM walk rather than `offsetParent` avoids the // false-negative for `position:fixed` elements whose offsetParent // is null even when they are perfectly visible. if (!el) return false; let node = el; while (node && node !== document.body) { if (getComputedStyle(node).display === 'none') return false; node = node.parentElement; } return true; } // `_libNavItems` is consulted on every arrow / Enter / Space / Home / // End / activation press, including during autorepeat. Re-running // `querySelectorAll` + visibility filtering on every keypress is the // dominant cost on large libraries (hundreds of nodes × per-keypress // layout reads), so the result is memoised against a generation // counter that's bumped only when the underlying DOM actually // changes shape: render functions and `_toggleHeader` bump // `_libNavGeneration`. Cache misses fall through to a fresh query. let _libNavGeneration = 0; let _libNavItemsCache = { gen: -1, items: [], container: null, mode: null, scope: null }; function _bumpLibNavGeneration() { _libNavGeneration++; } function _libNavItems() { const active = document.querySelector('.screen.active'); if (!active) return { items: [], container: null, mode: null }; let tree, grid; if (active.id === 'home') { tree = document.getElementById('lib-tree'); grid = document.getElementById('lib-grid'); } else if (active.id === 'favorites') { tree = document.getElementById('fav-tree'); grid = document.getElementById('fav-grid'); } else { return { items: [], container: null, mode: null }; } const treeMode = tree && !tree.classList.contains('hidden'); const scope = treeMode ? tree : grid; // Cache key includes the active container — switching grid↔tree or // home↔favorites must miss even if the generation hasn't ticked. if ( _libNavItemsCache.gen === _libNavGeneration && _libNavItemsCache.scope === scope && scope && document.body.contains(scope) ) { return { items: _libNavItemsCache.items, container: _libNavItemsCache.container, mode: _libNavItemsCache.mode, }; } let items, container, mode; if (treeMode) { // List mode — include artist headers, album headers, and song // rows so arrow nav still works when artists/albums are // collapsed (only the headers are visible then). Filter to // the currently-displayed nodes so collapsed children don't // count as targets the keyboard can land on. const all = Array.from(tree.querySelectorAll( '.artist-header, .album-header, .song-row[data-play], .song-row[data-library-song][tabindex="0"]' )); items = all.filter(_isElementVisible); container = tree; mode = 'list'; } else { items = Array.from((grid || document).querySelectorAll('.song-card[data-play], .song-card[data-library-song][tabindex="0"]')); container = grid; mode = 'grid'; } _libNavItemsCache = { gen: _libNavGeneration, items, container, mode, scope }; return { items, container, mode }; } function _gridColumns(container) { // Count columns by grouping the first row of children by their // top coordinate. Robust against any grid-template-columns syntax // (`repeat(...)`, `auto-fit`, named lines, etc.) where naively // splitting `getComputedStyle().gridTemplateColumns` on whitespace // would miscount because of spaces inside `repeat(...)` / // `minmax(...)`. Falls back to 1 when the container is empty // so callers' max(1, ...) clamps stay valid. if (!container) return 1; const children = Array.from(container.children).filter( c => c && c.offsetParent !== null ); if (!children.length) return 1; const firstTop = children[0].getBoundingClientRect().top; let cols = 0; for (const c of children) { // Allow ~1px slop for sub-pixel rounding so two children that // would visually align still group together. if (Math.abs(c.getBoundingClientRect().top - firstTop) < 1.5) cols++; else break; } return Math.max(1, cols); } // Tracked separately from `document.activeElement` so the persistent // `.selected` highlight survives focus drifting elsewhere (clicks // outside the grid, drawer opening, etc). Also lets us avoid a global // `querySelectorAll('.selected')` on every arrow press — large // libraries make that a noticeable hot path. let _lastLibSelected = null; // Tracks which list screen launched the player so Esc-from-player // returns the user to that screen instead of always defaulting to // the Library (feedBack#126). Reset on every `playSong` call so a // song launched from a deep-link / plugin screen still gets a sane // fallback ('home'). let _playerOriginScreen = 'home'; let _settingsOriginScreen = 'home'; // One-shot flag set in `showScreen` when the user enters Home or // Favorites. Consumed by the very next library render so the // restored selection scrolls into view exactly once on screen entry // (player → home, hard reload). Routine re-renders driven by // search / sort / filter changes leave the user's scroll position // alone — the highlight still re-applies, but they aren't yanked. const _libScrollOnNextRender = { home: false, favorites: false }; // localStorage keys for "remember the last selection across reloads // and after returning from the player". One key per screen so the // Library and Favorites trees don't fight over the same slot. Only // song-row / song-card selections are persisted — header selections // in the tree are ephemeral by design (re-derived from arrow nav). const _LIB_SELECTED_KEY = 'feedBack.libLastSelected'; const _FAV_SELECTED_KEY = 'feedBack.favLastSelected'; function _selectedKeyForActiveScreen() { const active = document.querySelector('.screen.active'); if (!active) return null; if (active.id === 'home') return _LIB_SELECTED_KEY; if (active.id === 'favorites') return _FAV_SELECTED_KEY; return null; } function _persistLibSelection(el) { if (!el || !el.dataset) return; // Both local entries (data-play) and remote entries (data-library-song, // no data-play yet) are persisted so the selection highlight survives a // library re-render after sync or provider switch. const isLocal = !!el.dataset.play; const isRemote = !isLocal && !!el.dataset.librarySong; if (!isLocal && !isRemote) return; const key = _selectedKeyForActiveScreen(); if (!key) return; // Stored as JSON `{f, a, p, s}`: // f — encoded filename (local entries); drives data-play restore. // a — artist, for future cross-page restore. // p — encoded provider id; prevents cross-provider collisions. // s — encoded song id (remote entries); drives data-library-song restore. // Older bare-string and {f,a}/{f,a,p} formats are still tolerated in // `_loadPersistedLibSelection`. const artist = el.dataset.artist || ''; const provider = el.dataset.libraryProvider || ''; // For synced provider entries (data-play + data-library-song both present), // persist both f and s so _restoreLibSelection can match the card by either // attribute after a post-sync re-render. const payload = isLocal ? { f: el.dataset.play, a: artist, p: provider, s: el.dataset.librarySong || '' } : { f: '', a: artist, p: provider, s: el.dataset.librarySong }; try { localStorage.setItem(key, JSON.stringify(payload)); } catch { /* private mode / quota */ } } function _loadPersistedLibSelection(key) { let raw = null; try { raw = localStorage.getItem(key); } catch { return null; } if (!raw) return null; // Tolerate the older bare-string format (just the encoded // filename) — older builds wrote that and we'd rather upgrade // silently than orphan the user's saved selection. if (raw[0] !== '{') return { f: raw, a: '', p: '', s: '' }; try { const o = JSON.parse(raw); return (o && typeof o === 'object') ? { f: o.f || '', a: o.a || '', p: o.p || '', s: o.s || '' } : null; } catch { return null; } } function _setLibSelection(el, { focus = true } = {}) { if (!el) return; // Only the previously-tracked element needs its `.selected` class // cleared. classList.remove on an element that no longer carries // the class is a no-op, so a stale `_lastLibSelected` from a // re-render is harmless. Avoids the global `querySelectorAll` // pass that the earlier implementation ran on every keypress. if (_lastLibSelected && _lastLibSelected !== el) { _lastLibSelected.classList.remove('selected'); } el.classList.add('selected'); _lastLibSelected = el; // Save song selections to localStorage so a reload (or returning // from the player) can restore the highlight. Headers don't get // persisted — they don't carry a stable id and the tree's auto- // open heuristic re-derives them on each render anyway. _persistLibSelection(el); if (focus) { // `preventScroll: true` skips the browser's native focus-scroll, // then we run a single `scrollIntoView` so we don't double-jank // when the element is partially in view. The browser's default // focus scroll uses `block: 'nearest'` too but isn't smoothable // and can interact poorly with sticky headers. el.focus({ preventScroll: true }); } _scrollSelectionIntoView(el); } // Scroll the selected element to keep it inside a margin from the // viewport edges. Plain `scrollIntoView({block:'nearest'})` only // reacts when the element is fully off-screen, so during arrow nav // the selection drifts to the edge and stays partially visible // until it falls off — feels laggy. Centering when the row enters // the buffer zone keeps it comfortably on-screen as the user holds // the arrow keys. const _SCROLL_EDGE_MARGIN = 96; function _scrollSelectionIntoView(el) { if (!el) return; const r = el.getBoundingClientRect(); const vh = window.innerHeight || document.documentElement.clientHeight; if (r.top < _SCROLL_EDGE_MARGIN || r.bottom > vh - _SCROLL_EDGE_MARGIN) { el.scrollIntoView({ block: 'center', inline: 'nearest' }); } } function _restoreLibSelection(scopeEl, screen, { scroll = true } = {}) { // Re-apply the persistent `.selected` class to whichever song // matches the saved filename. For the tree we also walk up and // open every collapsed ancestor so the restored row is actually // visible — the user shouldn't have to hunt for their place // inside a collapsed artist node. if (!scopeEl) return null; const key = screen === 'favorites' ? _FAV_SELECTED_KEY : _LIB_SELECTED_KEY; const saved = _loadPersistedLibSelection(key); if (!saved || (!saved.f && !saved.s)) return null; // Match by dataset values — both stored and DOM values are in the // encoded form, so no decoding is needed. Avoid interpolating persisted // data into CSS selectors so malformed localStorage can't make // querySelector throw and break rendering. // // Local entries: match data-play (f) + data-library-provider (p) when p // is present to avoid cross-provider collisions on the same filename. // Remote entries: match data-library-song (s) + data-library-provider (p). // When f is present but no data-play card matches (e.g. the file has not // been downloaded on this load), fall back to the s (provider song-id) so // a previously-synced remote selection can still be restored. let el = null; if (saved.f) { const candidates = scopeEl.querySelectorAll('.song-card[data-play], .song-row[data-play]'); el = Array.from(candidates).find((node) => { if (node.dataset.play !== saved.f) return false; if (saved.p && node.dataset.libraryProvider !== saved.p) return false; return true; }); } if (!el && saved.s) { const candidates = scopeEl.querySelectorAll('.song-card[data-library-song], .song-row[data-library-song]'); el = Array.from(candidates).find((node) => { if (node.dataset.librarySong !== saved.s) return false; if (saved.p && node.dataset.libraryProvider !== saved.p) return false; return true; }); } if (!el) return null; // Open every collapsed ancestor in the tree so the restored row // is on-screen; harmless on the grid since cards have no such // ancestors. Sync `aria-expanded` on the matching header inside // each ancestor too — bypassing `_toggleHeader` here would leave // assistive tech reporting "collapsed" while the visual is open. let n = el.parentElement; while (n && n !== scopeEl) { if (n.classList.contains('artist-row') || n.classList.contains('album-group')) { n.classList.add('open'); const header = Array.from(n.children).find(c => c.classList.contains('artist-header') || c.classList.contains('album-header')); if (header) header.setAttribute('aria-expanded', 'true'); } n = n.parentElement; } if (_lastLibSelected && _lastLibSelected !== el) { _lastLibSelected.classList.remove('selected'); } el.classList.add('selected'); _lastLibSelected = el; // Center the restored element in the viewport so the user's eye // lands on it instead of having to scan up from the bottom edge. // `block: 'center'` is forgiving of items already on-screen — the // browser only scrolls when needed to bring the requested // alignment into view. // Skip when the caller opts out (e.g. during search/filter/sort // re-renders, where the user's scroll position should be left // alone and only the `.selected` class is re-applied). if (scroll) { el.scrollIntoView({ block: 'center', inline: 'nearest' }); } return el; } function _moveSelectionInItems(items, deltaIdx) { // Items are passed in by the caller so we don't re-query the DOM // twice per keypress (handler queries `_libNavItems`, then we'd // query it again). if (!items.length) return false; const current = document.activeElement && items.includes(document.activeElement) ? document.activeElement : (_lastLibSelected && items.includes(_lastLibSelected) ? _lastLibSelected : null); let idx = current ? items.indexOf(current) : -1; let next; if (idx === -1) { // No current selection — first arrow lands on the first item // regardless of direction. Saves a press. next = items[0]; } else { next = items[Math.max(0, Math.min(items.length - 1, idx + deltaIdx))]; } _setLibSelection(next); return true; } function _isInsideInteractiveControl(el) { // Bail when the user is interacting with anything that has its // own keyboard semantics — form controls (checkbox / select / // button) consume arrow keys for their own behavior, and the // filters drawer is a focus trap of those. Without this guard the // library's arrow nav would steal arrow presses from a focused // tuning checkbox or sort dropdown. if (!el) return false; const tag = el.tagName; if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(tag)) return true; if (el.isContentEditable) return true; if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal')) return true; return false; } function _isSpaceKey(e) { return e.key === ' ' || e.key === 'Spacebar'; } function _sectionPracticeBarContains(el) { if (!el) return false; const bar = document.getElementById('section-practice-bar'); return !!(bar && bar.contains(el)); } function _shortcutDispatchBlocked(e) { if (_isTextInput(e.target)) return true; // Space in Section Practice bar should pause/resume, not toggle checkboxes/buttons. if (_isSpaceKey(e) && _sectionPracticeBarContains(e.target)) return false; // While the Section Practice popover is open, Esc just closes it (handled by // the popover's own keydown listener) — suppress the player-scope // "back to library" Esc so the user doesn't get bounced out of the player. if (e.key === 'Escape' && _sectionPracticePopoverOpen()) return true; // Space on the player screen should always play/pause, even if focus is on a // sidebar nav link, player rail button, popover control, or any other // interactive element — the shortcut dispatcher calls preventDefault so the // focused element won't also activate. Two exceptions keep native Space: // text inputs (already exempted above), and focus inside a true modal // dialog (role="dialog" aria-modal="true", or a .feedBack-modal overlay) // layered over the player — a modal traps interaction, so Space must reach // its focused control (e.g. the Close button) rather than toggle playback // behind it. Non-modal player popovers/toasts (loop A/B, arrangement pin, // role="dialog" aria-modal="false") are not modals and stay covered. if (_isSpaceKey(e) && _getCurrentContext().isPlayer && !(e.target && e.target.closest && e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) { return false; } // Escape is the universal "back" action and must fire like Space above even // when a transport/rail control `; document.body.appendChild(modal); function finish(result) { modal.remove(); document.removeEventListener('keydown', onKey, true); if (previouslyFocused && document.body.contains(previouslyFocused)) { try { previouslyFocused.focus({ preventScroll: true }); } catch {} } resolve(result); } function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); finish(false); } else if (e.key === 'Enter' && document.activeElement === modal.querySelector('[data-confirm]')) { e.preventDefault(); finish(true); } } modal.addEventListener('click', (e) => { if (e.target === modal) finish(false); else if (e.target.closest('[data-confirm]')) finish(true); else if (e.target.closest('[data-cancel]')) finish(false); }); document.addEventListener('keydown', onKey, true); _trapFocusInModal(modal); // Focus Cancel by default for destructive prompts so an accidental // Enter / Space won't fire the dangerous action; otherwise focus // the confirm button so Enter accepts. const focusTarget = modal.querySelector(danger ? '[data-cancel]' : '[data-confirm]'); if (focusTarget) focusTarget.focus({ preventScroll: true }); }); } // Shortcut cheat-sheet overlay. Opens on `?` (Shift+/), closes on // Esc (handled by the generic modal close path) or on backdrop / // close-button click. The list mirrors the canonical shortcut table // in this file's keydown handler — when a shortcut changes here, the // table below should change too. We keep it inline rather than // fetching a separate file so the cheat sheet can never disagree // with the version of app.js the user actually loaded. function _openShortcutsModal() { if (document.getElementById('shortcuts-modal')) return; function _isTreeMode() { // Check if we're in tree view (not grid) on the active library screen const screen = document.querySelector('.screen.active'); if (!screen) return false; const tree = screen.querySelector('#lib-tree,#fav-tree'); return tree && !tree.classList.contains('hidden'); } const ctx = _getCurrentContext(); // Library shortcuts that are handled by the navigation system (not in registry) const navShortcuts = [ { keys: '↑ ↓', desc: 'Move selection' }, { keys: '→', desc: 'Step in', condition: _isTreeMode }, { keys: '←', desc: 'Step out', condition: _isTreeMode }, { keys: 'Home / End', desc: 'Jump to first / last item' }, { keys: 'Enter / Space', desc: 'Activate selection (play song / toggle header)' }, ]; // Filter out items whose condition returns false const filterNavItems = (items) => items.filter(item => !item.condition || item.condition()); // Format a shortcut entry for display, including modifier prefixes const formatShortcut = (s) => { const mods = s.modifiers || {}; let label = ''; if (mods.ctrl) label += 'Ctrl+'; if (mods.alt) label += 'Alt+'; if (mods.shift) label += 'Shift+'; if (mods.meta) label += 'Meta+'; return label + s.key; }; // Get shortcuts from active panel by scope const getPanelShortcuts = (panel, scope) => { const shortcuts = []; for (const [key, s] of panel.shortcuts) { if (s.scope === scope) { shortcuts.push({ keys: formatShortcut(s), desc: s.description }); } } return shortcuts; }; const activePanel = _panels.get(_activePanel); const defaultPanel = _panels.get('default'); // Merge shortcuts from both active and default panel for display const mergeShortcuts = (scope) => { const result = []; if (activePanel) result.push(...getPanelShortcuts(activePanel, scope)); if (defaultPanel && defaultPanel !== activePanel) result.push(...getPanelShortcuts(defaultPanel, scope)); return result; }; const playerShortcuts = mergeShortcuts('player'); const globalShortcuts = mergeShortcuts('global'); const libraryShortcuts = mergeShortcuts('library'); // Get plugin shortcuts for current plugin screen const pluginShortcuts = []; if (ctx.isPlugin && activePanel) { for (const [key, s] of activePanel.shortcuts) { if (s.scope.startsWith('plugin-') && s.scope === ctx.screen) { pluginShortcuts.push({ keys: formatShortcut(s), desc: s.description }); } } } // Get shortcuts from other panels (if multiple panels exist) const otherPanelShortcuts = []; if (_panels.size > 1) { for (const [panelId, panel] of _panels) { if (panelId === _activePanel) continue; for (const [key, s] of panel.shortcuts) { otherPanelShortcuts.push({ keys: formatShortcut(s), desc: s.description, panel: panelId }); } } } // Build sections based on current context const sections = []; if (ctx.isSettings) { sections.push({ heading: 'Settings', items: mergeShortcuts('settings') }); } else if (ctx.isLibrary) { sections.push({ heading: 'Library', items: [ ...filterNavItems(navShortcuts), ...libraryShortcuts, { keys: 'Esc', desc: 'Clear search' } ]}); } if (ctx.isPlayer) { sections.push({ heading: 'Player', items: playerShortcuts }); } if (!ctx.isSettings && globalShortcuts.length > 0) { sections.push({ heading: 'Global', items: globalShortcuts }); } if (pluginShortcuts.length > 0) { sections.push({ heading: 'Current Plugin', items: pluginShortcuts }); } if (otherPanelShortcuts.length > 0) { // Group other panel shortcuts by panel const byPanel = new Map(); for (const item of otherPanelShortcuts) { if (!byPanel.has(item.panel)) { byPanel.set(item.panel, []); } byPanel.get(item.panel).push(item); } for (const [panelId, items] of byPanel) { sections.push({ heading: `Panel ${panelId}`, items }); } } const modal = document.createElement('div'); modal.id = 'shortcuts-modal'; modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-label', 'Keyboard shortcuts'); // Record the element that triggered the modal so Esc / close can // return focus to the correct entry even if _lastLibSelected drifts. // Scope to the active screen so a stale _lastLibSelected from a // different screen (e.g. Library vs Favorites) doesn't receive focus. const _scModal = document.querySelector('.screen.active'); modal._opener = (_lastLibSelected && document.body.contains(_lastLibSelected) && _scModal && _scModal.contains(_lastLibSelected)) ? _lastLibSelected : null; const sectionsHtml = sections.map(section => { const itemsHtml = section.items.map(({ keys, desc }) => `
${esc(desc)} ${esc(keys)}
`).join(''); return `

${esc(section.heading)}

${itemsHtml}
`; }).join(''); modal.innerHTML = `

Keyboard shortcuts

${sectionsHtml}
`; // Click outside the inner panel (i.e. on the backdrop) closes the // modal — matches the conventional dialog UX. modal.addEventListener('click', (ev) => { if (ev.target === modal || ev.target.closest('[data-shortcuts-close]')) { const opener = modal._opener; modal.remove(); const focusTarget = (opener && document.body.contains(opener)) ? opener : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null); if (focusTarget) focusTarget.focus({ preventScroll: true }); } }); document.body.appendChild(modal); // Move focus into the dialog so background shortcuts (and arrow // nav) can't fire on the underlying library entry while the // overlay is open. Close button is the safe default — there's no // primary input to focus on a read-only cheat sheet. const closeBtn = modal.querySelector('[data-shortcuts-close]'); if (closeBtn) closeBtn.focus({ preventScroll: true }); // Trap Tab / Shift+Tab inside the modal so focus can't escape to // the library content underneath while the overlay is open. _trapFocusInModal(modal); } document.addEventListener('keydown', (e) => { // Modifier-key combos belong to the browser / OS shortcuts; never // intercept those. if (e.ctrlKey || e.metaKey || e.altKey) return; if (_handleLibArrowNav(e)) return; // `?` (Shift+/) opens the keyboard-shortcuts cheat sheet. Some // Linux/Electron stacks report Shift+/ as key='/' with code='Slash', // so check the help shape before treating plain '/' as search. if (_isShortcutHelpKey(e)) { if (_isShortcutHelpSuppressedTarget(e.target || document.activeElement)) return; e.preventDefault(); // Stop other keydown listeners on document (notably the shortcut // registry below) from also consuming this event — otherwise a // Linux/Electron Shift+Slash reported as key='/' opens help here and // then the registry's plain `/` library-search shortcut focuses // #lib-filter behind the modal. (Copilot review on #602.) e.stopImmediatePropagation(); _openShortcutsModal(); return; } if (e.key === '/') { if (_isTextInput(document.activeElement)) return; // Also bail when focus is inside the filter drawer, a dialog, or // any other interactive region — those contexts have their own // keyboard semantics and shouldn't be hijacked by the search // shortcut (e.g. a focused checkbox inside the filters drawer). if (_isInsideInteractiveControl(document.activeElement)) return; const search = _activeSearchInput(); if (!search) return; e.preventDefault(); // suppress the literal '/' the input would receive search.focus(); // Move caret to end without mutating .value — round-tripping // the value resets the browser's undo stack and can fire // unexpected input events on some engines. setSelectionRange // is the no-side-effects path. try { const len = search.value.length; search.setSelectionRange(len, len); } catch { // Some input types (search/email/tel) don't support // selection APIs in older browsers; the focus alone is // still useful, just no caret-end guarantee. } return; } // Single-letter shortcuts that act on the focused / selected // library entry — works on both grid cards and tree rows. Each // dispatches to a button class that the entry markup already // exposes, so plugins can keep owning the actual behavior: // f → .fav-btn (favorite heart toggle) // e → .edit-btn (edit metadata modal) // No-op when no entry is currently focused / selected, when the // entry doesn't expose the requested button, or when the button is disabled. // Bails on text input / drawer focus so single-letter typing in // inputs still works. const entryShortcut = { f: 'button.fav-btn', e: 'button.edit-btn' }[e.key.toLowerCase()]; if (entryShortcut) { if (_isInsideInteractiveControl(document.activeElement)) return; const ae = document.activeElement; const activeScreen = document.querySelector('.screen.active'); const isEntry = el => el && el.classList && (el.classList.contains('song-card') || el.classList.contains('song-row')); // Scope both candidates to the active screen so that a stale // _lastLibSelected from Library doesn't fire when the user is // on Favorites (or vice-versa), and so pressing f/e/c on a // hidden screen can't accidentally persist that filename into // the current screen's localStorage key. const inActiveScreen = el => activeScreen && activeScreen.contains(el); const target = (isEntry(ae) && inActiveScreen(ae)) ? ae : (isEntry(_lastLibSelected) && inActiveScreen(_lastLibSelected) ? _lastLibSelected : null); if (!target) return; const btn = target.querySelector(entryShortcut); if (!btn || btn.disabled) return; e.preventDefault(); // Sync the persistent selection to the acted-on entry so that // Esc-to-close-modal returns focus to the correct element and // the `.selected` highlight stays consistent with the action. _setLibSelection(target, { focus: false }); btn.click(); return; } if (e.key === 'Escape') { // Modal-first: close the topmost open modal (edit-metadata, // shortcuts cheat sheet, future modals) so Esc dismisses // from anywhere — including when keyboard focus is inside // a form field within the modal. Restores focus to the // element that opened the modal (tracked in modal._opener) // so arrow nav resumes without an extra Tab; falls back to // _lastLibSelected when the opener is no longer in the DOM. const modals = document.querySelectorAll('[role="dialog"][aria-modal="true"].feedBack-modal'); if (modals.length) { e.preventDefault(); e.stopImmediatePropagation(); const modal = modals[modals.length - 1]; const opener = modal._opener; modal.remove(); const focusTarget = (opener && document.body.contains(opener)) ? opener : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null); if (focusTarget) focusTarget.focus({ preventScroll: true }); return; } // Esc while typing in either search box clears + blurs. Other Esc // semantics (drawer close, screen back) are handled elsewhere; we // only act when a search box is the focused element. const ae = document.activeElement; if (ae && (ae.id === 'lib-filter' || ae.id === 'fav-filter')) { if (ae.value) { ae.value = ''; ae.dispatchEvent(new Event('input', { bubbles: true })); } ae.blur(); } } }); // ── Screen Navigation ───────────────────────────────────────────────────── async function showScreen(id) { // Capture the previous screen before changing active classes const prevScreenId = document.querySelector('.screen.active')?.id; document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); document.getElementById(id).classList.add('active'); // Mark the next render as a screen-entry so it scrolls the // restored selection into view exactly once. Routine renders // (search / sort / filter typing) won't have this flag set and // so won't yank the viewport. Also bump the nav-items // generation so the next keypress doesn't reuse a cache built // against a now-hidden screen's container. _bumpLibNavGeneration(); if (id === 'home') { _libScrollOnNextRender.home = true; const beforeProviderId = _activeLibraryProviderId(); await loadLibraryProviders({ restoreSaved: true }); if (_activeLibraryProviderId() !== beforeProviderId) { _resetLibraryProviderViewState(); } else { _libEpoch++; currentPage = 0; _treeStats = null; stopInfiniteScroll(); } loadLibrary(0); } if (id === 'favorites') { _libScrollOnNextRender.favorites = true; loadFavorites(); } if (id === 'settings') { // Record where we came from so Esc can go back. The player screen // is torn down by the `id !== 'player'` branch below, so // re-entering it via showScreen() would land on a dead screen — // fall back to the player's own origin (or 'home') instead. if (prevScreenId && prevScreenId !== 'settings') { _settingsOriginScreen = prevScreenId === 'player' ? (_playerOriginScreen || 'home') : prevScreenId; } loadSettings(); } if (id !== 'player') { const audio = document.getElementById('audio'); const stopTime = _audioTime(); const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || isPlaying; // Snapshot where we were so leaving the player — especially by accident // — is recoverable instead of dumping the user back at bar 1 next time. // Must run BEFORE highway.stop()/audio unload, while getSongInfo() and // the position (stopTime) are still live. if (hadPlayableSong) _snapshotResumeSession(stopTime); highway.stop(); // Cancel any queued seeks, in-flight shim closures, AND active // count-in timers before stopping playback so none of these paths // can mutate the torn-down session (mirrors the same triple reset // in playSong()). _cancelCountIn(); _resetJuceAudioShimChain(); _resetAudioSeekState(); if (window._juceMode) { // HTML5 emits 'pause' via the media-element listener below; // JUCE doesn't, so plugins would stay stuck in "playing". // Snapshot the canonical payload BEFORE stop() resets _pos // to 0, then emit AFTER stop completes. Mirrors the HTML5 // pause contract via _songEventPayload (audioT/chartT/perfNow). const payload = _songEventPayload(); const wasPlaying = isPlaying; await jucePlayer.stop().catch(() => {}); if (wasPlaying && window.feedBack) { window.feedBack.isPlaying = false; window.feedBack.emit('song:pause', payload); } window._juceMode = false; window._juceAudioUrl = null; } if (hadPlayableSong) window.feedBack.emit('song:stop', { time: stopTime || 0, screen: id }); audio.pause(); audio.src = ''; window._currentSongAudio = null; // Reloading any song later should get a fresh JUCE routing attempt. window._clearJuceRerouteMemo?.(); isPlaying = false; setPlayButtonState(false); } window.scrollTo(0, 0); if (window.feedBack) window.feedBack.emit('screen:changed', { id }); } // ── Library ────────────────────────────────────────────────────────────── // Persist the view toggle (grid vs tree), sort selection, and format // filter across reloads. Stored as separate keys (rather than one // blob) so future controls can opt in independently and a corrupted // single value doesn't wipe the rest. Validation lives at the read // site — we coerce unknown values back to safe defaults rather than // trusting whatever happens to be in localStorage. const _LIB_VIEW_KEY = 'feedBack.libView'; const _LIB_SORT_KEY = 'feedBack.libSort'; const _LIB_FORMAT_KEY = 'feedBack.libFormat'; const _LIB_PROVIDER_KEY = 'feedBack.libProvider'; const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']); const _LIB_SORT_VALUES = new Set([ 'artist', 'artist-desc', 'title', 'title-desc', 'recent', 'year-desc', 'year', 'tuning', ]); const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']); // Tree-view expand/collapse persistence. Three states per tree: // '1' → user asked to expand all // '0' → user asked to collapse all // null → no explicit choice; renderTreeInto's existing heuristic // (auto-open when search active or few artists) wins // // Library and Favorites are separate trees with separate // Expand/Collapse buttons, so each gets its own key — toggling one // must not flip the other's persisted state. const _LIB_TREE_EXPAND_KEY = 'feedBack.libTreeExpand'; const _FAV_TREE_EXPAND_KEY = 'feedBack.favTreeExpand'; const _LIB_TREE_EXPAND_VALUES = new Set(['1', '0']); function _readPersistedChoice(key, allowed, fallback) { try { const v = localStorage.getItem(key); return v !== null && allowed.has(v) ? v : fallback; } catch { return fallback; } } function _writePersistedChoice(key, value) { try { localStorage.setItem(key, value); } catch { /* private mode / quota */ } } function _libraryProviderApi() { const api = window.feedBack && window.feedBack.libraryProviders; return api && typeof api === 'object' ? api : null; } function _libraryProviderSnapshot() { const api = _libraryProviderApi(); if (api && typeof api.snapshot === 'function') return api.snapshot(); return { available: false, current: 'local', providers: [{ id: 'local', label: 'My Library', kind: 'local', capabilities: ['library.read', 'art.read', 'song.play'], default: true }] }; } function _providerById(providerId) { const api = _libraryProviderApi(); if (api && typeof api.providerById === 'function') return api.providerById(providerId); return (_libraryProviderSnapshot().providers || []).find(provider => provider.id === providerId) || null; } function _activeLibraryProvider() { const api = _libraryProviderApi(); if (api && typeof api.activeProvider === 'function') return api.activeProvider(); const snapshot = _libraryProviderSnapshot(); return _providerById(snapshot.current) || _providerById('local') || (snapshot.providers || [])[0]; } function _activeLibraryProviderId() { const api = _libraryProviderApi(); if (api && typeof api.activeProviderId === 'function') return api.activeProviderId(); return (_activeLibraryProvider() || {}).id || 'local'; } function _isLocalLibraryProvider(providerId) { const api = _libraryProviderApi(); if (api && typeof api.isLocal === 'function') return api.isLocal(providerId); const provider = _providerById(providerId); return providerId === 'local' || (provider && provider.kind === 'local'); } function _providerSupports(providerId, capability) { const api = _libraryProviderApi(); if (api && typeof api.supports === 'function') return api.supports(providerId, capability); const provider = _providerById(providerId); return !!provider && Array.isArray(provider.capabilities) && provider.capabilities.includes(capability); } function _applyLibraryProviderToParams(params) { params.set('provider', _activeLibraryProviderId()); return params; } function _resetLibraryProviderViewState() { _libEpoch++; currentPage = 0; _treePage = 0; _treeStats = null; _tuningNames = null; stopInfiniteScroll(); } function _renderLibraryProviderSelector() { const select = document.getElementById('lib-provider'); const title = document.getElementById('lib-title'); const activeProvider = _activeLibraryProvider(); const providers = _libraryProviderSnapshot().providers || []; if (select) { select.innerHTML = providers.map(provider => `` ).join(''); select.value = activeProvider.id; select.classList.toggle('hidden', providers.length <= 1); } if (title) title.textContent = activeProvider.id === 'local' ? 'Your Library' : (activeProvider.label || activeProvider.id); } async function loadLibraryProviders({ restoreSaved = false, reloadOnChange = false } = {}) { const beforeProviderId = _activeLibraryProviderId(); const api = _libraryProviderApi(); if (api && typeof api.refresh === 'function') { await api.refresh({ restoreSaved }); } _renderLibraryProviderSelector(); const afterProviderId = _activeLibraryProviderId(); if (reloadOnChange && afterProviderId !== beforeProviderId) { _resetLibraryProviderViewState(); loadLibrary(0); } } async function setLibraryProvider(providerId, options = {}) { const beforeProviderId = _activeLibraryProviderId(); try { const capabilityApi = window.feedBack && window.feedBack.capabilities; if (capabilityApi && typeof capabilityApi.command === 'function') { await capabilityApi.command('library', 'select-provider', { requester: 'app.library', target: { providerId }, payload: options && typeof options === 'object' ? options : {}, }); } else { _libraryProviderApi()?.select?.(String(providerId || '')); } } catch (err) { // Reached from an inline onchange="setLibraryProvider(this.value)" // handler that does not await us, so a rejection would otherwise // surface as an unhandled promise rejection. Log and bail without a // reload. Re-render the selector so the 's displayed value, so // re-render to snap it back to the provider that is actually active. _renderLibraryProviderSelector(); return; } _renderLibraryProviderSelector(); _resetLibraryProviderViewState(); loadLibrary(0); } function _libraryProviderIdForSong(song, fallbackProviderId) { return String( song.provider_id || song.providerId || song.library_provider_id || song.libraryProviderId || song.provider || fallbackProviderId || 'local' ); } function _librarySongId(song) { const songId = song.song_id || song.songId || song.remote_id || song.remoteId || song.id || song.filename || ''; return String(songId || ''); } function _libraryLocalFilename(song, providerId) { if (_isLocalLibraryProvider(providerId)) return song.filename ? String(song.filename) : ''; const filename = song.local_filename || song.localFilename || song.synced_filename || song.syncedFilename || song.play_filename || song.playFilename || ''; if (filename) return String(filename); const state = _librarySyncState(providerId, _librarySongId(song)); return state && state.status === 'synced' && state.localFilename ? String(state.localFilename) : ''; } function _libraryDisplayFilename(song, providerId) { return _libraryLocalFilename(song, providerId) || _librarySongId(song) || 'Unknown song'; } function _librarySongTitle(song, providerId) { const fallback = _libraryDisplayFilename(song, providerId); return song.title || fallback.replace(/_p\.archive$/i, '').replace(/_/g, ' '); } function _librarySongArtUrl(song, providerId) { const explicitArt = song.art_url || song.artUrl || song.cover_url || song.coverUrl; if (explicitArt) return _safeImageUrl(explicitArt); const version = song.mtime ? `?v=${Math.floor(song.mtime)}` : ''; const localFilename = _libraryLocalFilename(song, providerId); if (localFilename) return `/api/song/${encodeURIComponent(localFilename)}/art${version}`; if (_isLocalLibraryProvider(providerId)) return ''; if (!_providerSupports(providerId, 'art.read')) return ''; const songId = _librarySongId(song); return songId ? `/api/library/providers/${encodeURIComponent(providerId)}/songs/${encodeURIComponent(songId)}/art${version}` : ''; } function _safeImageUrl(value) { const raw = String(value || '').trim(); if (!raw) return ''; try { const parsed = new URL(raw, window.location.origin); return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : ''; } catch { return ''; } } const _librarySyncStates = new Map(); function _librarySyncKey(providerId, songId) { // JSON.stringify avoids delimiter collision: a newline in either value // would make "${p}\n${s}" ambiguous, but JSON-serialised arrays are // always distinct for distinct (providerId, songId) pairs. return JSON.stringify([providerId, songId]); } function _librarySyncState(providerId, songId) { return _librarySyncStates.get(_librarySyncKey(providerId, songId)) || null; } function _librarySyncStatusText(state) { if (!state) return ''; if (state.status === 'syncing') return 'Loading package...'; if (state.status === 'synced') return state.message || 'Ready to play'; if (state.status === 'error') return state.message ? `Load failed: ${state.message}` : 'Load failed'; return ''; } function _librarySyncStatusClass(state, layout) { const base = layout === 'inline' ? 'library-sync-status inline-block text-[11px] ml-1' : 'library-sync-status block mt-1 text-[11px] leading-snug'; if (!state) return `${base} hidden text-gray-500`; if (state.status === 'error') return `${base} text-red-300`; if (state.status === 'synced') return `${base} text-green-300`; return `${base} text-gray-400`; } function _librarySyncStatusMarkup(providerId, songId, layout = 'block') { const state = _librarySyncState(providerId, songId); return `${esc(_librarySyncStatusText(state))}`; } let libView = _readPersistedChoice(_LIB_VIEW_KEY, _LIB_VIEW_VALUES, 'grid'); let currentPage = 0; const PAGE_SIZE = 24; // Tree letter selection persists across reloads / coming back from // the player so the user lands on the same alphabet group they // picked. Validation: any single uppercase letter, or `#` for // non-alphabetical artists, or `''` for the All bucket. const _LIB_TREE_LETTER_KEY = 'feedBack.libTreeLetter'; const _FAV_TREE_LETTER_KEY = 'feedBack.favTreeLetter'; function _readPersistedLetter(key) { let v = null; try { v = localStorage.getItem(key); } catch { return ''; } if (v === null) return ''; return (v === '' || v === '#' || /^[A-Z]$/.test(v)) ? v : ''; } function _writePersistedLetter(key, value) { try { localStorage.setItem(key, value || ''); } catch { /* private mode / quota */ } } let _treeLetter = _readPersistedLetter(_LIB_TREE_LETTER_KEY); let _treeStats = null; let _debounceTimer = null; let _loadingMore = false; let _hasMore = true; let _gridObserver = null; // Bumped on filter/sort/view changes so in-flight page fetches can detect // they've been superseded and skip rendering stale results. let _libEpoch = 0; // ── Library filters (feedBack#129/#69) ──────────────────────────────── // // Filter state lives in a single object so the active set can be // serialized to localStorage as one key. Each axis is OR-within (Lead // + Rhythm = "has Lead OR Rhythm"); cross-axis is AND. Tri-state pills // translate to `_has` / `_lacks` lists on the wire so the server's // SQL doesn't have to encode the third "any" state. // In smart mode Combo is subsumed into Lead; only show Lead/Rhythm/Bass. // In legacy mode keep the original four values. // In-memory cache so a localStorage.setItem failure (private mode / quota / // disabled storage) still keeps the chosen mode for the rest of the session. // Initialised lazily from localStorage on first read. let _arrangementNamingMode = null; function _getArrangementNamingMode() { if (_arrangementNamingMode === 'smart' || _arrangementNamingMode === 'legacy') { return _arrangementNamingMode; } try { _arrangementNamingMode = localStorage.getItem('arrangementNamingMode') === 'legacy' ? 'legacy' : 'smart'; } catch (_) { _arrangementNamingMode = 'smart'; } return _arrangementNamingMode; } // In smart mode 'Combo' is subsumed into 'Lead' (_ensure_smart_names maps it // the same way). Normalize any persisted 'Combo' tokens before querying or // rendering so the UI and the server stay in sync. function _toSmartArrs(arr) { return arr.map(a => a === 'Combo' ? 'Lead' : a); } function _onNamingModeChange(value) { const mode = value === 'legacy' ? 'legacy' : 'smart'; _arrangementNamingMode = mode; try { localStorage.setItem('arrangementNamingMode', mode); } catch (_) {} if (mode === 'smart') { _libFilters.arrHas = _toSmartArrs(_libFilters.arrHas); _libFilters.arrLacks = _toSmartArrs(_libFilters.arrLacks); _saveLibFilters(); } _renderLibFilterDrawer(); _renderLibFilterChips(); _libEpoch++; currentPage = 0; _treeStats = null; loadLibrary(0); } function _getArrangements() { return _getArrangementNamingMode() === 'smart' ? ['Lead', 'Rhythm', 'Bass'] : ['Lead', 'Rhythm', 'Bass', 'Combo']; } function _arrangementBadgeHtml(arrangement, nm) { const label = (nm === 'smart' && arrangement.smart_name) ? arrangement.smart_name : arrangement.name; const cls = label.includes('Lead') ? 'bg-red-900/40 text-red-300' : label.includes('Rhythm') ? 'bg-blue-900/40 text-blue-300' : label.includes('Bass') ? 'bg-green-900/40 text-green-300' : 'bg-dark-600 text-gray-400'; return `${esc(label)}`; } // Stem ids match the bare strings sloppak manifests use ("drums", // "bass", etc.). `full` is intentionally omitted from the filter UI: // it's the fallback mix every sloppak ships with, so filtering by it // would match all sloppaks and confuse users. const _STEM_DEFS = [ { id: 'drums', label: 'Drums' }, { id: 'bass', label: 'Bass' }, { id: 'vocals', label: 'Vocals' }, { id: 'guitar', label: 'Guitar' }, { id: 'piano', label: 'Piano' }, { id: 'other', label: 'Other' }, ]; const _LIB_FILTERS_KEY = 'feedBack.libFilters'; let _libFilters = _loadLibFilters(); let _tuningNames = null; // cached from /api/library/tuning-names function _defaultLibFilters() { return { arrHas: [], arrLacks: [], stemsHas: [], stemsLacks: [], lyrics: null, // null | 1 | 0 tunings: [], }; } function _normalizeStringArray(v) { return Array.isArray(v) ? v.filter(x => typeof x === 'string' && x) : []; } function _normalizeLibFilters(parsed) { // Defensive: a stale or hand-edited localStorage payload could have // any shape. Without normalization a later `.join` or `.includes` // on a non-array would throw at filter-apply time. Coerce each // field back to its expected type, dropping anything we don't // recognize. FeedBack#134 review. if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return _defaultLibFilters(); } const lyrics = parsed.lyrics; return { arrHas: _normalizeStringArray(parsed.arrHas), arrLacks: _normalizeStringArray(parsed.arrLacks), stemsHas: _normalizeStringArray(parsed.stemsHas), stemsLacks: _normalizeStringArray(parsed.stemsLacks), lyrics: lyrics === 0 || lyrics === 1 ? lyrics : null, tunings: _normalizeStringArray(parsed.tunings), }; } function _loadLibFilters() { try { const raw = localStorage.getItem(_LIB_FILTERS_KEY); if (!raw) return _defaultLibFilters(); const filters = _normalizeLibFilters(JSON.parse(raw)); // Normalize any stale 'Combo' tokens left from legacy-mode sessions. if (_getArrangementNamingMode() === 'smart') { filters.arrHas = _toSmartArrs(filters.arrHas); filters.arrLacks = _toSmartArrs(filters.arrLacks); } return filters; } catch { return _defaultLibFilters(); } } function _saveLibFilters() { try { localStorage.setItem(_LIB_FILTERS_KEY, JSON.stringify(_libFilters)); } catch { /* private mode / quota — ignore, in-memory state still works */ } } function _libActiveCount() { let n = 0; if (_libFilters.arrHas.length) n++; if (_libFilters.arrLacks.length) n++; if (_libFilters.stemsHas.length) n++; if (_libFilters.stemsLacks.length) n++; if (_libFilters.lyrics !== null) n++; if (_libFilters.tunings.length) n++; return n; } function _applyLibFiltersToParams(params) { const nm = _getArrangementNamingMode(); params.set('naming_mode', nm); const arrHas = nm === 'smart' ? _toSmartArrs(_libFilters.arrHas) : _libFilters.arrHas; const arrLacks = nm === 'smart' ? _toSmartArrs(_libFilters.arrLacks) : _libFilters.arrLacks; if (arrHas.length) params.set('arrangements_has', arrHas.join(',')); if (arrLacks.length) params.set('arrangements_lacks', arrLacks.join(',')); if (_libFilters.stemsHas.length) params.set('stems_has', _libFilters.stemsHas.join(',')); if (_libFilters.stemsLacks.length) params.set('stems_lacks', _libFilters.stemsLacks.join(',')); if (_libFilters.lyrics !== null) params.set('has_lyrics', String(_libFilters.lyrics)); if (_libFilters.tunings.length) params.set('tunings', _libFilters.tunings.join(',')); return params; } function _pillState(item, hasList, lacksList) { if (hasList.includes(item)) return 'require'; if (lacksList.includes(item)) return 'exclude'; return 'any'; } function _cyclePill(item, hasKey, lacksKey) { // Cycle: any -> require -> exclude -> any. Mutates _libFilters in place. const hasList = _libFilters[hasKey]; const lacksList = _libFilters[lacksKey]; const inHas = hasList.indexOf(item); const inLacks = lacksList.indexOf(item); if (inHas === -1 && inLacks === -1) { hasList.push(item); } else if (inHas !== -1) { hasList.splice(inHas, 1); lacksList.push(item); } else { lacksList.splice(inLacks, 1); } _saveLibFilters(); _renderLibFilterDrawer(); _renderLibFilterChips(); _libEpoch++; currentPage = 0; _treeStats = null; // letter bar counts depend on filters now loadLibrary(0); } function _renderPillRow(containerId, items, hasKey, lacksKey, labelFor) { const c = document.getElementById(containerId); if (!c) return; c.innerHTML = ''; for (const it of items) { const id = typeof it === 'string' ? it : it.id; const label = labelFor ? labelFor(it) : id; const state = _pillState(id, _libFilters[hasKey], _libFilters[lacksKey]); const btn = document.createElement('button'); btn.type = 'button'; btn.className = `filter-pill state-${state}`; btn.textContent = label; btn.onclick = () => _cyclePill(id, hasKey, lacksKey); c.appendChild(btn); } } function _renderLyricsPill() { // Single tri-state pill matching the arrangement / stem pattern. // Cycle: any (null) -> require (1) -> exclude (0) -> any. const c = document.getElementById('filter-lyrics'); if (!c) return; c.innerHTML = ''; const v = _libFilters.lyrics; const state = v === 1 ? 'require' : v === 0 ? 'exclude' : 'any'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = `filter-pill state-${state}`; btn.textContent = 'Lyrics'; btn.onclick = () => { _libFilters.lyrics = v === null ? 1 : v === 1 ? 0 : null; _saveLibFilters(); _renderLyricsPill(); _renderLibFilterChips(); _libEpoch++; currentPage = 0; _treeStats = null; loadLibrary(0); }; c.appendChild(btn); } async function _renderTuningList() { const c = document.getElementById('filter-tunings'); if (!c) return; let fetchError = null; if (!_tuningNames) { const myEpoch = _libEpoch; c.innerHTML = '
Loading...
'; try { const params = _applyLibraryProviderToParams(new URLSearchParams()); const resp = await fetch(`/api/library/tuning-names?${params}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); // Guard against a provider switch that invalidated _tuningNames // while this request was in flight — discard a stale result. if (myEpoch !== _libEpoch) return; _tuningNames = Array.isArray(data.tunings) ? data.tunings : []; } catch (e) { if (myEpoch !== _libEpoch) return; // Distinguish a server / network failure from "the DB // genuinely has no tunings indexed". The latter wants a // Full Rescan; the former just wants a retry. Don't cache // the failure — leave _tuningNames null so reopening the // drawer triggers a fresh attempt. _tuningNames = null; fetchError = e.message || 'request failed'; } } c.innerHTML = ''; if (fetchError) { c.innerHTML = `
Failed to load tunings (${esc(fetchError)}). Reopen the drawer to retry.
`; return; } if (!_tuningNames.length) { c.innerHTML = '
No tunings indexed yet — try Full Rescan.
'; return; } for (const t of _tuningNames) { // Filter on the server grouping key (offsets for customs, name for named // tunings); label custom pills with their target notes so two "Custom // Tuning" entries are distinguishable. See tuning_names() in server.py. 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; } const checked = _libFilters.tunings.includes(val); const row = document.createElement('label'); row.className = 'tuning-row'; row.innerHTML = `` + `${esc(label)}` + `${t.count}`; const cb = row.querySelector('input'); cb.onchange = () => { const i = _libFilters.tunings.indexOf(val); if (cb.checked && i === -1) _libFilters.tunings.push(val); else if (!cb.checked && i !== -1) _libFilters.tunings.splice(i, 1); _saveLibFilters(); _updateLibFiltersBadge(); _renderLibFilterChips(); _renderTuningSummary(); _libEpoch++; currentPage = 0; _treeStats = null; loadLibrary(0); }; c.appendChild(row); } _renderTuningSummary(); } function _renderTuningSummary() { const s = document.getElementById('filter-tunings-summary'); if (!s) return; if (!_libFilters.tunings.length) { s.textContent = 'All tunings'; return; } if (_libFilters.tunings.length === 1) { s.textContent = _libFilters.tunings[0]; return; } s.textContent = `${_libFilters.tunings[0]} +${_libFilters.tunings.length - 1}`; } function _updateLibFiltersBadge() { const badge = document.getElementById('lib-filters-count'); if (!badge) return; const n = _libActiveCount(); badge.textContent = String(n); badge.classList.toggle('hidden', n === 0); } function _renderLibFilterDrawer() { _renderPillRow('filter-arrangements', _getArrangements(), 'arrHas', 'arrLacks'); _renderPillRow('filter-stems', _STEM_DEFS, 'stemsHas', 'stemsLacks', s => s.label); _renderLyricsPill(); _updateLibFiltersBadge(); } function _renderLibFilterChips() { const row = document.getElementById('lib-filter-chips'); if (!row) return; const chips = []; for (const a of _libFilters.arrHas) chips.push({ label: a, kind: 'require', remove: () => _libFilters.arrHas = _libFilters.arrHas.filter(x => x !== a) }); for (const a of _libFilters.arrLacks) chips.push({ label: `no ${a}`, kind: 'exclude', remove: () => _libFilters.arrLacks = _libFilters.arrLacks.filter(x => x !== a) }); for (const s of _libFilters.stemsHas) { const def = _STEM_DEFS.find(d => d.id === s); chips.push({ label: def ? def.label : s, kind: 'require', remove: () => _libFilters.stemsHas = _libFilters.stemsHas.filter(x => x !== s) }); } for (const s of _libFilters.stemsLacks) { const def = _STEM_DEFS.find(d => d.id === s); chips.push({ label: `no ${def ? def.label : s}`, kind: 'exclude', remove: () => _libFilters.stemsLacks = _libFilters.stemsLacks.filter(x => x !== s) }); } if (_libFilters.lyrics === 1) chips.push({ label: 'has lyrics', kind: 'require', remove: () => _libFilters.lyrics = null }); if (_libFilters.lyrics === 0) chips.push({ label: 'no lyrics', kind: 'exclude', remove: () => _libFilters.lyrics = null }); for (const t of _libFilters.tunings) chips.push({ label: t, kind: 'require', remove: () => _libFilters.tunings = _libFilters.tunings.filter(x => x !== t) }); row.innerHTML = ''; if (!chips.length) { row.classList.add('hidden'); return; } row.classList.remove('hidden'); for (const c of chips) { const el = document.createElement('span'); el.className = `chip ${c.kind === 'exclude' ? 'chip-exclude' : ''}`; // The "×" glyph isn't a reliable accessible name; assistive tech // also can't depend on `title` alone. Spell out the action plus // the chip's label in `aria-label` so screen-reader users hear // "Remove filter: Lead" instead of "button" or just "×". const ariaLabel = `Remove filter: ${c.label}`; el.innerHTML = `${esc(c.label)}`; el.querySelector('button').onclick = () => { c.remove(); _saveLibFilters(); _renderLibFilterDrawer(); _renderLibFilterChips(); _libEpoch++; currentPage = 0; _treeStats = null; loadLibrary(0); }; row.appendChild(el); } } function toggleLibFilters(force) { const drawer = document.getElementById('lib-filter-drawer'); const overlay = document.getElementById('lib-filter-overlay'); if (!drawer) return; const open = force === undefined ? !drawer.classList.contains('open') : !!force; drawer.classList.toggle('open', open); overlay.classList.toggle('hidden', !open); if (open) { _renderLibFilterDrawer(); _renderTuningList(); } } function clearLibFilters() { _libFilters = _defaultLibFilters(); _saveLibFilters(); _renderLibFilterDrawer(); _renderTuningList(); _renderLibFilterChips(); _libEpoch++; currentPage = 0; _treeStats = null; loadLibrary(0); } function setLibView(view) { libView = view; if (_LIB_VIEW_VALUES.has(view)) _writePersistedChoice(_LIB_VIEW_KEY, view); document.getElementById('lib-grid').classList.toggle('hidden', view !== 'grid'); document.getElementById('lib-tree').classList.toggle('hidden', view !== 'tree'); document.querySelectorAll('.lib-grid-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'grid')); document.querySelectorAll('.lib-tree-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'tree')); document.querySelectorAll('.lib-nontree-ctrl').forEach(el => el.classList.toggle('hidden', view === 'tree')); document.getElementById('view-grid-btn').className = `px-3 py-2.5 text-sm transition ${view === 'grid' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; document.getElementById('view-tree-btn').className = `px-3 py-2.5 text-sm transition ${view === 'tree' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; // Folder view const folderTreeEl = document.getElementById('lib-folder-tree'); if (folderTreeEl) folderTreeEl.classList.toggle('hidden', view !== 'folder'); const folderCtrlEl = document.getElementById('lib-folder-controls'); if (folderCtrlEl) folderCtrlEl.classList.toggle('hidden', view !== 'folder'); // The folder-view toolbar button only exists in the classic (v2) markup; // setLibView also runs at v3 startup where it's absent, so guard it (the // grid/tree buttons above predate this and exist on both paths). const folderBtnEl = document.getElementById('view-folder-btn'); if (folderBtnEl) folderBtnEl.className = `px-3 py-2.5 text-sm transition ${view === 'folder' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; if (libView === 'folder' && view !== 'folder') window.folderLibrary?.unload?.(); if (view !== 'grid') stopInfiniteScroll(); _libEpoch++; // View toggle changes which container `_libNavItems` resolves // to (tree vs grid) — drop the cache so the next keypress // re-derives. _bumpLibNavGeneration(); loadLibrary(); } async function loadLibrary(page) { if (libView === 'grid') { await loadGridPage(page !== undefined ? page : currentPage); } else if (libView === 'tree') { await loadTreeView(); } else if (libView === 'folder') { if (window.folderLibrary) await window.folderLibrary.load(); } // v3 Songs page manages its own view state independently of libView — if // lib-folder-tree is visible, the folder library must also react to filter changes. if (libView !== 'folder' && window.folderLibrary) { const treeEl = document.getElementById('lib-folder-tree'); if (treeEl && !treeEl.classList.contains('hidden')) { await window.folderLibrary.load(); } } } // ── Folder Library: filter bridge ───────────────────────────────────────── // Serialises the active lib filter state as URL params so the plugin can pass // them to /api/plugins/folder_library/tree — the same pattern grid and tree // views use when sending filter params to their own backend endpoints. window.feedBackLibFilterParams = function() { var p = new URLSearchParams(); _applyLibFiltersToParams(p); return p.toString(); }; async function _fetchJsonOrThrow(url) { const resp = await fetch(url); const raw = await resp.text(); let data = {}; let parseError = null; if (raw) { try { data = JSON.parse(raw); } catch (error) { parseError = error; } } if (!resp.ok) { const detail = String(data.detail || data.error || data.message || '').trim(); throw new Error(detail || `HTTP ${resp.status}`); } if (parseError) throw new Error('Malformed JSON response'); return data; } function _setLibraryOfflineMessage(containerId, countId, message) { const container = document.getElementById(containerId); const count = document.getElementById(countId); if (count) count.textContent = 'Source appears offline'; if (container) { container.innerHTML = `
${esc(message || 'This source appears to be offline.')}
`; } } function _setLibraryLoadingMessage(containerId, countId, message) { const container = document.getElementById(containerId); const count = document.getElementById(countId); if (count) count.textContent = 'Loading source...'; if (container) { container.innerHTML = `
${esc(message || 'Loading library...')}
`; } } function _libraryLoadingText() { const provider = _activeLibraryProvider(); if (!provider || provider.id === 'local' || provider.kind === 'local') { return 'Loading library...'; } return `Connecting to ${provider.label || provider.id}...`; } function filterLibrary() { clearTimeout(_debounceTimer); _debounceTimer = setTimeout(() => { _libEpoch++; currentPage = 0; _treeLetter = ''; // Letter-bar counts depend on `q` and the active filter set — // any change to those must invalidate the tree-view stats // cache or the next switch to tree view will render stale // letter counts (feedBack#134 review). _treeStats = null; loadLibrary(0); }, 250); } function sortLibrary() { // Persist whichever of the two dropdowns just changed so the next // page load can restore both. Both selects route through this // handler today; reading both is cheap and keeps the function // single-purpose. const sortEl = document.getElementById('lib-sort'); if (sortEl && _LIB_SORT_VALUES.has(sortEl.value)) { _writePersistedChoice(_LIB_SORT_KEY, sortEl.value); } const fmtEl = document.getElementById('lib-format'); if (fmtEl && _LIB_FORMAT_VALUES.has(fmtEl.value)) { _writePersistedChoice(_LIB_FORMAT_KEY, fmtEl.value); } _libEpoch++; currentPage = 0; // Same reason as filterLibrary: format dropdown changes the stats // payload, so the cache must drop too. _treeStats = null; loadLibrary(0); } // ── Grid View (server-side pagination, infinite scroll) ──────────────── async function loadGridPage(page = 0) { const myEpoch = _libEpoch; const q = document.getElementById('lib-filter').value.trim(); const sort = document.getElementById('lib-sort').value; const format = (document.getElementById('lib-format') || {}).value || ''; const params = new URLSearchParams({ q, page, size: PAGE_SIZE, sort }); if (format) params.set('format', format); _applyLibraryProviderToParams(params); _applyLibFiltersToParams(params); if (page === 0) { _setLibraryLoadingMessage('lib-grid', 'lib-count', _libraryLoadingText()); } let data; try { data = await _fetchJsonOrThrow(`/api/library?${params}`); } catch (error) { if (myEpoch !== _libEpoch) return; currentPage = 0; _hasMore = false; stopInfiniteScroll(); _setLibraryOfflineMessage('lib-grid', 'lib-count', error.message || 'This source appears to be offline.'); return; } if (myEpoch !== _libEpoch) return; // filter/sort/view changed mid-fetch currentPage = page; const total = data.total || 0; const songs = data.songs || []; document.getElementById('lib-count').textContent = `${total} songs`; renderGridCards(songs, 'lib-grid', page === 0 ? 'replace' : 'append'); _hasMore = (page + 1) * PAGE_SIZE < total; setupInfiniteScroll(); } function setupInfiniteScroll() { let sentinel = document.getElementById('lib-grid-sentinel'); if (!sentinel) { sentinel = document.createElement('div'); sentinel.id = 'lib-grid-sentinel'; sentinel.style.height = '1px'; document.getElementById('lib-grid').after(sentinel); } stopInfiniteScroll(); if (!_hasMore) return; _gridObserver = new IntersectionObserver(async (entries) => { if (entries[0].isIntersecting && !_loadingMore && _hasMore) { _loadingMore = true; try { await loadGridPage(currentPage + 1); } finally { _loadingMore = false; } } }, { rootMargin: '400px' }); _gridObserver.observe(sentinel); } function stopInfiniteScroll() { if (_gridObserver) { _gridObserver.disconnect(); _gridObserver = null; } } function formatBadge(fmt, stemCount) { if (fmt === 'sloppak' && (stemCount || 0) > 1) { return `STEMS`; } if (fmt === 'sloppak') { return `FEEDPAK`; } if (fmt === 'loose') { return `FOLDER`; } return ''; } function formatBadgeInline(fmt, stemCount) { if (fmt === 'sloppak' && (stemCount || 0) > 1) { return `STEMS`; } if (fmt === 'sloppak') { return `FEEDPAK`; } if (fmt === 'loose') { return `FOLDER`; } return ''; } function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace') { const grid = document.getElementById(containerId); const screenProviderId = containerId.startsWith('fav') ? 'local' : _activeLibraryProviderId(); const html = songs.map(song => { const providerId = _libraryProviderIdForSong(song, screenProviderId); const localFilename = _libraryLocalFilename(song, providerId); const songId = _librarySongId(song); const title = _librarySongTitle(song, providerId); const artist = song.artist || ''; const duration = song.duration ? formatTime(song.duration) : ''; const tuningRaw = song.tuning || song.tuning_name || ''; const tuning = displayTuningName(tuningRaw); const artUrl = _librarySongArtUrl(song, providerId); const isLocalProvider = _isLocalLibraryProvider(providerId); const isSloppak = song.format === 'sloppak'; // Use the canonical display label (displayTuningName names raw offset // strings too), not the raw token, so a row whose tuning is stored as // offsets still qualifies for the "Convert to E Standard" button. const stdRetune = isLocalProvider && localFilename && !isSloppak && tuning && !song.has_estd && ['Eb Standard', 'D Standard', 'C# Standard', 'C Standard'].includes(tuning); const retuneBtn = stdRetune ? `` : ''; const fmtBadge = formatBadge(song.format, song.stem_count); const syncStatus = !localFilename ? _librarySyncStatusMarkup(providerId, songId) : ''; const actionButtons = isLocalProvider && localFilename ? `${editBtn(song)}${heartBtn(localFilename, song.favorite)}` : ''; const canSync = !localFilename && _providerSupports(providerId, 'song.sync'); const isInteractive = !!localFilename || canSync; const providerAttr = `data-library-provider="${encodeURIComponent(providerId)}"`; // For provider-backed entries, keep data-library-song alongside // data-play once the song is synced so _restoreLibSelection can // still match the persisted remote selection after a re-render. const songAttr = !isLocalProvider ? ` data-library-song="${encodeURIComponent(songId)}"` : ''; const entryAttrs = localFilename ? `data-play="${encodeURIComponent(localFilename)}" ${providerAttr}${songAttr}` : `data-library-provider="${encodeURIComponent(providerId)}" data-library-song="${encodeURIComponent(songId)}"`; const ariaAction = localFilename ? 'Play' : 'Load and play'; const ariaLabel = `${ariaAction} ${title || _libraryDisplayFilename(song, providerId)}${artist ? ' by ' + artist : ''}`; const displayLabel = `${title || _libraryDisplayFilename(song, providerId)}${artist ? ' by ' + artist : ''}`; const interactiveAttrs = isInteractive ? `tabindex="0" role="button" aria-label="${_escAttr(ariaLabel)}"` : `role="listitem" aria-label="${_escAttr(displayLabel)}"`; const artHtml = artUrl ? ` ` : `🎸`; return `
${artHtml} ${fmtBadge}

${esc(title)}

${esc(artist)}

${actionButtons}
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()} ${tuning ? `${esc(tuning)}` : ''} ${song.has_lyrics ? `Lyrics` : ''} ${duration ? `${duration}` : ''}
${retuneBtn} ${syncStatus}
`; }).join(''); if (mode === 'append') { grid.insertAdjacentHTML('beforeend', html); } else { grid.innerHTML = html; } // Items list invalidation: any DOM mutation to the grid changes // the result of the next `_libNavItems` call. _bumpLibNavGeneration(); // Re-apply the persistent selection after a fresh render so the // user's last picked card stays highlighted across reloads / a // round-trip through the player. Skip this during `append` mode // (infinite scroll) so restoring selection can't re-center the // viewport and yank the user away from the newly loaded page. // When a search input is focused the user is actively filtering — // re-apply the highlight but don't move the viewport (they didn't // leave the page and their scroll position should be preserved). if (mode !== 'append') { const screen = containerId.startsWith('fav') ? 'favorites' : 'home'; // Scroll only on the first render after a screen entry — // routine search / sort / filter renders re-apply the // highlight without moving the viewport. The flag is // one-shot and consumed here. const scroll = _libScrollOnNextRender[screen]; if (scroll) _libScrollOnNextRender[screen] = false; _restoreLibSelection(grid, screen, { scroll }); } } // ── Tree View (server-side) ───────────────────────────────────────────── async function loadTreeView() { const myEpoch = _libEpoch; if (!_treeStats) { _setLibraryLoadingMessage('lib-tree', 'lib-count', _libraryLoadingText()); const q = document.getElementById('lib-filter').value.trim(); const format = (document.getElementById('lib-format') || {}).value || ''; const sp = new URLSearchParams(); if (q) sp.set('q', q); if (format) sp.set('format', format); _applyLibraryProviderToParams(sp); _applyLibFiltersToParams(sp); const qs = sp.toString(); try { _treeStats = await _fetchJsonOrThrow(`/api/library/stats${qs ? '?' + qs : ''}`); } catch (error) { if (myEpoch !== _libEpoch) return; _treeStats = null; _setLibraryOfflineMessage('lib-tree', 'lib-count', error.message || 'This source appears to be offline.'); return; } if (myEpoch !== _libEpoch) return; } const q = document.getElementById('lib-filter').value.trim(); await renderTreeInto('lib-tree', 'lib-count', _treeStats, _treeLetter, q, false, undefined, myEpoch); } let _treePage = 0; const TREE_PAGE_SIZE = 50; async function renderTreeInto(containerId, countId, stats, letter, q, favoritesOnly, page, expectedEpoch = _libEpoch) { if (page === undefined) page = favoritesOnly ? _favTreePage || 0 : _treePage; const container = document.getElementById(containerId); const screenProviderId = favoritesOnly ? 'local' : _activeLibraryProviderId(); const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split(''); const chevron = ``; const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter'; const pageFn = favoritesOnly ? 'goFavTreePage' : 'goTreePage'; let html = '
'; html += ``; for (const l of letters) { const count = stats.letters[l] || 0; const active = letter === l; html += ``; } html += '
'; // Fetch artists for the selected letter/all const params = new URLSearchParams(); if (letter) params.set('letter', letter); if (q) params.set('q', q); if (favoritesOnly) params.set('favorites', '1'); else _applyLibraryProviderToParams(params); const format = (document.getElementById('lib-format') || {}).value || ''; if (format) params.set('format', format); if (!favoritesOnly) _applyLibFiltersToParams(params); params.set('page', page); params.set('size', TREE_PAGE_SIZE); let data; try { data = await _fetchJsonOrThrow(`/api/library/artists?${params}`); } catch (error) { if (expectedEpoch !== _libEpoch) return; _setLibraryOfflineMessage(containerId, countId, error.message || 'This source appears to be offline.'); return; } if (expectedEpoch !== _libEpoch) return; const artists = data.artists || []; const totalArtists = data.total_artists || 0; const totalPages = Math.ceil(totalArtists / TREE_PAGE_SIZE); let songCount = 0, artistCount = artists.length; for (const a of artists) songCount += a.song_count; const pageInfo = totalPages > 1 ? ` · Page ${page + 1} of ${totalPages}` : ''; document.getElementById(countId).textContent = `${totalArtists} artists (${songCount} songs on this page)${pageInfo}`; // A previous Expand/Collapse-All click is persisted as '1'/'0' and // overrides the auto-open heuristic for both artists and albums. // Library and Favorites have independent buttons and independent // keys (feedBack.libTreeExpand vs feedBack.favTreeExpand) — fed // off the favoritesOnly flag — so toggling one doesn't flip the // other's state. Falsy / unset key → fall back to the existing // heuristic (open when there's an active search or few rows). const expandKey = favoritesOnly ? _FAV_TREE_EXPAND_KEY : _LIB_TREE_EXPAND_KEY; const savedExpand = _readPersistedChoice(expandKey, _LIB_TREE_EXPAND_VALUES, null); const forceArtistOpen = savedExpand === '1'; const forceArtistClosed = savedExpand === '0'; for (const artist of artists) { const heuristicOpen = q || artists.length <= 5; const isOpen = forceArtistOpen ? true : forceArtistClosed ? false : heuristicOpen; const openClass = isOpen ? ' open' : ''; const artistAria = _escAttr(`Toggle artist ${artist.name}`); html += `
`; html += `
`; html += chevron; html += `${esc(artist.name)}`; html += `${artist.song_count} song${artist.song_count !== 1 ? 's' : ''} · ${artist.album_count} album${artist.album_count !== 1 ? 's' : ''}`; html += `
`; for (const album of artist.albums) { const albumSongs = Array.isArray(album.songs) ? album.songs : []; const artSong = albumSongs[0] || {}; const artProviderId = _libraryProviderIdForSong(artSong, screenProviderId); const artUrl = _librarySongArtUrl(artSong, artProviderId); const albumHeuristicOpen = q || artist.albums.length === 1; const albumIsOpen = forceArtistOpen ? true : forceArtistClosed ? false : albumHeuristicOpen; const albumOpen = albumIsOpen ? ' open' : ''; const albumAria = _escAttr(`Toggle album ${album.name}`); html += `
`; html += `
`; html += chevron; if (artUrl) html += ``; html += `${esc(album.name)}`; html += `${albumSongs.length}`; html += `
`; for (const song of albumSongs) { const providerId = _libraryProviderIdForSong(song, screenProviderId); const localFilename = _libraryLocalFilename(song, providerId); const songId = _librarySongId(song); const title = _librarySongTitle(song, providerId); const duration = song.duration ? formatTime(song.duration) : ''; const tuningRaw = song.tuning || song.tuning_name || ''; const tuning = displayTuningName(tuningRaw); const isLocalProvider = _isLocalLibraryProvider(providerId); const isSloppak = song.format === 'sloppak'; const stdRetune = isLocalProvider && localFilename && !isSloppak && tuningRaw && !song.has_estd && ['Eb Standard', 'D Standard', 'C# Standard', 'C Standard'].includes(tuningRaw); const canSyncRow = !localFilename && _providerSupports(providerId, 'song.sync'); const isInteractiveRow = !!localFilename || canSyncRow; const providerAttr = `data-library-provider="${encodeURIComponent(providerId)}"`; // Keep data-library-song alongside data-play for provider-backed // entries once synced so _restoreLibSelection can still find the // card after a post-sync re-render. const rowSongAttr = !isLocalProvider ? ` data-library-song="${encodeURIComponent(songId)}"` : ''; const rowAttrs = localFilename ? `data-play="${encodeURIComponent(localFilename)}" ${providerAttr}${rowSongAttr}` : `data-library-provider="${encodeURIComponent(providerId)}" data-library-song="${encodeURIComponent(songId)}"`; const ariaAction = localFilename ? 'Play' : 'Load and play'; const rowAria = _escAttr(`${ariaAction} ${title}${artist.name ? ' by ' + artist.name : ''}`); const rowDisplayLabel = `${title}${artist.name ? ' by ' + artist.name : ''}`; const rowInteractiveAttrs = isInteractiveRow ? `tabindex="0" role="button" aria-label="${rowAria}"` : `role="listitem" aria-label="${_escAttr(rowDisplayLabel)}"`; html += `
`; html += `
${esc(title)}${formatBadgeInline(song.format, song.stem_count)}
`; html += `
`; { const _nm = _getArrangementNamingMode(); for (const arrangement of (song.arrangements || [])) html += _arrangementBadgeHtml(arrangement, _nm); } if (tuning) html += `${esc(tuning)}`; if (song.has_lyrics) html += `Lyrics`; if (duration) html += `${duration}`; if (stdRetune) html += ``; if (isLocalProvider && localFilename) { html += editBtn(song); html += heartBtn(localFilename, song.favorite); } else if (!localFilename) { html += _librarySyncStatusMarkup(providerId, songId, 'inline'); } html += `
`; } html += `
`; } html += `
`; } // Pagination if (totalPages > 1) { html += '
'; html += ``; html += ``; const start = Math.max(0, page - 2); const end = Math.min(totalPages, start + 5); for (let i = start; i < end; i++) { html += ``; } html += ``; html += ``; html += '
'; } container.innerHTML = html; // Items list invalidation — see grid render counterpart. _bumpLibNavGeneration(); // Re-apply the persisted selection. For the tree we also expand // every collapsed ancestor of the saved row so the highlight is // actually visible — see _restoreLibSelection. Scroll only on // the first render after a screen entry (one-shot flag set in // showScreen) so routine renders don't yank the viewport. const screen = favoritesOnly ? 'favorites' : 'home'; const scroll = _libScrollOnNextRender[screen]; if (scroll) _libScrollOnNextRender[screen] = false; _restoreLibSelection(container, screen, { scroll }); } function goTreePage(p) { _treePage = Math.max(0, p); loadTreeView(); document.getElementById('library-section').scrollIntoView({ behavior: 'smooth' }); } function filterTreeLetter(letter) { _treeLetter = (_treeLetter === letter) ? '' : letter; _treePage = 0; _writePersistedLetter(_LIB_TREE_LETTER_KEY, _treeLetter); loadTreeView(); } function _toggleAllInTree(containerId, expand, persistKey) { // Scope the open/close to the named tree's container so toggling // Library doesn't flip the (offscreen) Favorites DOM and vice // versa — they share `.artist-row` / `.album-group` classes. const container = document.getElementById(containerId); if (!container) return; container.querySelectorAll('.artist-row').forEach(el => el.classList.toggle('open', expand)); container.querySelectorAll('.album-group').forEach(el => el.classList.toggle('open', expand)); // Bulk open/close changes which song-rows pass the visibility // filter in `_libNavItems` — same reason `_toggleHeader` bumps // the generation. Without this, a stale cached items list from // before the toggle would let arrow nav step into now-hidden // rows. _bumpLibNavGeneration(); // Persist the explicit choice so the next page reload (or letter // change, which re-runs renderTreeInto) honors it instead of // falling back to the auto-open heuristic. Stored as '1'/'0' so a // missing key reliably means "no explicit choice". _writePersistedChoice(persistKey, expand ? '1' : '0'); } function toggleAllArtists(expand) { _toggleAllInTree('lib-tree', expand, _LIB_TREE_EXPAND_KEY); } function toggleAllFavoriteArtists(expand) { _toggleAllInTree('fav-tree', expand, _FAV_TREE_EXPAND_KEY); } // Display-only tuning label helpers — never mutate offsets or affect playback. function _looksLikeRawTuningOffsets(str) { if (!str || typeof str !== 'string') return false; const s = str.trim(); if (!s) return false; if (/^-?\d+$/.test(s)) return true; if (/^-?\d+(?: -?\d+)+$/.test(s)) return true; if (/^-?\d+(?:,-?\d+)+$/.test(s)) return true; if (/^-?\d+(-?\d+){2,}$/.test(s)) return true; return false; } function _tuningNameFromOffsets(offsets) { if (!offsets || !offsets.length) return ''; const standard = { 0: 'E Standard', '-1': 'Eb Standard', '-2': 'D Standard', '-3': 'C# Standard', '-4': 'C Standard', '-5': 'B Standard', '-6': 'Bb Standard', '-7': 'A Standard', 1: 'F Standard', 2: 'F# Standard', }; // Uniform offsets across 4 (bass) / 5 / 6 strings name the same Standard; // a 4-string bass [0,0,0,0] must read "E Standard", not "Custom Tuning". if (offsets.length >= 4 && offsets.every((o) => o === offsets[0])) { const name = standard[offsets[0]]; if (name) return name; } if (offsets.length >= 4 && offsets[0] === offsets[1] - 2 && offsets.slice(1).every((o) => o === offsets[1])) { const noteNames = ['E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'C#', 'D', 'Eb']; return 'Drop ' + noteNames[((offsets[0] % 12) + 12) % 12]; } const named = { '-2,0,0,0,0,0': 'Drop D', '-4,-2,-2,-2,-2,-2': 'Drop C', '-2,-2,0,0,0,0': 'Double Drop D', '0,0,0,-1,0,0': 'Open G', '-2,-2,0,0,-2,-2': 'Open D', '-2,0,0,0,-2,0': 'DADGAD', '0,2,2,1,0,0': 'Open E', '-2,0,0,2,3,2': 'Open D (alt)', }; if (offsets.length === 6) { const key = offsets.join(','); if (named[key]) return named[key]; } return 'Custom Tuning'; } function displayTuningName(value, offsets) { // Explicit offsets win — always name them. if (Array.isArray(offsets) && offsets.length > 0) { return _tuningNameFromOffsets(offsets); } if (value && typeof value === 'string') { const trimmed = value.trim(); if (!trimmed || trimmed === 'Unknown') return ''; if (!_looksLikeRawTuningOffsets(trimmed)) { return trimmed; } // A raw offset string (now served by the API) — parse and name it so a // known tuning like "-1 -1 -1 -1 -1 -1" reads "Eb Standard" rather than // collapsing to "Custom Tuning". const parsed = (typeof parseRawTuningOffsets === 'function') ? parseRawTuningOffsets(trimmed) : null; if (parsed && parsed.length) return _tuningNameFromOffsets(parsed); return 'Custom Tuning'; } return ''; } window.displayTuningName = displayTuningName; window.feedBack = window.feedBack || {}; window.slopsmith = window.feedBack; window.feedBack.displayTuningName = displayTuningName; function isBassArrangement(context) { const ctx = context && typeof context === 'object' ? context : {}; if (typeof ctx.isBass === 'boolean') return ctx.isBass; const label = ((ctx.arrangement || '') + ' ' + (ctx.arrangement_smart_name || '')).toLowerCase(); if (/\bbass\b/.test(label)) return true; if (/\b(lead|rhythm|combo|guitar)\b/.test(label)) return false; return false; } function effectiveStringCount(offsets, context) { if (!Array.isArray(offsets) || !offsets.length) return 0; const ctx = context && typeof context === 'object' ? context : {}; const isBass = isBassArrangement(ctx); let sc = ctx.stringCount > 0 ? Number(ctx.stringCount) : 0; if (!isBass) { if (sc > 0 && sc <= 5 && offsets.length >= 6) sc = 6; if (!sc) sc = offsets.length >= 6 ? offsets.length : 6; } else if (!sc) { sc = offsets.length >= 5 ? offsets.length : 4; } return Math.min(sc, offsets.length); } function songTuningContext(songInfo) { if (!songInfo || typeof songInfo !== 'object') return {}; return { stringCount: songInfo.stringCount, arrangement: songInfo.arrangement, arrangement_smart_name: songInfo.arrangement_smart_name, }; } window.feedBack.isBassArrangement = isBassArrangement; window.feedBack.effectiveStringCount = effectiveStringCount; window.feedBack.songTuningContext = songTuningContext; // Open-string target notes (display only) — mirrors plugins/tuner/utils/tuning-utils.js. const _TUNING_BASE_MIDI = { 4: [28, 33, 38, 43], 5: [23, 28, 33, 38, 43], 6: [40, 45, 50, 55, 59, 64], 7: [35, 40, 45, 50, 55, 59, 64], 8: [30, 35, 40, 45, 50, 55, 59, 64], }; const _TUNING_NOTE_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const _TUNING_NOTE_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; function _tuningMidiToFreq(m) { return Math.pow(2, (m - 69) / 12) * 440; } function _tuningOffsetsToFreqs(offsets, isBass) { const len = offsets.length; let base; if (len === 4 || len === 5) { base = isBass ? _TUNING_BASE_MIDI[len] : _TUNING_BASE_MIDI[6]; } else { base = _TUNING_BASE_MIDI[len] || _TUNING_BASE_MIDI[6]; } return offsets.map((offset, i) => { const root = i < base.length ? base[i] : base[base.length - 1]; return _tuningMidiToFreq(root + offset); }); } function _noteNameFromFreq(freq, useFlats) { const midi = 69 + 12 * Math.log2(freq / 440); const rounded = Math.round(midi); const names = useFlats ? _TUNING_NOTE_FLAT : _TUNING_NOTE_SHARP; return names[((rounded % 12) + 12) % 12]; } function _octaveNoteFromFreq(freq, useFlats) { const midi = 69 + 12 * Math.log2(freq / 440); const rounded = Math.round(midi); const octave = Math.floor(rounded / 12) - 1; return _noteNameFromFreq(freq, useFlats) + octave; } function _stringOrdinalLabel(n) { const v = n % 100; if (v >= 11 && v <= 13) return n + 'th'; const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th'; return n + suffix; } function _tuningTargetFreqs(offsets, context) { if (!Array.isArray(offsets) || !offsets.length) return []; const ctx = context && typeof context === 'object' ? context : {}; const stringCount = effectiveStringCount(offsets, ctx); const trimmed = offsets.slice(0, stringCount); if (!trimmed.length) return []; const isBass = isBassArrangement(ctx); try { return _tuningOffsetsToFreqs(trimmed, isBass); } catch (_) { return []; } } // Flat vs sharp spelling. A caller that knows the preference can pass // ctx.useFlats; otherwise we infer from a flat-keyed tuning name. The v3 // card/HUD pass "Custom Tuning" (raw offsets carry no key), so those default // to sharps unless an explicit useFlats is supplied. function _resolveTargetUseFlats(ctx) { if (typeof ctx.useFlats === 'boolean') return ctx.useFlats; return typeof ctx.tuningName === 'string' && /\b[A-G]b\b/.test(ctx.tuningName); } function displayTuningTargetDetails(offsets, context) { const ctx = context && typeof context === 'object' ? context : {}; const useFlats = _resolveTargetUseFlats(ctx); const freqs = _tuningTargetFreqs(offsets, ctx); return freqs.map((f, i) => { const stringNumber = freqs.length - i; const note = _noteNameFromFreq(f, useFlats); const octaveNote = _octaveNoteFromFreq(f, useFlats); return { stringNumber, note, octaveNote, title: _stringOrdinalLabel(stringNumber) + ' string: ' + octaveNote, }; }); } function displayTuningTargets(offsets, context) { const ctx = context && typeof context === 'object' ? context : {}; const useFlats = _resolveTargetUseFlats(ctx); const freqs = _tuningTargetFreqs(offsets, ctx); if (!freqs.length) return ''; return freqs.map((f) => _noteNameFromFreq(f, useFlats)).join(' '); } function parseRawTuningOffsets(value) { if (Array.isArray(value) && value.length) return value; if (!value || typeof value !== 'string') return null; const s = value.trim(); if (/^-?\d+(?: -?\d+)+$/.test(s)) { return s.split(/\s+/).map((n) => Number(n)); } if (/^-?\d+(?:,-?\d+)+$/.test(s)) { return s.split(',').map((n) => Number(n)); } return null; } window.displayTuningTargets = displayTuningTargets; window.displayTuningTargetDetails = displayTuningTargetDetails; window.parseRawTuningOffsets = parseRawTuningOffsets; window.feedBack.displayTuningTargets = displayTuningTargets; window.feedBack.displayTuningTargetDetails = displayTuningTargetDetails; window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets; function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // `esc()` escapes the HTML-content metacharacters (<, >, &) but not // quotes — fine for text-node interpolation but unsafe when the // result is used as an attribute value, where a literal `"` ends the // attribute early. Use `_escAttr` for any `attr="${...}"` site. function _escAttr(s) { return esc(s == null ? '' : String(s)) .replace(/"/g, '"') .replace(/'/g, '''); } // Toggle an artist/album header's parent `.open` state and keep // `aria-expanded` on the header itself in sync so screen readers // announce the collapsed/expanded transition correctly. Used by // both the inline onclick (mouse) and the keyboard handlers. function _toggleHeader(headerEl) { if (!headerEl) return; const parent = headerEl.parentElement; if (!parent) return; parent.classList.toggle('open'); headerEl.setAttribute('aria-expanded', parent.classList.contains('open') ? 'true' : 'false'); // Toggling open/closed changes which song-rows pass the // visibility filter in `_libNavItems`, so the cached items list // is now stale. _bumpLibNavGeneration(); } // Called by the inline onclick on artist- and album-headers so the // mouse-click path also syncs the persistent `.selected` state — // keeps arrow-nav resuming from the last-clicked header rather than // from a stale highlight on a different element. function _onHeaderClick(el) { _toggleHeader(el); _setLibSelection(el, { focus: false }); } // ── Favorites ──────────────────────────────────────────────────────────── let favView = 'grid'; let favPage = 0; let _favTreeLetter = _readPersistedLetter(_FAV_TREE_LETTER_KEY); let _favTreePage = 0; let _favTreeStats = null; let _favDebounce = null; function heartBtn(filename, isFav) { return ``; } function editBtn(song) { return ``; } async function toggleFavorite(filename) { const resp = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }), }); const data = await resp.json(); // Refresh whichever view is active const activeScreen = document.querySelector('.screen.active'); if (activeScreen?.id === 'favorites') loadFavorites(); else loadLibrary(); return data.favorite; } function setFavView(view) { favView = view; document.getElementById('fav-grid').classList.toggle('hidden', view !== 'grid'); document.getElementById('fav-tree').classList.toggle('hidden', view !== 'tree'); document.querySelectorAll('.fav-grid-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'grid')); document.querySelectorAll('.fav-tree-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'tree')); document.getElementById('fav-view-grid-btn').className = `px-3 py-2.5 text-sm transition ${view === 'grid' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; document.getElementById('fav-view-tree-btn').className = `px-3 py-2.5 text-sm transition ${view === 'tree' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`; const pag = document.getElementById('fav-pagination'); if (pag && view !== 'grid') pag.innerHTML = ''; // Same reason as setLibView: dropping the items cache so the // next keypress re-derives against the now-active container. _bumpLibNavGeneration(); loadFavorites(); } async function loadFavorites() { if (favView === 'grid') await loadFavGridPage(favPage); else await loadFavTreeView(); } function filterFavorites() { clearTimeout(_favDebounce); _favDebounce = setTimeout(() => { favPage = 0; _favTreeLetter = ''; loadFavorites(); }, 250); } function sortFavorites() { favPage = 0; loadFavorites(); } async function loadFavGridPage(page = 0) { const q = document.getElementById('fav-filter').value.trim(); const sort = document.getElementById('fav-sort').value; favPage = page; const params = new URLSearchParams({ q, page, size: PAGE_SIZE, sort, favorites: 1 }); const resp = await fetch(`/api/library?${params}`); const data = await resp.json(); const totalPages = Math.ceil((data.total || 0) / PAGE_SIZE); document.getElementById('fav-count').textContent = `${data.total || 0} favorites · Page ${favPage + 1} of ${Math.max(1, totalPages)}`; renderGridCards(data.songs || [], 'fav-grid'); renderFavPagination(totalPages); } function renderFavPagination(totalPages) { let pag = document.getElementById('fav-pagination'); if (!pag) { pag = document.createElement('div'); pag.id = 'fav-pagination'; pag.className = 'flex items-center justify-center gap-2 py-6'; document.getElementById('fav-grid').after(pag); } if (totalPages <= 1) { pag.innerHTML = ''; return; } let html = ''; html += ``; html += ``; const start = Math.max(0, favPage - 2); const end = Math.min(totalPages, start + 5); for (let i = start; i < end; i++) { html += ``; } html += ``; html += ``; pag.innerHTML = html; } function goFavPage(p) { loadFavGridPage(Math.max(0, p)); } async function loadFavTreeView() { if (!_favTreeStats) { const resp = await fetch('/api/library/stats?favorites=1'); _favTreeStats = await resp.json(); } const q = document.getElementById('fav-filter').value.trim(); const letter = _favTreeLetter; // Reuse the tree renderer with fav-tree container and fav-count await renderTreeInto('fav-tree', 'fav-count', _favTreeStats, letter, q, true); } function filterFavTreeLetter(letter) { _favTreeLetter = (_favTreeLetter === letter) ? '' : letter; _favTreePage = 0; _writePersistedLetter(_FAV_TREE_LETTER_KEY, _favTreeLetter); loadFavTreeView(); } function goFavTreePage(p) { _favTreePage = Math.max(0, p); loadFavTreeView(); } // ── Settings ───────────────────────────────────────────────────────────── let _defaultArrangement = ''; const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio']; function _normalizeInstrumentPathway(value) { return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs'; } function _syncDefaultArrangementSelect(value) { const sel = document.getElementById('default-arrangement'); if (!sel) return; const wanted = value || ''; const existing = Array.from(sel.options).find(opt => opt.value === wanted); const dynamic = sel.querySelector('option[data-dynamic-default-arrangement]'); if (dynamic && dynamic.value !== wanted) dynamic.remove(); if (wanted && !existing) { const opt = document.createElement('option'); opt.value = wanted; opt.textContent = `${wanted} (saved default)`; opt.dataset.dynamicDefaultArrangement = 'true'; sel.appendChild(opt); } sel.value = wanted; } function _currentArrangementName() { const song = window.feedBack?.currentSong; const sel = document.getElementById('arr-select'); if (song?.arrangements && sel) { const match = song.arrangements.find(a => String(a.index) === String(sel.value)); if (match?.name) return String(match.name); } if (song?.arrangement) return String(song.arrangement); const selectedText = sel?.selectedOptions?.[0]?.textContent || ''; return selectedText.replace(/\s*\([^)]*\)\s*$/, '').trim(); } function syncDefaultArrangementPin() { const btn = document.getElementById('arr-default-pin'); if (!btn) return; const name = _currentArrangementName(); const isDefault = !!name && name === _defaultArrangement; const label = name ? (isDefault ? `${name} is the default arrangement` : `Make ${name} the default for new songs`) : 'Select an arrangement to make it the default'; btn.textContent = isDefault ? '★' : '☆'; btn.setAttribute('aria-pressed', isDefault ? 'true' : 'false'); btn.setAttribute('aria-label', label); btn.disabled = !name; btn.classList.toggle('text-yellow-300', isDefault); btn.classList.toggle('text-gray-400', !isDefault); btn.title = label; } async function pinCurrentArrangementDefault() { const name = _currentArrangementName(); if (!name || name === _defaultArrangement) { syncDefaultArrangementPin(); return; } const resp = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ default_arrangement: name }), }); if (!resp.ok) return; _defaultArrangement = name; _syncDefaultArrangementSelect(name); syncDefaultArrangementPin(); } // ── Highway String Colors (user theming) ────────────────────────────────── // Colors are assigned per NAMED string (Low E, A, D, G, B, High E, plus the // extended low strings of 7/8-string guitars), so a string keeps its color // when the string count changes (e.g. Low E stays the same from a 6-string // guitar to a 4-string bass, and on a 7-string the extra Low B takes the // 7-string slot rather than bumping every color over). The highways color by // raw string INDEX, so a small translation table maps named slots → per-index // colors for the current arrangement; this is recomputed whenever a song loads // (its string count / bass-vs-guitar may differ). Applies to BOTH the 2D and // bundled 3D highway; stored client-side; shared via a copy/paste code. const HWC_KEY_ACTIVE = 'highwayStringColors'; // JSON slot→hex map (active) const HWC_KEY_THEMES = 'highwayColorThemes'; // { "": {slot:hex} } const HWC_KEY_NAME = 'highwayColorActiveName'; // selected saved theme name, or '' const HWC_HEX_RE = /^#[0-9a-fA-F]{6}$/; // Named color slots, in display order (high → low, then extended low strings). const HWC_SLOTS = [ { key: 'highE', label: 'High E', sub: '1st' }, { key: 'B', label: 'B', sub: '2nd' }, { key: 'G', label: 'G', sub: '3rd' }, { key: 'D', label: 'D', sub: '4th' }, { key: 'A', label: 'A', sub: '5th' }, { key: 'lowE', label: 'Low E', sub: '6th / lowest' }, { key: 'low7', label: 'Low B', sub: '7-string' }, { key: 'low8', label: 'Low F#', sub: '8-string' }, ]; const HWC_SLOT_KEYS = HWC_SLOTS.map((s) => s.key); // Hardcoded fallback (matches the highway defaults) for before the 2D highway // is queryable. const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '#cc6600', B: '#00cc66', highE: '#9900cc', low7: '#cc00aa', low8: '#00cccc' }; // One-click string-color presets. Each is a full named-slot → hex map (every // slot, so 7/8-string charts get a sensible color too) keyed by the same slot // names as HWC_SLOTS, so "Low E" always lands on the lowE slot regardless of // string count. Hues are chosen for the dark scene (~#080810): each color is // bright enough to read on black and distinct from its neighbours. // - warmcool: an ordered low→high spectrum (warm reds at the bass end → // cool blues/violet at the treble end) so pitch reads as color temperature. // - vivid: punchier, higher-saturation take on the classic mapping for a // stage-bright look. // - colorblind: the Okabe–Ito accessible qualitative palette (vermillion, // orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most // distinguishable option for deuteranopia/protanopia. // - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags // between neighbours (bright→bright→brightest→dark blue→bright green→dark // violet) so adjacent strings separate harder than vivid — a stage/stream // "pop" set, not a vivid duplicate. // - accessible: a CVD-safe set ORDERED by ascending lightness low→high (deep // blue → vermilion → azure → orange → yellow → cream). Unlike the unordered // Okabe–Ito 'colorblind' set, the value ramp teaches pitch low→high AND // survives grayscale/colorblindness; no red/green pair carries meaning. // - ember: a warm, lower-intensity family for long sessions, luminance-stepped // from rust/ember at the bass through warm gold to cream at the treble. The // bass embers stay light enough to clear the near-black scene. // - tapedeck: a vintage-print, slightly desaturated ochre-tinted family // (rust-red → mustard → avocado → teal → faded denim → dusty plum). Muted // hues collapse, so neighbour LIGHTNESS deliberately zig-zags to keep the // dusty mid-strings (avocado/teal/denim) distinct on the dark board. // - crtgreen / crtamber: monochrome CRT-phosphor families (green / amber) // stepped by STRICT ASCENDING LIGHTNESS low→high. Mono sets collapse on hue, // so lightness alone carries the ordering. Verified to stay legible even on // the matching phosphor scene board (green-on-green / amber-on-amber). // - pitchramp: a smooth low→high hue sweep (violet → blue → teal → green → // yellow → warm-white) with rising lightness — memorable + teaches order. // - sunrise: a soft dawn gradient (plum → rose → coral → amber → gold → cream), // warm and lower-intensity, lightness-stepped low→high. const HWC_PRESETS = [ { id: 'warmcool', label: 'Warm → Cool', colors: { lowE: '#ff3b30', A: '#ff7a18', D: '#ffc400', G: '#36c46a', B: '#2196f3', highE: '#9b5cff', low7: '#ff2d78', low8: '#00c2c7' }, }, { id: 'vivid', label: 'Vivid', colors: { lowE: '#ff2222', A: '#ffd000', D: '#1e8bff', G: '#ff7a00', B: '#16d65a', highE: '#b24bff', low7: '#ff3cc0', low8: '#15d8d8' }, }, { id: 'colorblind', label: 'Colorblind-friendly', colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' }, }, { id: 'neon', label: 'Neon', colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' }, }, { id: 'accessible', label: 'Accessible (ordered)', colors: { lowE: '#2453c0', A: '#c44a00', D: '#3f93cf', G: '#ec9a1e', B: '#f2d43c', highE: '#f5eecb', low7: '#173f96', low8: '#0f2c6b' }, }, { id: 'ember', label: 'Warm Ember', colors: { lowE: '#c0392b', A: '#e0552a', D: '#ef7d2e', G: '#f6a13a', B: '#f4c95d', highE: '#f7e3a8', low7: '#9e2f23', low8: '#7d2418' }, }, { id: 'tapedeck', label: 'Tape Deck', colors: { lowE: '#b04632', A: '#d8ad42', D: '#5f7a34', G: '#54b3a6', B: '#5e83ad', highE: '#b98abb', low7: '#8f3526', low8: '#6f2a1e' }, }, { id: 'crtgreen', label: 'CRT Green', colors: { lowE: '#0a5a23', A: '#108a30', D: '#1fb53f', G: '#3ad94f', B: '#74f06a', highE: '#c7ffb0', low7: '#08491c', low8: '#063514' }, }, { id: 'crtamber', label: 'CRT Amber', colors: { lowE: '#7a3a02', A: '#a85f06', D: '#cf8410', G: '#e8a82a', B: '#f4cf5e', highE: '#ffeeb8', low7: '#5f2d01', low8: '#471f00' }, }, { id: 'pitchramp', label: 'Pitch Ramp', colors: { lowE: '#7a2390', A: '#2f5ad8', D: '#1f9bc4', G: '#2fb84a', B: '#cfd22a', highE: '#f3e0c0', low7: '#5e1a78', low8: '#440f5e' }, }, { id: 'sunrise', label: 'Sunrise', colors: { lowE: '#8a3a6e', A: '#bf4a5e', D: '#e0664f', G: '#f29a55', B: '#f7c873', highE: '#fce8b8', low7: '#6e2c5c', low8: '#54214a' }, }, ]; // Translation table: chart string index → named slot, for a given string count // and bass/guitar family. Mirrors the 3D highway's _baseOpenStringMidis: bass // shares the low strings (E A D G), 7/8-string guitars prepend lower strings, // and sub-6 guitars truncate from the high end. Index 0 is always the lowest. function _hwcSlotKeysForChart(sc, isBass) { sc = Math.max(1, Math.min(8, (sc | 0) || 6)); if (isBass) { if (sc <= 4) return ['lowE', 'A', 'D', 'G'].slice(0, sc); if (sc === 5) return ['low7', 'lowE', 'A', 'D', 'G']; return ['low8', 'low7', 'lowE', 'A', 'D', 'G'].slice(0, sc); } if (sc <= 6) return ['lowE', 'A', 'D', 'G', 'B', 'highE'].slice(0, sc); if (sc === 7) return ['low7', 'lowE', 'A', 'D', 'G', 'B', 'highE']; return ['low8', 'low7', 'lowE', 'A', 'D', 'G', 'B', 'highE']; } // Current arrangement shape (string count + bass-vs-guitar) from the 2D highway. function _hwcChartShape() { let sc = 6, arr = ''; try { sc = window.highway?.getStringCount?.() || 6; } catch (_) {} try { arr = window.highway?.getSongInfo?.()?.arrangement || window.feedBack?.currentSong?.arrangement || ''; } catch (_) {} return { sc: Math.max(1, Math.min(8, sc)), isBass: /bass/i.test(String(arr)) }; } // Normalize an arbitrary value to a slot→hex map of validated lowercase colors // (absent / invalid slots are omitted). function _hwcNormalize(slotMap) { const out = {}; if (slotMap && typeof slotMap === 'object' && !Array.isArray(slotMap)) { for (const k of HWC_SLOT_KEYS) { const v = (typeof slotMap[k] === 'string') ? slotMap[k].trim().toLowerCase() : ''; if (HWC_HEX_RE.test(v)) out[k] = v; } } return out; } // Canonical default color per named slot (the classic highway mapping). // Fixed, not read back from the highway (which may already be name-remapped for // a 7/8-string chart), so the pickers always preview the true per-name default. function getHighwayDefaultSlotColors() { return { ...HWC_DEFAULT_FALLBACK }; } // Active (user-customized) slot→hex map from storage ({} when none set). function getHighwayStringColors() { try { const raw = localStorage.getItem(HWC_KEY_ACTIVE); if (raw) return _hwcNormalize(JSON.parse(raw)); } catch (_) { /* corrupt / blocked */ } return {}; } // Defaults overlaid with the user's custom slots (custom wins). Always a full // 8-slot map, so name-mapping has a color for every string of any arrangement. function _hwcMergedSlotColors() { return { ...getHighwayDefaultSlotColors(), ...getHighwayStringColors() }; } // True when the slot→index mapping is the identity (index 0 = lowest = Low E): // guitar ≤6 strings and 4-string bass. For these the name mapping equals the // stock index order, so we leave the highways on their hand-tuned defaults // (byte-identical) unless the user set custom colors. Extended-range charts — // 7/8-string guitar and 5/6-string bass — prepend lower strings (Low B/F#), // shifting Low E up an index, so their defaults must be name-remapped too. function _hwcMappingIsIdentity(sc, isBass) { return isBass ? sc <= 4 : sc <= 6; } // Translate a full slot map into the index-keyed array the highways consume. function _hwcEffectiveIndexColors(slotMap, sc, isBass) { const keys = _hwcSlotKeysForChart(sc, isBass); return keys.map((k) => slotMap[k] || null); } // Persist the user's custom slot map (or clear it), then apply. Only slots that // actually DIFFER from the default are stored — so reverting every picker to its // stock color persists as empty and the identity/stock path is restored (rather // than pinning the highways on an all-default "custom" theme). function applyHighwayStringColors(slotMap, opts) { const persist = !opts || opts.persist !== false; const colors = _hwcNormalize(slotMap); const defaults = getHighwayDefaultSlotColors(); const overrides = {}; for (const k of Object.keys(colors)) { if (colors[k] !== defaults[k]) overrides[k] = colors[k]; } if (persist) { try { if (Object.keys(overrides).length) localStorage.setItem(HWC_KEY_ACTIVE, JSON.stringify(overrides)); else localStorage.removeItem(HWC_KEY_ACTIVE); } catch (_) {} } reapplyHighwayStringColors(); } // Apply a named one-click string-color preset (see HWC_PRESETS) to all strings. // Persists + applies to both highways (via applyHighwayStringColors), then — // when the Settings UI is mounted — refreshes the per-string pickers so their // swatches show the preset's colors. Unknown id is a no-op. function applyHighwayStringPreset(id) { const preset = HWC_PRESETS.find((p) => p.id === id); if (!preset) return false; applyHighwayStringColors(preset.colors); try { if (typeof hwcRenderPickers === 'function') hwcRenderPickers(); } catch (_) {} return true; } // Apply colors by NAMED string to both highways for the current arrangement. // Colors follow the string name regardless of count: Low E stays Low E's color // on a 6-, 7-, or 8-string. Defaults map identically to the stock order for // 6-string/bass (so those stay byte-identical); 7/8-string remaps the defaults // too so Low E keeps its color. The String Colors UI replaces the 3D highway's // old palette picker, so core always drives the 3D string colors here. function reapplyHighwayStringColors() { const { sc, isBass } = _hwcChartShape(); const custom = getHighwayStringColors(); const hasCustom = Object.keys(custom).length > 0; if (!hasCustom && _hwcMappingIsIdentity(sc, isBass)) { // Pure stock defaults in natural order — leave the hand-tuned highway // defaults intact, and make sure the 3D is on its plain default palette // (clears any stale 'custom' / leftover palette selection). try { window.highway?.setStringColors?.(null); } catch (_) {} try { if (localStorage.getItem('h3d_bg_palette') !== 'default') window.h3dBgSetPalette?.('default'); } catch (_) {} try { window.feedBack?.emit?.('highway:stringColors', {}); } catch (_) {} return; } const eff = _hwcEffectiveIndexColors(_hwcMergedSlotColors(), sc, isBass); try { window.highway?.setStringColors?.(eff); } catch (_) {} try { window.h3dBgSetStringColors?.(eff); } catch (_) {} try { window.feedBack?.emit?.('highway:stringColors', custom); } catch (_) {} } function _hwcReadThemes() { // Null-prototype store: theme names come from user input / share codes, so // names like `constructor`/`toString`/`__proto__` must not collide with // inherited Object properties or mutate the prototype. try { const parsed = JSON.parse(localStorage.getItem(HWC_KEY_THEMES) || '{}'); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return Object.create(null); const out = Object.create(null); for (const [name, colors] of Object.entries(parsed)) out[name] = _hwcNormalize(colors); return out; } catch (_) { return Object.create(null); } } function _hwcWriteThemes(o) { try { localStorage.setItem(HWC_KEY_THEMES, JSON.stringify(o)); } catch (_) {} } function listHighwayColorThemes() { return Object.keys(_hwcReadThemes()); } function getActiveHighwayColorThemeName() { try { return localStorage.getItem(HWC_KEY_NAME) || ''; } catch (_) { return ''; } } function saveHighwayColorTheme(name, slotMap) { name = String(name || '').trim(); if (!name) return false; const o = _hwcReadThemes(); o[name] = _hwcNormalize(slotMap); _hwcWriteThemes(o); try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {} return true; } function deleteHighwayColorTheme(name) { const o = _hwcReadThemes(); if (Object.prototype.hasOwnProperty.call(o, name)) { delete o[name]; _hwcWriteThemes(o); } if (getActiveHighwayColorThemeName() === name) { try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {} } } // Select a saved theme by name, or pass '' to revert to defaults. function selectHighwayColorTheme(name) { if (!name) { try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {} applyHighwayStringColors(null); return; } const o = _hwcReadThemes(); if (!Object.prototype.hasOwnProperty.call(o, name)) return; try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {} applyHighwayStringColors(o[name]); } // Compact, paste-friendly share code: "SLOPHWY2." + base64url(JSON{n,c}) where // c is the named slot→hex map. function encodeHighwayColorShare(name, slotMap) { const payload = { n: String(name || '').slice(0, 60), c: _hwcNormalize(slotMap) }; const json = JSON.stringify(payload); let b64; try { b64 = btoa(unescape(encodeURIComponent(json))); } catch (_) { b64 = btoa(json); } return 'SLOPHWY2.' + b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function decodeHighwayColorShare(code) { if (typeof code !== 'string') return null; let s = code.trim(); // Require the exact versioned prefix. Anything else (a future/legacy // SLOPHWY*, or unprefixed text) is rejected so the version boundary is real. const PREFIX = 'SLOPHWY2.'; if (s.slice(0, PREFIX.length).toUpperCase() !== PREFIX) return null; s = s.slice(PREFIX.length); s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; let json; try { json = decodeURIComponent(escape(atob(s))); } catch (_) { try { json = atob(s); } catch (_) { return null; } } let obj; try { obj = JSON.parse(json); } catch (_) { return null; } if (!obj || typeof obj.c !== 'object' || Array.isArray(obj.c)) return null; return { name: String(obj.n || '').slice(0, 60), colors: _hwcNormalize(obj.c) }; } // Import a share code: store it as a (uniquely named) saved theme and apply. function importHighwayColorShare(code) { const parsed = decodeHighwayColorShare(code); if (!parsed) return null; let name = parsed.name || 'Imported'; const existing = _hwcReadThemes(); if (Object.prototype.hasOwnProperty.call(existing, name)) { let i = 2; while (Object.prototype.hasOwnProperty.call(existing, name + ' ' + i)) i++; name = name + ' ' + i; } saveHighwayColorTheme(name, parsed.colors); applyHighwayStringColors(parsed.colors); return { name, colors: parsed.colors }; } // Startup: apply persisted colors to the 2D highway immediately and re-apply on // every song load (string count / bass-vs-guitar can change the slot→index // mapping) and whenever a viz renderer (re)initializes (the 3D loads async + // rebuilds per song, so a one-shot apply could land before it exists). let _hwcWired = false; function initHighwayColors() { reapplyHighwayStringColors(); if (!_hwcWired && window.feedBack && typeof window.feedBack.on === 'function') { _hwcWired = true; window.feedBack.on('viz:renderer:ready', reapplyHighwayStringColors); window.feedBack.on('song:loaded', reapplyHighwayStringColors); window.feedBack.on('song:ready', reapplyHighwayStringColors); } _hwcInstallFacade(); } // ── Public plugin API: window.feedBack.highwayColors ───────────────────── // A stable, documented facade over the (otherwise private) string-color // manager so plugins can read / react to / set the user's per-string colors // without reaching into internals. This is a synchronous data-plane API, not a // capability domain — consistent with the constitution keeping highway/viz // surfaces off the capability graph until a dedicated render-facade slice // lands. Colors are keyed by NAMED string slot (see `slots`); use // `keysForChart`/`toEffective` to map names → per-string-index for a given // arrangement. See docs/plugin-capability-inventory.md. const _hwcChangeWrappers = new WeakMap(); function _hwcInstallFacade() { if (!window.feedBack || window.feedBack.highwayColors) return; const api = { version: 1, // Ordered named slots: [{ key, label, sub }]. `key` is the stable id. slots: HWC_SLOTS.map((s) => ({ key: s.key, label: s.label, sub: s.sub })), // User-set overrides only (named slot → hex); empty object = defaults. get() { return getHighwayStringColors(); }, // Canonical default color per named slot. getDefaults() { return getHighwayDefaultSlotColors(); }, // Defaults overlaid with overrides — the colors in effect, by name. getResolved() { return _hwcMergedSlotColors(); }, // Which named slot each chart string index maps to, for an arrangement // (index 0 = lowest string). e.g. (7,false) → ['low7','lowE','A',...]. keysForChart(stringCount, isBass) { return _hwcSlotKeysForChart(stringCount, !!isBass); }, // Per-string-INDEX hex array (resolved colors) for an arrangement. // Omit args to use the currently-loaded chart's shape. toEffective(stringCount, isBass) { const shape = (typeof stringCount === 'number') ? { sc: stringCount, isBass: !!isBass } : _hwcChartShape(); return _hwcEffectiveIndexColors(_hwcMergedSlotColors(), shape.sc, shape.isBass); }, // The per-index colors actually applied to the live 2D highway now. getCurrent() { try { return (window.highway && window.highway.getStringColors) ? window.highway.getStringColors() : []; } catch (_) { return []; } }, // Set colors programmatically (persists + applies to both highways). // Pass a named slot map, or null/{} to revert to defaults. apply(slotMap) { return applyHighwayStringColors(slotMap); }, // One-click presets: [{ id, label, colors }] (full named-slot maps). presets: HWC_PRESETS.map((p) => ({ id: p.id, label: p.label, colors: { ...p.colors } })), // Apply a preset by id (persists + applies to both highways). applyPreset(id) { return applyHighwayStringPreset(id); }, // Share-code interop (the "SLOPHWY2." copy/paste format). encodeShare(name, slotMap) { return encodeHighwayColorShare(name, slotMap); }, decodeShare(code) { return decodeHighwayColorShare(code); }, // Subscribe to color changes; handler receives the resolved slot map. // Returns an unsubscribe fn that removes exactly THIS subscription; // offChange(fn) removes every subscription registered with that fn. // (Each fn maps to a Set of wrappers so repeated mount/init paths that // subscribe the same handler don't clobber each other or leak.) onChange(fn) { if (typeof fn !== 'function' || !window.feedBack) return () => {}; const wrapper = () => { try { fn(api.getResolved()); } catch (e) { console.error('[highwayColors] onChange handler threw', e); } }; let set = _hwcChangeWrappers.get(fn); if (!set) { set = new Set(); _hwcChangeWrappers.set(fn, set); } set.add(wrapper); window.feedBack.on('highway:stringColors', wrapper); return () => { if (window.feedBack) window.feedBack.off('highway:stringColors', wrapper); const s = _hwcChangeWrappers.get(fn); if (s) { s.delete(wrapper); if (!s.size) _hwcChangeWrappers.delete(fn); } }; }, offChange(fn) { const set = _hwcChangeWrappers.get(fn); if (set && window.feedBack) { for (const wrapper of set) window.feedBack.off('highway:stringColors', wrapper); _hwcChangeWrappers.delete(fn); } }, }; window.feedBack.highwayColors = api; } // ── Highway String Colors — Settings UI wiring ─────────────────────────── // Pickers are per NAMED string (see HWC_SLOTS). Assigning "Low E" a color // keeps Low E that color regardless of string count — the translation table // (_hwcSlotKeysForChart) handles the index remapping per arrangement. function _hwcStatus(msg) { const el = document.getElementById('hwc-status'); if (!el) return; el.textContent = msg || ''; if (msg) { clearTimeout(_hwcStatus._t); _hwcStatus._t = setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 2500); } } // Render one color input per named slot, seeded from active colors (falling // back to the highway defaults for that slot). function hwcRenderPickers() { const host = document.getElementById('hwc-pickers'); if (!host) return; const defaults = getHighwayDefaultSlotColors(); const active = getHighwayStringColors(); host.innerHTML = ''; for (const slot of HWC_SLOTS) { const val = active[slot.key] || defaults[slot.key] || '#888888'; const wrap = document.createElement('label'); wrap.className = 'flex items-center gap-2 text-xs text-gray-400'; const input = document.createElement('input'); input.type = 'color'; input.id = 'hwc-color-' + slot.key; input.dataset.slot = slot.key; input.value = val; input.style.width = '2.5rem'; input.style.height = '1.75rem'; input.style.padding = '2px'; input.style.cursor = 'pointer'; input.className = 'rounded border border-gray-800 bg-dark-700'; input.addEventListener('input', () => hwcOnColorInput()); wrap.appendChild(input); const span = document.createElement('span'); span.textContent = slot.label; wrap.appendChild(span); const sub = document.createElement('span'); sub.className = 'text-gray-600'; sub.textContent = slot.sub; wrap.appendChild(sub); host.appendChild(wrap); } } function hwcReadPickers() { const out = {}; for (const slot of HWC_SLOTS) { const el = document.getElementById('hwc-color-' + slot.key); if (el) out[slot.key] = el.value; } return out; } // Live apply on any picker change. Leaves the saved-theme select alone so a // tweaked-but-unsaved state is allowed; "Save as…" captures it. function hwcOnColorInput() { applyHighwayStringColors(hwcReadPickers()); } function hwcPopulateThemeSelect() { const sel = document.getElementById('hwc-theme-select'); if (!sel) return; const names = listHighwayColorThemes().sort((a, b) => a.localeCompare(b)); const current = getActiveHighwayColorThemeName(); sel.innerHTML = ''; const def = document.createElement('option'); def.value = ''; def.textContent = 'Default colors'; sel.appendChild(def); for (const n of names) { const opt = document.createElement('option'); opt.value = n; opt.textContent = n; sel.appendChild(opt); } sel.value = (current && names.includes(current)) ? current : ''; } function hwcOnSelectTheme(name) { selectHighwayColorTheme(name); hwcRenderPickers(); } async function hwcSaveTheme() { const name = await uiPrompt({ title: 'Save Highway Colors', label: 'Theme name', value: getActiveHighwayColorThemeName() || 'My Colors', okLabel: 'Save' }); if (!name) return; saveHighwayColorTheme(name, hwcReadPickers()); hwcPopulateThemeSelect(); _hwcStatus('Saved “' + name + '”'); } function hwcDeleteTheme() { const name = getActiveHighwayColorThemeName(); if (!name) { _hwcStatus('No saved theme selected'); return; } deleteHighwayColorTheme(name); applyHighwayStringColors(null); hwcPopulateThemeSelect(); hwcRenderPickers(); _hwcStatus('Deleted “' + name + '”'); } function hwcReset() { try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {} applyHighwayStringColors(null); hwcPopulateThemeSelect(); hwcRenderPickers(); _hwcStatus('Reset to defaults'); } async function hwcCopyShare() { const name = getActiveHighwayColorThemeName() || 'Highway Colors'; const code = encodeHighwayColorShare(name, hwcReadPickers()); let copied = false; try { await navigator.clipboard.writeText(code); copied = true; } catch (_) {} if (!copied) { // Fallback: drop the code into the import field so it can be copied manually. const inp = document.getElementById('hwc-import-code'); if (inp) { inp.value = code; inp.select(); } } _hwcStatus(copied ? 'Share code copied' : 'Copy failed — code shown below'); } function hwcImport() { const inp = document.getElementById('hwc-import-code'); const code = inp ? inp.value : ''; const res = importHighwayColorShare(code); if (!res) { _hwcStatus('Invalid share code'); return; } if (inp) inp.value = ''; hwcPopulateThemeSelect(); hwcRenderPickers(); _hwcStatus('Imported “' + res.name + '”'); } function hwcInitSettingsUI() { hwcPopulateThemeSelect(); hwcRenderPickers(); } async function loadSettings() { // App Updates UI does not depend on /api/settings — run it first so a // failed fetch below still leaves the desktop updater wired up. // setupAppUpdates() is idempotent via _appUpdatesWired. setupAppUpdates(); const resp = await fetch('/api/settings'); const data = await resp.json(); // Null-guard the form fields: on the v3 tabbed settings page the markup is // rendered by settings.js, so a control may be absent if that render hasn't // run yet (or on a follower window). The optional-chaining keeps loadSettings // from throwing and aborting the rest of the hydration. const dlcEl = document.getElementById('dlc-path'); if (dlcEl) dlcEl.value = data.dlc_dir || ''; _defaultArrangement = data.default_arrangement || ''; _syncDefaultArrangementSelect(_defaultArrangement); const pathwayEl = document.getElementById('setting-instrument-pathway'); if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway); const demucsEl = document.getElementById('demucs-server-url'); if (demucsEl) demucsEl.value = data.demucs_server_url || ''; const leftyEl = document.getElementById('setting-lefty'); if (leftyEl) leftyEl.checked = highway.getLefty(); const autoplayExitEl = document.getElementById('setting-autoplay-exit'); if (autoplayExitEl) autoplayExitEl.checked = _autoplayExitEnabled(); const showUpNextEl = document.getElementById('setting-show-upnext'); if (showUpNextEl) showUpNextEl.checked = _showUpNextEnabled(); const confirmExitEl = document.getElementById('setting-confirm-exit'); if (confirmExitEl) confirmExitEl.checked = _exitConfirmEnabled(); // Restore master-difficulty slider from persisted value (defaults // to 100 when the key is absent — no behaviour change for users // who've never touched the slider). const masteryPct = typeof data.master_difficulty === 'number' ? Math.max(0, Math.min(100, data.master_difficulty)) : 100; // Drives both the player-popover slider (#mastery-slider) and the // Gameplay-tab "Note highway speed" slider (#setting-highway-speed), which // share the master_difficulty key. skipPersist so loading the value doesn't // echo it back to the server. _applyMastery(masteryPct, { skipPersist: true }); // Route the loaded value through setAvOffsetMs so the highway's // render clock, the Settings slider, the HUD readout, and the // module variable all pick it up consistently. Pass skipPersist // so we don't echo the loaded value back to the server. setAvOffsetMs(Number(data.av_offset_ms) || 0, /* skipPersist */ true); // Arrangement naming mode is localStorage-only (client preference). const namingModeEl = document.getElementById('arrangement-naming-mode'); if (namingModeEl) namingModeEl.value = _getArrangementNamingMode(); // Gameplay-tab settings (tabbed settings page). Countdown is mirrored to // localStorage so the song-start path reads it synchronously without an // async /api/settings fetch on the play hot path. Miss penalty / fail // behavior are persist-only stubs (not yet consumed by scoring). const countdownOn = data.countdown_before_song === true; try { localStorage.setItem('countdownBeforeSong', countdownOn ? '1' : '0'); } catch (_) { /* private mode */ } const countdownEl = document.getElementById('setting-countdown-before-song'); if (countdownEl) countdownEl.checked = countdownOn; // Achievements epic: mirror the opt-in flag to localStorage so the // onboarding card + the bundled achievements plugin can read the current // state app-wide (the plugin's own settings panel still owns the toggle). try { localStorage.setItem('achievementsEnabled', data.achievements_enabled === true ? '1' : '0'); } catch (_) { /* private mode */ } const missEl = document.getElementById('setting-miss-penalty'); if (missEl) missEl.value = typeof data.miss_penalty === 'string' ? data.miss_penalty : 'none'; const failEl = document.getElementById('setting-fail-behavior'); if (failEl) failEl.value = typeof data.fail_behavior === 'string' ? data.fail_behavior : 'continue'; // Native folder picker — only present when running inside feedBack-desktop. if (window.feedBackDesktop && typeof window.feedBackDesktop.pickDirectory === 'function') { document.getElementById('btn-pick-dlc')?.classList.remove('hidden'); } syncDefaultArrangementPin(); // Hydrate the highway-color settings UI (theme select + per-string pickers) // — the runtime apply path (initHighwayColors) doesn't render these controls. hwcInitSettingsUI(); } // ── App Updates (desktop-only) ─────────────────────────────────────────── // Velopack auto-update controls, rendered as the first block of the Settings // page. Whole block stays hidden in the plain web app; unhide + wire only // when the feedBack-desktop bridge (window.feedBackDesktop.update) is // present. On Linux the block renders but its controls are disabled — the // desktop reports platform === 'linux' and short-circuits the IPC. const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha']; let _appUpdatesWired = false; function setupAppUpdates() { const block = document.getElementById('app-updates-block'); if (!block) return; const updateApi = window.feedBackDesktop?.update; // Per-method capability check: an older or partial feedBack-desktop // bridge may expose `update` without the full shape. Skip wiring (and // leave the block hidden) rather than throwing on first interaction. if (!updateApi || typeof updateApi.getStatus !== 'function' || typeof updateApi.setChannel !== 'function' || typeof updateApi.checkNow !== 'function') { return; } block.classList.remove('hidden'); const channelSelect = document.getElementById('app-update-channel'); const checkBtn = document.getElementById('app-update-check-now'); const statusEl = document.getElementById('app-update-status'); const linuxNote = document.getElementById('app-update-linux-note'); if (!channelSelect || !checkBtn || !statusEl) return; // localStorage access can throw in storage-restricted contexts (sandbox // iframes, privacy modes, etc.); fall back to the default channel so the // panel still renders rather than aborting wiring entirely. let storedRaw = null; // Read the canonical key, falling back to the pre-rename // 'slopsmith-update-channel' so an existing channel preference survives. try { storedRaw = localStorage.getItem('feedBack-update-channel') || localStorage.getItem('slopsmith-update-channel'); } catch (_) { /* fall through */ } const stored = APP_UPDATE_CHANNELS.includes(storedRaw) ? storedRaw : 'stable'; channelSelect.value = stored; const isLinux = window.feedBackDesktop?.platform === 'linux'; function showLinuxFallback(message) { if (linuxNote) linuxNote.classList.remove('hidden'); channelSelect.disabled = true; checkBtn.disabled = true; statusEl.textContent = message || 'Auto-update is not available on this platform.'; } function fmtTimestamp(ts) { if (!ts) return 'never'; try { const d = new Date(ts); return Number.isNaN(d.getTime()) ? 'never' : d.toLocaleString(); } catch (_) { return 'never'; } } function renderStatus(extra) { try { // Wrap in Promise.resolve so a future getStatus() that returns // synchronously won't blow up on .then(). void Promise.resolve(updateApi.getStatus()).then((s) => { if (!s) { statusEl.textContent = extra || 'Updater status unavailable.'; return; } if (s.status === 'unsupported' || s.platform === 'linux') { showLinuxFallback('Auto-update is not available on Linux.'); return; } if (s.status === 'error') { const errMsg = s.message ? `Update error: ${s.message}` : 'Update check failed.'; statusEl.textContent = extra ? `${extra} · ${errMsg}` : errMsg; return; } const parts = [ `Version ${s.currentVersion || '?'}`, `channel ${s.channel || channelSelect.value}`, `last checked ${fmtTimestamp(s.lastChecked)}`, ]; statusEl.textContent = extra ? `${extra} · ${parts.join(' · ')}` : parts.join(' · '); }).catch((e) => { console.warn('[updater] getStatus failed:', e); statusEl.textContent = extra || 'Failed to read updater status.'; }); } catch (e) { console.warn('[updater] getStatus threw:', e); statusEl.textContent = extra || 'Failed to read updater status.'; } } if (isLinux) { showLinuxFallback('Auto-update is not available on Linux.'); // Keep main informed of the persisted channel even on Linux so // cross-platform reasoning about the channel stays consistent. // setChannel() may return a Promise — chain .catch() so a rejected // promise doesn't surface as an unhandled rejection. try { void Promise.resolve(updateApi.setChannel(stored)).catch((e) => { console.warn('[updater] setChannel(linux) failed:', e); }); } catch (e) { console.warn('[updater] setChannel(linux) threw:', e); } return; } // Inform main of the persisted channel on each load. setChannel() on // main is idempotent when the channel already matches. try { void Promise.resolve(updateApi.setChannel(stored)).catch((e) => { console.warn('[updater] setChannel(initial) failed:', e); }); } catch (e) { console.warn('[updater] setChannel(initial) threw:', e); } if (!_appUpdatesWired) { // Wire DOM listeners once. The elements live in static index.html // and are not recreated, so re-wiring on every loadSettings() call // would just stack duplicate handlers. channelSelect.addEventListener('change', async () => { const val = channelSelect.value; if (!APP_UPDATE_CHANNELS.includes(val)) return; try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {} try { // Await setChannel so the status line reflects what actually // happened — rendering "Channel set" unconditionally would // mislead users when the IPC rejects. await Promise.resolve(updateApi.setChannel(val)); renderStatus(`Channel set to ${val}.`); } catch (e) { console.warn('[updater] setChannel failed:', e); renderStatus(`Failed to set channel to ${val}: ${e?.message || e}`); } }); checkBtn.addEventListener('click', async () => { checkBtn.disabled = true; statusEl.textContent = 'Checking for updates…'; let reEnableBtn = true; try { const result = await updateApi.checkNow(); const status = result?.status || 'unknown'; let msg; switch (status) { case 'idle': msg = "You're on the newest version in this channel."; break; case 'downloading': msg = 'Update available — downloading…'; break; case 'downloaded': msg = 'Update downloaded — restart to apply.'; break; case 'unsupported': reEnableBtn = false; showLinuxFallback('Auto-update is not available on Linux.'); return; case 'error': msg = `Update check failed${result?.message ? `: ${result.message}` : '.'}`; break; default: msg = `Update check returned: ${status}`; } renderStatus(msg); } catch (e) { console.warn('[updater] checkNow failed:', e); statusEl.textContent = `Update check failed: ${e?.message || e}`; } finally { if (reEnableBtn) checkBtn.disabled = false; } }); _appUpdatesWired = true; } renderStatus(); } // ── Restart banner (desktop-only) ──────────────────────────────────────── // Subscribes to window.feedBackDesktop.update.onDownloaded and renders a // persistent banner with a "Restart now" button. Runs once at app boot so a // download finishing while the user is on a non-Settings screen still pops // the banner. function initAppUpdateBanner() { const updateApi = window.feedBackDesktop?.update; // Same capability gate as setupAppUpdates — the banner needs onDownloaded // to subscribe, getStatus to detect pre-existing pending updates on boot, // and apply to actually restart from the button. A bridge missing any // of these would partially fail; better to no-op cleanly. if (!updateApi || typeof updateApi.onDownloaded !== 'function' || typeof updateApi.getStatus !== 'function' || typeof updateApi.apply !== 'function') { return; } const BANNER_ID = 'feedBack-update-banner'; function renderUpdateBanner(payload) { // Avoid stacking duplicate banners if onDownloaded fires more than once. if (document.getElementById(BANNER_ID)) return; const banner = document.createElement('div'); banner.id = BANNER_ID; banner.setAttribute('role', 'status'); banner.style.cssText = [ 'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99999', 'padding:10px 16px', 'background:linear-gradient(90deg,#1e3a8a,#4338ca)', 'color:#fff', 'font-size:13px', 'font-family:system-ui,sans-serif', 'display:flex', 'align-items:center', 'justify-content:space-between', 'gap:12px', 'box-shadow:0 2px 8px rgba(0,0,0,0.4)', ].join(';'); const text = document.createElement('span'); const version = payload && payload.version ? ` (${payload.version})` : ''; text.textContent = `Update downloaded${version} — restart to apply.`; const actions = document.createElement('span'); actions.style.cssText = 'display:flex;gap:8px;align-items:center'; const restartBtn = document.createElement('button'); restartBtn.textContent = 'Restart now'; restartBtn.style.cssText = [ 'padding:4px 12px', 'border-radius:4px', 'background:#fff', 'color:#1e3a8a', 'border:none', 'font-weight:600', 'cursor:pointer', 'font-size:13px', ].join(';'); restartBtn.addEventListener('click', async () => { restartBtn.disabled = true; restartBtn.textContent = 'Restarting…'; try { // apply() can resolve with { status: 'error' } instead of // throwing; only re-enable the button on that path. const result = await updateApi.apply(); if (result?.status === 'error') { console.warn('[updater] apply returned error:', result.message || 'unknown'); restartBtn.disabled = false; restartBtn.textContent = 'Restart now'; } } catch (e) { console.warn('[updater] apply failed:', e); restartBtn.disabled = false; restartBtn.textContent = 'Restart now'; } }); const dismissBtn = document.createElement('button'); dismissBtn.textContent = 'Later'; dismissBtn.setAttribute('aria-label', 'Dismiss update banner'); dismissBtn.style.cssText = [ 'padding:4px 10px', 'border-radius:4px', 'background:transparent', 'color:#fff', 'border:1px solid rgba(255,255,255,0.3)', 'cursor:pointer', 'font-size:13px', ].join(';'); dismissBtn.addEventListener('click', () => banner.remove()); actions.appendChild(restartBtn); actions.appendChild(dismissBtn); banner.appendChild(text); banner.appendChild(actions); const insert = () => { if (document.body) document.body.appendChild(banner); else document.addEventListener('DOMContentLoaded', () => document.body.appendChild(banner), { once: true }); }; insert(); } try { updateApi.onDownloaded((payload) => { try { renderUpdateBanner(payload); } catch (e) { console.warn('[updater] renderUpdateBanner failed:', e); } }); } catch (e) { console.warn('[updater] onDownloaded subscribe failed:', e); } // Catch pre-existing pending updates (downloaded in a previous session, // or restored on launch). onDownloaded only fires for downloads that // complete in the current session, so do an explicit status check too. try { void Promise.resolve(updateApi.getStatus()).then((status) => { // Render the banner for any 'downloaded' status; the version // string is best-effort — renderUpdateBanner() already drops the // "(vX.Y.Z)" suffix when none is supplied, so an update reported // without pending.version still surfaces the restart prompt. if (status && status.status === 'downloaded') { renderUpdateBanner({ version: status.pending?.version, channel: status.channel }); } }).catch((e) => { console.warn('[updater] getStatus on init failed:', e); }); } catch (e) { console.warn('[updater] getStatus on init threw:', e); } } // Updates the fill on slider elements. Expects a CSS variable --range-pct used // in the track fill styling. Declared as a function (not a const) so it is // hoisted onto window — audio-mixer.js calls it as window.handleSliderInput, // matching the window.playSong / window.showScreen cross-script convention. function handleSliderInput(el) { if (!el) return; const min = el.min || 0; const max = el.max || 100; const pct = (el.value - min) / (max - min) * 100; el.style.setProperty('--range-pct', pct + '%'); } // A/V sync calibration. Positive = audio runs ahead of visuals; we // add this to audio.currentTime when driving the highway so the // visuals catch up. Persisted via /api/settings as av_offset_ms. // Live-tunable from the player screen via [ / ] keys (Shift for // ±50 ms) and from the Settings slider; both auto-save with the // same debounced POST. loadSettings() seeds the value via // setAvOffsetMs without saving (skipPersist=true) to avoid an // echo-back round-trip. let _avOffsetMs = 0; let _avSaveDebounce = null; function setAvOffsetMs(ms, skipPersist) { // Clamp to the same bounds the Settings/player-bar sliders enforce // (-1000..1000 ms). Defends against bad values from /api/settings // landing as `value` on . const n = Number(ms); _avOffsetMs = Math.max(-1000, Math.min(1000, Number.isFinite(n) ? n : 0)); // Drive the highway's render-time shift. getTime() still returns // the audio-aligned chart time so plugins (note detection, etc.) // keep scoring against the real chart clock regardless of visual // calibration. if (typeof highway !== 'undefined' && highway?.setAvOffset) highway.setAvOffset(_avOffsetMs); // Sync any visible Settings slider const avSlider = document.getElementById('setting-av-offset'); if (avSlider) { avSlider.value = _avOffsetMs; handleSliderInput(avSlider); } const avVal = document.getElementById('setting-av-offset-val'); if (avVal) avVal.textContent = Math.round(_avOffsetMs); // Sync the inline player-bar slider (live-tunable while playing) const playerAvSlider = document.getElementById('player-av-offset-slider'); if (playerAvSlider) { playerAvSlider.value = _avOffsetMs; handleSliderInput(playerAvSlider); } const playerAvLabel = document.getElementById('player-av-offset-label'); if (playerAvLabel) { const rounded = Math.round(_avOffsetMs); playerAvLabel.textContent = `${rounded >= 0 ? '+' : ''}${rounded}ms`; } // Update the player HUD readout (hidden when offset = 0 to // avoid clutter; the keyboard shortcut is documented in the // Settings help text so it stays discoverable). const hud = document.getElementById('hud-avoffset'); if (hud) { hud.textContent = `A/V ${_avOffsetMs >= 0 ? '+' : ''}${Math.round(_avOffsetMs)} ms`; hud.classList.toggle('hidden', _avOffsetMs === 0); } if (!skipPersist) _persistAvOffset(); } function _persistAvOffset() { // Debounced persist — POST only the one field; the server merges. if (_avSaveDebounce) clearTimeout(_avSaveDebounce); _avSaveDebounce = setTimeout(async () => { _avSaveDebounce = null; try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ av_offset_ms: _avOffsetMs }), }); } catch (e) { console.warn('A/V offset save failed:', e); } }, 400); } function nudgeAvOffsetMs(delta) { setAvOffsetMs(Math.max(-1000, Math.min(1000, _avOffsetMs + delta))); } // Open a native OS folder picker via the Electron bridge (desktop only) and // stash the chosen path into the DLC input. User still has to hit Save. async function pickDlcFolder() { if (!window.feedBackDesktop?.pickDirectory) return; const path = await window.feedBackDesktop.pickDirectory(); if (path) document.getElementById('dlc-path').value = path; } async function saveSettings() { const defaultArrangement = document.getElementById('default-arrangement').value; const resp = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dlc_dir: document.getElementById('dlc-path').value.trim(), default_arrangement: defaultArrangement, demucs_server_url: document.getElementById('demucs-server-url').value.trim(), av_offset_ms: _avOffsetMs, }), }); const data = await resp.json(); if (resp.ok) { _defaultArrangement = defaultArrangement; _syncDefaultArrangementSelect(_defaultArrangement); syncDefaultArrangementPin(); } document.getElementById('settings-status').textContent = data.message || data.error; } document.getElementById('arr-select')?.addEventListener('change', syncDefaultArrangementPin); // Persist a single settings field the instant a control changes (used by // the Settings dropdowns). The /api/settings POST handler merges only the // keys present in the body, so this one-field write won't clobber dlc_dir // or any other setting. No debounce: a . A plugin with either id would collide: the // restore-from-localStorage lookup would find the built-in entry, // dragging the plugin into never-selected land silently. Fail // loudly instead. const RESERVED_IDS = new Set(['default', 'auto']); for (const p of vizPlugins) { if (RESERVED_IDS.has(p.id)) { console.error(`viz picker: plugin id '${p.id}' collides with a reserved built-in picker entry ('auto' = Auto mode, 'default' = built-in 2D highway); rename the plugin's id in plugin.json to include it in the picker.`); continue; } // Skip entries where the plugin script hasn't exposed a factory — // likely means the script failed to load, or the plugin declared // itself as a viz without shipping the factory yet. const factoryName = 'feedBackViz_' + p.id; if (typeof window[factoryName] !== 'function') { console.warn(`viz picker: plugin '${p.id}' has type=visualization but ${factoryName} is not a function; skipping`); continue; } const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.name || p.id; sel.appendChild(opt); } _ensureVenueVizOption(sel); // Refresh the visualization capability domain's provider registry from // the picker entries just built (the domain host introspects each // factory global for contextType / predicate metadata). if (window.feedBack.vizDomain && typeof window.feedBack.vizDomain.refreshProviders === 'function') { try { // The host reads manifest-declared per-instance settings // (capabilities.visualization.settings, feedBack#849) from the // registered capability participant by id — no need to pass them // through the picker here. window.feedBack.vizDomain.refreshProviders( Array.from(sel.options) .filter(opt => !BUILTIN_OPT_VALUES.has(opt.value)) .map(opt => ({ id: opt.value, label: opt.text })) ); } catch (e) { console.warn('viz picker: capability provider refresh failed', e); } } // Restore previous selection if still available. Direct option // scan instead of a CSS-selector lookup so we don't depend on // CSS.escape (missing in some test environments / older runtimes) // and so a weird saved string (e.g. with a quote) can't throw. // localStorage.getItem can itself throw when storage is blocked // (private mode, sandboxed iframes, some strict test runners); // fall back to null so the startup chain doesn't abort. let saved = null; try { saved = localStorage.getItem('vizSelection'); } catch (e) { console.warn('viz picker: unable to read vizSelection', e); } // ── 3D promotion migration (feedBack#160 PR 3) ────────────────────── // Existing users with `vizSelection='default'` (the old built-in 2D // highway) are auto-flipped to the bundled 3D Highway exactly once, // and a non-modal nag toast offers them "Try the tour" / "Switch // back to 2D" the first time they open the player. Users on `auto` // are left alone (auto-pick semantics unchanged). Users on a custom // viz plugin are left alone. WebGL2 absence falls back via setViz. if (saved === 'default' && !_hasPromotedFlag()) { const has3D = Array.from(sel.options).some(o => o.value === 'highway_3d'); if (has3D && _canRun3D()) { saved = 'highway_3d'; try { localStorage.setItem('vizSelection', 'highway_3d'); } catch (_) {} _markPromoted(); _pendingPromotionNag = true; // Race guard: if song:ready already fired before _populateVizPicker // ran (e.g. a deeplink or a fast-loading song), getSongInfo() will // already be non-empty and we'll never receive another song:ready // in this session. Show the nag immediately in that case. const _si = window.highway && window.highway.getSongInfo(); if (_si && _si.title) { _pendingPromotionNag = false; _showPromotionNag(); } } else if (has3D && !_canRun3D()) { // 3D registered but WebGL2 absent — promote in name but // immediately fall back so we don't ping-pong on every load. // Set the flag so we don't try again next reload. _markPromoted(); _showWebGL2FallbackToast(); } // No `highway_3d` option (plugin unloaded?) → leave saved as // 'default'. We'll retry the migration once the plugin is back. } const savedMatches = saved && Array.from(sel.options).some(opt => opt.value === saved); if (savedMatches) { sel.value = saved; // 'default' needs no setViz — the highway already starts with // the built-in renderer. 'auto' runs setViz so _autoMatchViz // fires, though it's a no-op before the first song_info frame. if (saved !== 'default') setViz(saved); } else if (saved) { // Saved selection references an option that no longer exists — // plugin uninstalled since last session, renamed, or the plugin // script failed to register its factory this time. Clear the // stale value so we don't keep trying the same missing viz on // every reload, and fall through to the fresh-install default // below. try { localStorage.removeItem('vizSelection'); } catch (_) { /* storage blocked; ignore */ } saved = null; } if (!saved) { // Fresh install (or post-cleanup fallthrough): default to the // bundled 3D Highway when available + WebGL2-capable, falling // back to Auto otherwise so the arrangement-matching plugins // (piano on Keys songs, drums on Drums songs, ...) still take // over for non-3D arrangements. const has3D = Array.from(sel.options).some(o => o.value === 'highway_3d'); if (has3D && _canRun3D()) { sel.value = 'highway_3d'; try { localStorage.setItem('vizSelection', 'highway_3d'); } catch (_) {} setViz('highway_3d'); } else { sel.value = 'auto'; try { localStorage.setItem('vizSelection', 'auto'); } catch (_) {} if (has3D && !_canRun3D()) { _markPromoted(); _showWebGL2FallbackToast(); } } } // Close a startup race: if playback began before loadPlugins // finished, song:ready already fired while the picker had no // plugin options — _autoMatchViz saw no candidates and left the // default active. Now that plugins are registered, re-evaluate // against whatever song is currently loaded (a no-op when no song // has been loaded yet, since highway.getSongInfo() returns {}). if (sel.value === 'auto') _autoMatchViz(); } function _tagVizRenderer(renderer, id) { if (!renderer || !id) return renderer; try { if (!renderer.pluginId) renderer.pluginId = id; if (!renderer.source) renderer.source = id; } catch (_) {} return renderer; } // Attribution hooks into the visualization capability domain (cap:6). // Guarded no-ops when the domain host isn't loaded (minimal/test pages). function _notifyVizDomain(id, source) { const domain = window.feedBack && window.feedBack.vizDomain; if (domain && typeof domain.notifyRendererChanged === 'function') { try { domain.notifyRendererChanged(id, source); } catch (_) {} } } function _noteVizAutoMatch(id, matched) { const domain = window.feedBack && window.feedBack.vizDomain; if (domain && typeof domain.noteAutoMatch === 'function') { try { domain.noteAutoMatch(id, matched); } catch (_) {} } } function _installVizRenderer(renderer, id, source = 'user-select') { highway.setRenderer(_tagVizRenderer(renderer, id)); // Drop any stale notation-view hint now that we have a resolved renderer id. // This is also the path used by _autoMatchViz() after it resolves 'auto' to // a real plugin id, so the null passed at evaluation start is corrected here. _dropStaleNotationHint(id); _notifyVizDomain(id, source); if (window.v3VenueViz && typeof window.v3VenueViz.notifyRendererInstalled === 'function') { window.v3VenueViz.notifyRendererInstalled(id); } } function setViz(id) { // Helper: reset the UI and persisted selection to the built-in // "default" entry. Called whenever the requested viz can't be // applied (missing factory, factory threw, factory returned a // non-conforming renderer) so the picker, localStorage, and the // highway's active renderer stay in sync. const fallbackToDefault = () => { try { localStorage.setItem('vizSelection', 'default'); } catch (_) {} const sel = document.getElementById('viz-picker'); if (sel) sel.value = 'default'; highway.setRenderer(null); _syncVenueVizPlayerClass('default'); if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') { window.v3VenueScene3d.syncViz('default'); } _notifyVizDomain('default', 'fallback'); _maybeShowNotationViewHint('default'); }; // When switching away from Auto, reset the closed-state label so the // Auto option shows base text the next time the user opens the dropdown. // Also cancel any pending viz:renderer:ready listener from the previous // Auto match cycle so it can't set a stale label after we've moved on. if (id !== 'auto') { if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; } _setAutoVizLabel(null); } if (id === 'default' || !id) { try { localStorage.setItem('vizSelection', id || 'default'); } catch (_) {} const _sel = document.getElementById('viz-picker'); if (_sel) _sel.value = 'default'; highway.setRenderer(null); _syncVenueVizPlayerClass('default'); if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') { window.v3VenueScene3d.syncViz('default'); } _notifyVizDomain('default', 'user-select'); _maybeShowNotationViewHint('default'); return; } if (id === 'auto') { try { localStorage.setItem('vizSelection', 'auto'); } catch (_) {} _syncVenueVizPlayerClass('auto'); if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') { window.v3VenueScene3d.syncViz('auto'); } _autoMatchViz(); return; } if (id === 'venue') { if (!_canRun3D()) { console.warn('viz picker: WebGL2 unavailable, falling back to Classic 2D Highway'); _markPromoted(); _showWebGL2FallbackToast(); fallbackToDefault(); return; } const venueFactory = window['feedBackViz_highway_3d']; if (typeof venueFactory !== 'function') { console.error('viz picker: venue requires feedBackViz_highway_3d'); fallbackToDefault(); return; } let venueRenderer; try { venueRenderer = venueFactory(); } catch (e) { console.error('viz picker: feedBackViz_highway_3d threw for venue mode', e); fallbackToDefault(); return; } if (!venueRenderer || typeof venueRenderer.draw !== 'function') { console.error('viz picker: feedBackViz_highway_3d returned an invalid renderer for venue mode'); fallbackToDefault(); return; } try { localStorage.setItem('vizSelection', 'venue'); } catch (_) {} const _venueSel = document.getElementById('viz-picker'); if (_venueSel) _venueSel.value = 'venue'; _installVizRenderer(venueRenderer, 'highway_3d'); _syncVenueVizPlayerClass('venue'); console.info('[venue-viz] selected venue -> renderer highway_3d, venueClass=true'); if (window.v3VenueMoodFx && typeof window.v3VenueMoodFx.onVenueVisualizationSelected === 'function') { window.v3VenueMoodFx.onVenueVisualizationSelected(); } if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') { window.v3VenueScene3d.syncViz('venue'); } _maybeShowNotationViewHint('highway_3d'); return; } // 3D Highway specifically gates on WebGL2. Any future WebGL viz // plugin should declare its own probe — for now the bundled 3D // Highway is the only viz with this requirement, so the gate is // hardcoded. Falling back to 'default' (Classic 2D) keeps the // picker in sync; toast informs the user. if (id === 'highway_3d' && !_canRun3D()) { console.warn('viz picker: WebGL2 unavailable, falling back to Classic 2D Highway'); _markPromoted(); _showWebGL2FallbackToast(); fallbackToDefault(); return; } const factory = window['feedBackViz_' + id]; if (typeof factory !== 'function') { console.error(`viz picker: factory feedBackViz_${id} not available`); fallbackToDefault(); return; } let renderer; try { renderer = factory(); } catch (e) { console.error(`viz picker: factory feedBackViz_${id} threw`, e); fallbackToDefault(); return; } // Validate shape — highway.setRenderer will itself fall back to // default on a bad renderer, but without this check the UI and // localStorage would still advertise the broken selection. if (!renderer || typeof renderer.draw !== 'function') { console.error(`viz picker: factory feedBackViz_${id} returned an invalid renderer (missing draw)`); fallbackToDefault(); return; } // Persist only once we know the renderer is valid. try { localStorage.setItem('vizSelection', id); } catch (_) {} _installVizRenderer(renderer, id); _syncVenueVizPlayerClass(id); if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') { window.v3VenueScene3d.syncViz(id); } _maybeShowNotationViewHint(id); } // Auto mode: evaluate each registered viz factory's static // `matchesArrangement(songInfo)` predicate and install the first // matching renderer. No match → fall back to the built-in 2D highway. // // vizSelection stays 'auto' across invocations so the next song:ready // re-evaluates. An explicit picker choice overrides Auto by persisting // a different vizSelection. // // Enumerates viz plugins by walking the picker's own '; for (const l of loops) { sel.innerHTML += ``; } if (loops.length > 0) { sel.classList.remove('hidden'); } else { sel.classList.add('hidden'); } delBtn.classList.add('hidden'); } async function loadSavedLoop(loopId) { const sel = document.getElementById('saved-loops'); const opt = sel.selectedOptions[0]; const delBtn = document.getElementById('btn-loop-delete'); if (!loopId || !opt?.dataset.start) { delBtn.classList.add('hidden'); return; } let ok = false; try { // Pass raw strings — setLoop's Number() coercion is stricter than // parseFloat (rejects "12abc") so malformed dataset values throw // and fall into the catch instead of silently truncating. ok = await setLoop(opt.dataset.start, opt.dataset.end); } catch (err) { // Malformed dataset (server returned bad data): treat the same as // a failed seek so the dropdown resyncs and we don't propagate an // uncaught rejection out of the onchange handler. console.warn('[loadSavedLoop] setLoop threw:', err); ok = false; } if (!ok) { // Seek aborted, landed off-target, or input was malformed. // Resync the dropdown with the still-active loop so the UI // doesn't lie about which loop is loaded. _syncSavedLoopSelection(); return; } // Success path: setLoop already called _syncSavedLoopSelection, // which surfaces the delete button when the new loop matches a // saved option (which the dropdown selection guarantees here). } // In-app text prompt — replaces window.prompt(), which Electron does NOT // implement (it logs "prompt() is and will not be supported" and returns null), // so any prompt()-based flow is a silent no-op on desktop. Returns the entered // string, or null if cancelled (Esc / Cancel / backdrop). Styled to match the // edit modal; role=dialog so the global keyboard shortcuts ignore typing here. // Injection-safe: all caller text is set via textContent / value, never innerHTML. function uiPrompt({ title = '', label = '', value = '', okLabel = 'Save', placeholder = '' } = {}) { return new Promise((resolve) => { const modal = document.createElement('div'); modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); if (title) modal.setAttribute('aria-label', title); modal.innerHTML = `
`; const titleEl = modal.querySelector('[data-ui-prompt-title]'); const labelEl = modal.querySelector('[data-ui-prompt-label]'); const input = modal.querySelector('[data-ui-prompt-input]'); const okEl = modal.querySelector('[data-ui-prompt-ok]'); if (title) { titleEl.textContent = title; titleEl.hidden = false; } if (label) { labelEl.textContent = label; labelEl.hidden = false; } okEl.textContent = okLabel; input.value = value; if (placeholder) input.placeholder = placeholder; // Restore focus to wherever it was when we're done (matches the edit // modal's behavior so keyboard users aren't dumped at the page top). const previousActiveElement = document.activeElement; const focusables = () => Array.from( modal.querySelectorAll('input, button, [tabindex]:not([tabindex="-1"])'), ).filter((el) => !el.disabled && el.offsetParent !== null); let settled = false; const close = (result) => { if (settled) return; settled = true; document.removeEventListener('keydown', onKey, true); modal.remove(); if (previousActiveElement && typeof previousActiveElement.focus === 'function') { previousActiveElement.focus(); } resolve(result); }; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(null); return; } // Trap Tab inside the modal so focus can't wander to the page behind it. if (e.key === 'Tab') { const items = focusables(); if (!items.length) return; const first = items[0]; const last = items[items.length - 1]; const active = document.activeElement; if (e.shiftKey && (active === first || !modal.contains(active))) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && (active === last || !modal.contains(active))) { e.preventDefault(); first.focus(); } } }; modal.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); close(input.value); }); modal.querySelector('[data-ui-prompt-cancel]').addEventListener('click', () => close(null)); // Backdrop (overlay itself, not the panel) cancels. modal.addEventListener('mousedown', (e) => { if (e.target === modal) close(null); }); document.addEventListener('keydown', onKey, true); document.body.appendChild(modal); input.focus(); input.select(); }); } async function saveCurrentLoop() { if (loopA === null || loopB === null || !currentFilename) return; const name = await uiPrompt({ title: 'Save Loop', label: 'Loop name', value: 'Loop', okLabel: 'Save' }); if (name === null) return; // cancelled const finalName = name.trim() || 'Loop'; // never persist an empty name await fetch('/api/loops', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: decodeURIComponent(currentFilename), name: finalName, start: loopA, end: loopB, }), }); await loadSavedLoops(); document.getElementById('btn-loop-save').classList.add('hidden'); } async function deleteSelectedLoop() { const sel = document.getElementById('saved-loops'); const loopId = sel.value; if (!loopId) return; await fetch(`/api/loops/${loopId}`, { method: 'DELETE' }); clearLoop(); await loadSavedLoops(); } // ── Count-in click sound (Web Audio API) ──────────────────────────────── let _audioCtx = null; function playClick(high = false) { if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const osc = _audioCtx.createOscillator(); const gain = _audioCtx.createGain(); osc.connect(gain); gain.connect(_audioCtx.destination); osc.frequency.value = high ? 1200 : 800; osc.type = 'sine'; gain.gain.setValueAtTime(0.5, _audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, _audioCtx.currentTime + 0.08); osc.start(_audioCtx.currentTime); osc.stop(_audioCtx.currentTime + 0.08); } let _countingIn = false; let _countOverlay = null; // Generation token so teardown can cancel an in-progress count-in. Each // startCountIn() captures the gen at entry; rewindStep, the loop-wrap // then-callback, and beginCount's tick all bail when their captured gen // no longer matches. Bumped by _cancelCountIn(). let _countInGen = 0; let _countInTimer = null; let _countInRaf = 0; // Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the // highway when a song is loaded, alongside the count-in. Torn down together // with the count-in via _cancelCountIn(). let _creditsOverlay = null; let _creditsTimer = null; let _creditsHideOnPlay = null; let _creditsMaxTimer = null; const _CREDITS_HOLD_MS = 3000; // Backstop: the overlay's primary dismiss is song:play, but playback can fail // to start without emitting it (HTML5 autoplay rejection, JUCE start failure, // a count-in handoff that never plays). This hard cap guarantees the credits // never linger over the highway. Generous enough to outlast a normal count-in. const _CREDITS_MAX_MS = 12000; function _cancelCountIn() { _countInGen++; _countingIn = false; hideCountOverlay(); // The credits overlay rides the count-in lifecycle (and its no-count-in // hold timer), so a teardown — leaving the player, loading another song — // must clear it too, or it lingers on the next screen. hideSongCreditsOverlay(); if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; } if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; } } function showCountOverlay(n) { if (!_countOverlay) { _countOverlay = document.createElement('div'); _countOverlay.className = 'fixed inset-0 z-[100] flex items-center justify-center pointer-events-none'; document.body.appendChild(_countOverlay); } _countOverlay.innerHTML = `${n}`; } function hideCountOverlay() { if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; } } // Map a feedpak author `role` to a friendly " by" credit line. The // recommended vocabulary is from feedpak spec §5.4; unknown roles are // title-cased ("foo" → "Foo by"); a missing role shows the bare name. const _CREDIT_ROLE_VERBS = { charter: 'Charted by', transcriber: 'Transcribed by', arranger: 'Arranged by', editor: 'Edited by', mixer: 'Mixed by', engineer: 'Engineered by', proofreader: 'Proofread by', }; function _creditLineLabel(role) { if (!role) return ''; const key = String(role).trim().toLowerCase(); if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key]; return key.charAt(0).toUpperCase() + key.slice(1) + ' by'; } // Show the feedpak contributor credits over the highway. `authors` is the // sanitized [{name, role}] list from window.feedBack.currentSong.authors. // Anchored to the lower third (bottom-center) so it never collides with the // vertically-centered count-in number, and pointer-events-none so it never // intercepts clicks. No-op when there are no contributors to show. function showSongCreditsOverlay(authors) { if (!Array.isArray(authors) || authors.length === 0) return; if (!_creditsOverlay) { _creditsOverlay = document.createElement('div'); _creditsOverlay.className = 'song-credits-overlay'; document.body.appendChild(_creditsOverlay); } // Build via DOM + textContent — author names are untrusted pack data and // must never be interpolated as HTML. _creditsOverlay.replaceChildren(); const card = document.createElement('div'); card.className = 'song-credits-card'; const eyebrow = document.createElement('div'); eyebrow.className = 'song-credits-eyebrow'; eyebrow.textContent = 'Credits'; card.appendChild(eyebrow); const title = (window.feedBack && window.feedBack.currentSong && window.feedBack.currentSong.title) || ''; if (title) { const heading = document.createElement('div'); heading.className = 'song-credits-heading'; heading.textContent = title; card.appendChild(heading); } for (const a of authors) { if (!a || !a.name) continue; const row = document.createElement('div'); row.className = 'song-credits-line'; const label = _creditLineLabel(a.role); if (label) { const lab = document.createElement('span'); lab.className = 'song-credits-role'; lab.textContent = label + ' '; row.appendChild(lab); } const nm = document.createElement('span'); nm.className = 'song-credits-name'; nm.textContent = a.name; row.appendChild(nm); card.appendChild(row); } _creditsOverlay.appendChild(card); // Arm the backstop so the overlay self-clears even if playback never starts // / never emits song:play. song:play (or any teardown) clears it earlier. if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer); _creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS); } function hideSongCreditsOverlay() { if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; } if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; } if (_creditsHideOnPlay) { window.feedBack.off('song:play', _creditsHideOnPlay); _creditsHideOnPlay = null; } if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; } } async function startCountIn(opts = {}) { if (_countingIn) return; _countingIn = true; // Snapshot the current gen so every delayed callback (rewind frames, // post-seek then, count-in ticks, post-count play) can bail if a // teardown bumped the gen mid-flight via _cancelCountIn(). const gen = _countInGen; const immediate = !!opts.immediate; if (window._juceMode) { await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in count-in:', err)); } else { audio.pause(); } if (gen !== _countInGen) return; // teardown during pause // Section-practice entry: already at loop A after setLoop(); skip the // B→A rewind animation used on loop wrap and go straight to clicks. if (immediate) { if (loopA === null || loopB === null) { _countingIn = false; return; } lastAudioTime = loopA; highway.setTime(loopA); if (window.feedBack) { window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA }); } beginCount(); return; } // Rewind animation: sweep highway time from B to A const rewindDuration = 400; // ms const rewindStart = performance.now(); const fromTime = loopB; const toTime = loopA; function rewindStep(now) { if (gen !== _countInGen) return; // teardown mid-rewind const elapsed = now - rewindStart; const t = Math.min(elapsed / rewindDuration, 1); // Ease out quad const eased = 1 - (1 - t) * (1 - t); const currentT = fromTime + (toTime - fromTime) * eased; highway.setTime(currentT); if (t < 1) { _countInRaf = requestAnimationFrame(rewindStep); } else { _countInRaf = 0; // Rewind done — set final position and start count. // Await the JUCE seek so the engine has repositioned before // we start the click track (HTML5 path is synchronous). _audioSeek(loopA, 'loop-wrap').then((r) => { if (gen !== _countInGen) return; // teardown during seek // Abort the loop restart in two cases: // 1. Cancelled (player torn down): don't beginCount on a // new session. // 2. Off-target landing (JUCE rollback / clamp far from // loopA): proceeding would emit loop:restart and start // a count-in from the wrong position. Audio is at // r.from / r.to, which is not where the loop wants to // resume — better to drop this iteration than play out // of sync. // 50 ms tolerance: well within JUCE's normal seek precision // but tight enough to catch a real rollback or no-op. if (!r.completed || Math.abs(r.to - loopA) > 0.05) { // startCountIn paused audio at entry but left isPlaying // alone — beginCount would have set it on resume. On // abort, sync the transport: audio is paused, so // isPlaying must reflect that and the button + plugin // host must agree. _countingIn = false; if (isPlaying) { isPlaying = false; setPlayButtonState(false); if (window.feedBack) { window.feedBack.isPlaying = false; window.feedBack.emit('song:pause', _songEventPayload()); } } return; } // Use the verified post-seek clock for the chart so audio // and chart stay in sync if JUCE clamped to slightly // before/after loopA. The loop:restart event keeps `time: // loopA` because subscribers treat that as the semantic // marker for "new iteration starts at A", not the actual // audio position. lastAudioTime = r.to; highway.setTime(r.to); window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA }); beginCount(); }); } } _countInRaf = requestAnimationFrame(rewindStep); function beginCount() { const bpm = highway.getBPM(loopA); const beatInterval = 60 / bpm; let count = 0; function tick() { if (gen !== _countInGen) return; // teardown mid-count count++; if (count > 4) { hideCountOverlay(); _countingIn = false; if (window._juceMode) { jucePlayer.play().then((started) => { if (gen !== _countInGen) return; // teardown during play start if (!started) return; isPlaying = true; setPlayButtonState(true); window.feedBack.isPlaying = true; const payload = _songEventPayload(); window.feedBack.emit('song:play', payload); window.feedBack.emit('song:resume', payload); }).catch((err) => console.error('[app] jucePlayer.play error:', err)); } else { audio.play().then(() => { if (gen !== _countInGen) return; isPlaying = true; setPlayButtonState(true); }).catch((err) => { if (gen !== _countInGen) return; // An engine reroute's deliberate pause aborts this play() // while playback continues on JUCE — don't reset the // button (mirrors the togglePlay guard). if (window._juceRerouteInProgress) return; // Same rationale as togglePlay: don't claim playback // started if the Promise rejected. console.error('[app] audio.play() rejected after count-in:', err); isPlaying = false; setPlayButtonState(false); }); } return; } showCountOverlay(count); playClick(count === 1); _countInTimer = setTimeout(tick, beatInterval * 1000); } _countInTimer = setTimeout(tick, 500); } } // Start-of-song count-in: a 4-beat click before playback begins, gated by the // "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's // overlay + click + gen-token cancellation, but counts from the song's current // position (0 at song start) with no loop A/B rewind. startCountIn() is loop- // coupled (early-returns when loopA/loopB are null), so this is a sibling // rather than an overload. Hands off to togglePlay() once the count completes. async function startSongCountIn() { if (_countingIn) return; _countingIn = true; // Snapshot the gen so a teardown (showScreen/playSong calls _cancelCountIn) // bumps it and every delayed callback below bails. const gen = _countInGen; if (window._juceMode) { await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in song count-in:', err)); } else { audio.pause(); } if (gen !== _countInGen) return; // teardown during pause const startT = lastAudioTime || 0; let bpm = highway.getBPM(startT); // Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each). if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120; const beatInterval = 60 / bpm; let count = 0; function tick() { if (gen !== _countInGen) return; // teardown mid-count count++; if (count > 4) { hideCountOverlay(); _countingIn = false; // Hand off to the normal play path — togglePlay() flips isPlaying, // updates the button, and emits song:play/resume for plugins. Promise.resolve(togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err)); return; } showCountOverlay(count); playClick(count === 1); _countInTimer = setTimeout(tick, beatInterval * 1000); } // First beat after a short lead-in, matching the loop count-in's 500 ms. _countInTimer = setTimeout(tick, 500); } // Time display + highway sync let lastAudioTime = 0; // hud-time write cache: the 60 Hz tick below used to rewrite textContent // (and getElementById) every tick even though the mm:ss display only // changes once a second — each write invalidates layout. Write-on-change // with a cached element ref (re-resolved if detached). let _hudTimeEl = null; let _hudTimeLast = ''; setInterval(() => { let ct = _audioTime(); const dur = _audioDuration(); if (dur && !_countingIn) { // JUCE end-of-track: HTML5 fires 'ended'; JUCE needs a manual check if (window._juceMode && isPlaying && ct >= dur) { isPlaying = false; setPlayButtonState(false); window.feedBack.isPlaying = false; window.feedBack.emit('song:ended', _songEventPayload()); jucePlayer.pause().catch((err) => console.warn('[app] end-of-track pause error:', err)); } // A-B loop: count-in then seek back to A else if (loopA !== null && loopB !== null && ct >= loopB) { lastAudioTime = loopB; startCountIn(); } // Detect and fix audio time jumps (browser seeking bug; skip for JUCE — position is polled) else if (!window._juceMode && isPlaying && Math.abs(ct - lastAudioTime) > 30 && lastAudioTime > 0) { console.warn(`Audio time jumped from ${lastAudioTime.toFixed(1)} to ${ct.toFixed(1)}, resetting`); _audioSeek(lastAudioTime, 'jump-fix'); // Treat the corrected position as canonical for the rest of this // tick. Otherwise we'd write the stale jumped `ct` into // lastAudioTime below and ping-pong on the next tick. ct = lastAudioTime; } lastAudioTime = ct; const hudText = `${formatTime(ct)} / ${formatTime(dur)}`; if (hudText !== _hudTimeLast) { if (!_hudTimeEl || !_hudTimeEl.isConnected) _hudTimeEl = document.getElementById('hud-time'); if (_hudTimeEl) _hudTimeEl.textContent = hudText; _hudTimeLast = hudText; } if (dur) { _maybeRefreshSectionPracticeDuration(dur); } } _ensureSectionPracticeBar(); if (_sectionPracticeBarIsReady() && _sectionPracticeSourceSections().length) { _updateSectionPracticeHighlight(ct); } if (!_countingIn) highway.setTime(ct); }, 1000 / 60); _installSectionPracticeDrawHook(); // ── Centralized Keyboard Shortcut Registry ─────────────────────────────── // // Plugins can register keyboard shortcuts via window.registerShortcut(). // Shortcuts are scope-aware (global, player, library, plugin-specific) and // support optional condition callbacks for dynamic enable/disable. // // Panel-scoped shortcuts: // - Each panel has its own shortcut registry // - Use window.createShortcutPanel(id) to create a panel // - Use window.setActiveShortcutPanel(id) to set the active panel // - Shortcuts are registered to the active panel // - This allows multiple panels (e.g., splitscreen) to have their own shortcuts // // API: // window.registerShortcut({ // key: string, // Required: key value (e.key) or key code (e.code) // description: string, // Required: shown in help panel // scope: 'global' | 'player' | 'library' | 'settings' | 'plugin-{id}', // Default: 'global' // condition: () => boolean, // Optional: dynamic enable/disable guard // handler: (e) => void, // Required: callback when shortcut triggers // modifiers: { // Optional: require modifier keys // ctrl?: boolean, // alt?: boolean, // shift?: boolean, // meta?: boolean // } // }); // // Panel API: // window.createShortcutPanel(id) - Create a new panel // window.setActiveShortcutPanel(id) - Set the active panel for registration // window.getActiveShortcutPanel() - Get the current active panel // window.isInShortcutPanel() - Check if running in a panel (not default) // window.getGlobalShortcutContext() - Get default panel for truly global shortcuts // // Note: The handler receives the KeyboardEvent, so you can check // e.shiftKey, e.altKey, etc. directly in your handler if you need // behavior that depends on modifier state (e.g., different actions // for Shift+key vs key alone). Use the modifiers option when you // want the shortcut to ONLY fire with specific modifiers. // // See CLAUDE.md for full documentation. // ── Window ID system for per-window shortcuts ──────────────────────────────── // Each window gets a unique ID so plugins can register window-specific shortcuts. // This is useful for popup windows (e.g., splitscreen plugin) that need their // own keyboard shortcuts. let _shortcutWindowId = null; window.getShortcutWindowId = () => { if (_shortcutWindowId) return _shortcutWindowId; // Generate a unique ID for this window _shortcutWindowId = 'win-' + Math.random().toString(36).substr(2, 9); return _shortcutWindowId; }; // ── Shortcut registry ─────────────────────────────────────────────────────── // ── Panel-scoped shortcut system ─────────────────────────────────────────── // Each panel has its own shortcut registry. This allows multiple panels // (e.g., splitscreen) to have their own keyboard shortcuts without collisions. class ShortcutPanel { constructor(id) { this.id = id; this.shortcuts = new Map(); } _compositeKey(key, scope) { return `${scope}::${key}`; } registerShortcut(options) { const { key, description, scope = 'global', condition = null, handler, modifiers = null } = options; if (!key || !handler) { console.error(`registerShortcut: key and handler are required`); return; } // Validate scope const validScopes = ['global', 'player', 'library', 'settings']; const isValidScope = validScopes.includes(scope) || scope.startsWith('plugin-'); if (!isValidScope) { console.warn(`registerShortcut: invalid scope '${scope}'. Valid scopes are: global, player, library, settings, or plugin-{id}`); } // Conflict detection: warn if key+scope is already registered const compositeKey = this._compositeKey(key, scope); if (this.shortcuts.has(compositeKey)) { console.warn(`registerShortcut [${this.id}]: '${key}' in scope '${scope}' is already registered; overwriting. Previous:`, this.shortcuts.get(compositeKey)); } this.shortcuts.set(compositeKey, { key, description, scope, condition, handler, modifiers }); } unregisterShortcut(key, scope) { return this.shortcuts.delete(this._compositeKey(key, scope)); } clearShortcuts() { this.shortcuts.clear(); } listShortcuts() { return Array.from(this.shortcuts.entries()).map(([ck, s]) => [s.key, s]); } } // Global panel management const _panels = new Map(); let _activePanel = null; let _defaultPanel = null; // Create default panel on init const defaultPanel = new ShortcutPanel('default'); _panels.set('default', defaultPanel); _defaultPanel = 'default'; _activePanel = 'default'; // ── Panel API ─────────────────────────────────────────────────────────────── window.createShortcutPanel = (id) => { if (_panels.has(id)) { console.warn(`createShortcutPanel: panel '${id}' already exists`); return _panels.get(id); } const panel = new ShortcutPanel(id); _panels.set(id, panel); return panel; }; window.setActiveShortcutPanel = (id) => { if (!_panels.has(id)) { console.error(`setActiveShortcutPanel: panel '${id}' does not exist`); return; } _activePanel = id; }; window.getActiveShortcutPanel = () => _activePanel; window.isInShortcutPanel = () => { return _activePanel !== 'default'; }; window.getGlobalShortcutContext = () => { console.warn('getGlobalShortcutContext: Global shortcuts are exceptional. Consider using panel-scoped shortcuts instead.'); return _panels.get('default'); }; // ── Shortcut registry (routes to active panel) ─────────────────────────────── window.registerShortcut = (options) => { const panelId = _activePanel || _defaultPanel || 'default'; const panel = _panels.get(panelId); if (!panel) { console.error(`registerShortcut: No panel found for registration: ${panelId}`); return; } panel.registerShortcut(options); }; // Flat, read-only snapshot of every registered shortcut across all panels, // for the Settings → Keybinds reference tab. Dedupes by combo+scope (the same // shortcut can live in both the active panel and the default panel) and uses // the same modifier-prefix formatting as the shortcuts modal. Returns // [{ combo, description, scope }]; remapping is not supported, so this is // purely informational. window.getAllShortcuts = () => { const fmt = (s) => { const m = s.modifiers || {}; return (m.ctrl ? 'Ctrl+' : '') + (m.alt ? 'Alt+' : '') + (m.shift ? 'Shift+' : '') + (m.meta ? 'Meta+' : '') + s.key; }; const seen = new Set(); const out = []; for (const [, panel] of _panels) { if (!panel || !panel.shortcuts) continue; for (const [, s] of panel.shortcuts) { const combo = fmt(s); const dedupe = combo + '|' + (s.scope || ''); if (seen.has(dedupe)) continue; seen.add(dedupe); out.push({ combo, description: s.description || '', scope: s.scope || 'global' }); } } return out; }; window.unregisterShortcut = (key, scope) => { // Try the active panel first to preserve panel isolation; fall back to // other panels so a shortcut registered before a panel switch is still // removable. const resolvedScope = scope || 'global'; const activePanelId = _activePanel || _defaultPanel || 'default'; const activePanel = _panels.get(activePanelId); if (activePanel && activePanel.unregisterShortcut(key, resolvedScope)) { return true; } for (const [panelId, panel] of _panels) { if (panelId === activePanelId) continue; if (panel.unregisterShortcut(key, resolvedScope)) { return true; } } return false; }; window.clearWindowShortcuts = (windowId) => { // Remove all shortcuts registered for a specific window // This is for backward compatibility with window-specific shortcuts let removed = 0; for (const [panelId, panel] of _panels) { if (panelId.startsWith(`window-${windowId}`)) { panel.clearShortcuts(); _panels.delete(panelId); removed++; } } return removed; }; function _getCurrentContext() { const currentScreen = document.querySelector('.screen.active')?.id; return { screen: currentScreen, windowId: window.getShortcutWindowId(), activePanel: _activePanel, isPlayer: currentScreen === 'player', isLibrary: ['home', 'favorites'].includes(currentScreen), isSettings: currentScreen === 'settings', isPlugin: currentScreen?.startsWith('plugin-') }; } function _isShortcutActive(shortcut, ctx) { if (shortcut.scope === 'global') return true; if (shortcut.scope === 'player' && ctx.isPlayer) return true; if (shortcut.scope === 'library' && ctx.isLibrary) return true; if (shortcut.scope === 'settings' && ctx.isSettings) return true; if (shortcut.scope.startsWith('plugin-')) { const pluginId = shortcut.scope.replace('plugin-', ''); return ctx.screen === `plugin-${pluginId}`; } return false; } function _modifiersMatch(e, modifiers) { if (!modifiers) return true; if (modifiers.ctrl !== undefined && modifiers.ctrl !== e.ctrlKey) return false; if (modifiers.alt !== undefined && modifiers.alt !== e.altKey) return false; if (modifiers.shift !== undefined && modifiers.shift !== e.shiftKey) return false; if (modifiers.meta !== undefined && modifiers.meta !== e.metaKey) return false; return true; } // Debug mode for keyboard shortcuts let _DEBUG_SHORTCUTS = false; window._setDebugShortcuts = (enabled) => { _DEBUG_SHORTCUTS = enabled; console.log(`[Shortcuts] Debug mode ${enabled ? 'ENABLED' : 'DISABLED'}`); }; window._listShortcuts = () => { console.log('=== Registered Shortcuts ==='); for (const [panelId, panel] of _panels) { console.log(`Panel: ${panelId}`); for (const [, s] of panel.shortcuts) { console.log(` ${s.key.padEnd(15)} | ${s.scope.padEnd(10)} | ${s.description}`); } } console.log('=== End ==='); }; window._testShortcut = (key, scope) => { // Mirror the dispatcher: try the active panel first, then default. const resolvedScope = scope || 'global'; const tried = new Set(); const panelOrder = [_activePanel, _defaultPanel, 'default'].filter(id => { if (!id || tried.has(id)) return false; tried.add(id); return true; }); for (const panelId of panelOrder) { const panel = _panels.get(panelId); if (!panel) continue; const shortcut = panel.shortcuts.get(panel._compositeKey(key, resolvedScope)); if (!shortcut) continue; const ctx = _getCurrentContext(); const active = _isShortcutActive(shortcut, ctx); let conditionMet = true; if (shortcut.condition) { try { conditionMet = !!shortcut.condition(); } catch (err) { conditionMet = `threw: ${err.message}`; } } console.log(`Shortcut '${key}' [${resolvedScope}] [${panelId}]:`, { description: shortcut.description, scope: shortcut.scope, currentContext: ctx, isActive: active, conditionMet }); return; } console.log(`Shortcut '${key}' (scope: ${resolvedScope}) not registered in any panel`); }; // Expose internals for debugging (prefixed with _ to indicate private) // These are for development/debugging only and should not be used by plugins. window._panels = _panels; window._getCurrentContext = _getCurrentContext; window._isShortcutActive = _isShortcutActive; // ── Registry-based keydown handler ───────────────────────────────────────── // // This handler processes all registered shortcuts through the central registry. // It runs after the library navigation handler (which handles /, ?, c, f, e, etc.) // and before any other keydown listeners. document.addEventListener('keydown', e => { if (_shortcutDispatchBlocked(e)) return; const ctx = _getCurrentContext(); const activePanel = _panels.get(_activePanel); const defaultPanel = _panels.get('default'); if (!activePanel && !defaultPanel) return; if (_DEBUG_SHORTCUTS) { console.log('[Shortcuts] Key pressed:', { key: e.key, code: e.code, ctx, activePanel: _activePanel }); } // Try active panel first, then fall back to default const panelsToDispatch = []; if (activePanel && activePanel !== defaultPanel) panelsToDispatch.push(activePanel); if (defaultPanel) panelsToDispatch.push(defaultPanel); for (const panel of panelsToDispatch) { for (const [, shortcut] of panel.shortcuts) { // Match on both e.key (character produced) and e.code (physical key) if (e.key !== shortcut.key && e.code !== shortcut.key) continue; // Check modifier keys if specified if (!_modifiersMatch(e, shortcut.modifiers)) continue; if (_DEBUG_SHORTCUTS) { console.log('[Shortcuts] Matched shortcut:', shortcut.key, shortcut); } // Check scope if (!_isShortcutActive(shortcut, ctx)) { if (_DEBUG_SHORTCUTS) { console.log('[Shortcuts] Not active - scope mismatch:', shortcut.scope, ctx); } continue; } // Check condition callback — guard against plugin errors if (shortcut.condition) { try { if (!shortcut.condition()) { if (_DEBUG_SHORTCUTS) { console.log('[Shortcuts] Not active - condition failed'); } continue; } } catch (err) { console.error('[Shortcuts] condition() threw for key:', shortcut.key, err); continue; } } e.preventDefault(); if (_DEBUG_SHORTCUTS) { console.log('[Shortcuts] Executing handler for:', shortcut.key); } // Guard handler against plugin errors try { shortcut.handler(e); } catch (err) { console.error('[Shortcuts] handler() threw for key:', shortcut.key, err); } return; } } if (_DEBUG_SHORTCUTS) { console.log('[Shortcuts] No shortcut matched for:', e.key, e.code); } }); // ── Window cleanup ─────────────────────────────────────────────────────────── // Clean up window-specific shortcuts when a window is closed. // This is important for popup windows (e.g., splitscreen plugin) that // may be closed by the user. window.addEventListener('beforeunload', () => { const windowId = window.getShortcutWindowId(); const removed = window.clearWindowShortcuts(windowId); if (removed > 0 && _DEBUG_SHORTCUTS) { console.log(`[Shortcuts] Cleaned up ${removed} shortcuts for window ${windowId}`); } }); // ── Register built-in shortcuts ─────────────────────────────────────────── // Global shortcuts registerShortcut({ key: '?', description: 'Show keyboard shortcuts', scope: 'global', handler: () => _openShortcutsModal() }); // Library shortcuts registerShortcut({ key: '/', description: 'Focus search', scope: 'library', handler: () => { const input = _activeSearchInput(); if (input) input.focus(); } }); registerShortcut({ key: 'f', description: 'Toggle favorite', scope: 'library', handler: () => { // Handled by library navigation - this is for documentation only } }); registerShortcut({ key: 'e', description: 'Edit metadata', scope: 'library', handler: () => { // Handled by library navigation - this is for documentation only } }); // Player shortcuts registerShortcut({ key: 'Space', description: 'Play/Pause', scope: 'player', handler: () => togglePlay() }); registerShortcut({ key: 'ArrowLeft', description: 'Seek back 5 seconds', scope: 'player', handler: () => seekBy(-5) }); registerShortcut({ key: 'ArrowRight', description: 'Seek forward 5 seconds', scope: 'player', handler: () => seekBy(5) }); registerShortcut({ key: 'Escape', description: 'Back to library', scope: 'player', handler: () => requestExitSong() }); registerShortcut({ key: 'Escape', description: 'Go back to previous screen', scope: 'settings', handler: () => showScreen(_settingsOriginScreen || 'home') }); registerShortcut({ key: '[', description: 'Offset audio back (Shift: 50ms, else 10ms)', scope: 'player', handler: (e) => nudgeAvOffsetMs(e.shiftKey ? -50 : -10) }); registerShortcut({ key: ']', description: 'Offset audio forward (Shift: 50ms, else 10ms)', scope: 'player', handler: (e) => nudgeAvOffsetMs(e.shiftKey ? 50 : 10) }); registerShortcut({ key: '+', description: 'Volume up', scope: 'player', modifiers: { ctrl: false, alt: false, meta: false }, handler: () => _adjustSongVolume(1) }); // Layout-portable alias — matches the physical "=/+" key (e.code === 'Equal') // regardless of keyboard layout or shift state, so non-US layouts that // don't map Shift+= to '+' still work. registerShortcut({ key: 'Equal', description: 'Volume up', scope: 'player', modifiers: { ctrl: false, alt: false, meta: false }, handler: () => _adjustSongVolume(1) }); registerShortcut({ key: '-', description: 'Volume down', scope: 'player', modifiers: { ctrl: false, alt: false, meta: false }, handler: () => _adjustSongVolume(-1) }); registerShortcut({ key: 'Minus', description: 'Volume down', scope: 'player', modifiers: { ctrl: false, alt: false, meta: false }, handler: () => _adjustSongVolume(-1) }); // ── Edit metadata modal ───────────────────────────────────────────────── function openEditModal(songData, openerEl) { const artUrl = `/api/song/${encodeURIComponent(songData.f)}/art?t=${Date.now()}`; const modal = document.createElement('div'); modal.id = 'edit-modal'; modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm'; // role=dialog: assistive tech announces it as a modal; also lets // the global keyboard listener's `_isInsideInteractiveControl` // bail when typing inside the modal so Library shortcuts don't // hijack keys from the edit form. modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-label', 'Edit song metadata'); // Record the element that triggered the modal so Esc / Cancel can // return focus to the exact entry the user was on, even if // _lastLibSelected changes before the modal closes. // Prefer the explicitly-passed openerEl (from the edit-btn click // handler, which has the exact [data-play] parent) over // _lastLibSelected, which may not have been updated when the // click's stopPropagation() prevented the card-click handler. const _emActive = document.querySelector('.screen.active'); const _emLast = (_lastLibSelected && document.body.contains(_lastLibSelected) && _emActive && _emActive.contains(_lastLibSelected)) ? _lastLibSelected : null; modal._opener = (openerEl && document.body.contains(openerEl)) ? openerEl : _emLast; modal.innerHTML = `

Edit Song

Change

Click image to change album art

`; document.body.appendChild(modal); // Move focus into the dialog's first text input so background // shortcuts (and arrow nav) can't fire on the underlying library // entry while the edit form is open. Title is the natural primary // field — most edits are correcting spelling there. Caret-end // selection so the user can keep typing rather than overtype the // current value. const titleInput = document.getElementById('edit-title'); if (titleInput) { titleInput.focus({ preventScroll: true }); try { const len = titleInput.value.length; titleInput.setSelectionRange(len, len); } catch { /* some browsers reject selection on certain input types */ } } // Trap Tab / Shift+Tab inside the modal so focus can't escape to // the library content underneath while the edit form is open. _trapFocusInModal(modal); // Click on art triggers file input document.getElementById('edit-art-wrapper').addEventListener('click', () => { document.getElementById('edit-art-file').click(); }); // Save — wired in JS (not an inline onclick) so the filename never has to // survive embedding in a single-quoted attribute string. encodeURIComponent // does NOT escape `'`, so a filename like `Bob's Song.sloppak` used to break // the inline `saveEditModal('…')` handler and silently fail the save. The // raw filename lives in the closure; encode it here for saveEditModal. const saveBtn = modal.querySelector('[data-edit-save]'); if (saveBtn) { saveBtn.addEventListener('click', () => saveEditModal(encodeURIComponent(songData.f))); } const deleteBtn = modal.querySelector('[data-delete-filename]'); if (deleteBtn) { deleteBtn.addEventListener('click', () => { deleteSongFromModal(deleteBtn.dataset.deleteFilename); }); } // Close on backdrop click or Cancel button; restore focus to opener. // Backdrop dismissal requires the gesture's mousedown to have STARTED on // the backdrop — not just the click/mouseup to land there. Otherwise a // click-drag that begins inside a field (e.g. selecting text) and is // released past the modal edge resolves its `click` target to the backdrop // and silently discards the edit. Cancel / ✕ (data-edit-close) always close. let _downOnBackdrop = false; modal.addEventListener('mousedown', (e) => { _downOnBackdrop = (e.target === modal); }); modal.addEventListener('click', (e) => { if (!_editModalShouldClose(e.target, modal, _downOnBackdrop)) return; const opener = modal._opener; modal.remove(); const focusTarget = (opener && document.body.contains(opener)) ? opener : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null); if (focusTarget) focusTarget.focus({ preventScroll: true }); }); } // Whether a click on the edit-metadata modal should dismiss it. The Cancel / ✕ // control (data-edit-close) always dismisses. A backdrop dismissal needs BOTH // the click target to be the backdrop element itself AND the gesture to have // started there (downOnBackdrop) — so a click-drag begun inside a field and // released on the backdrop does not discard the form. Pure + top-level so it's // unit-testable in isolation. function _editModalShouldClose(clickTarget, modalEl, downOnBackdrop) { if (clickTarget && clickTarget.closest && clickTarget.closest('[data-edit-close]')) return true; return clickTarget === modalEl && downOnBackdrop === true; } function previewEditArt(input) { if (!input.files || !input.files[0]) return; const reader = new FileReader(); reader.onload = (e) => { document.getElementById('edit-art-preview').src = e.target.result; }; reader.readAsDataURL(input.files[0]); } async function saveEditModal(encodedFilename) { const filename = decodeURIComponent(encodedFilename); // Save metadata await fetch(`/api/song/${encodeURIComponent(filename)}/meta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: document.getElementById('edit-title').value.trim(), artist: document.getElementById('edit-artist').value.trim(), album: document.getElementById('edit-album').value.trim(), // Year is normalised server-side (non-numeric/empty → ""), so a // blank or cleared field round-trips safely. year: document.getElementById('edit-year').value.trim(), }), }); // Upload art if changed const fileInput = document.getElementById('edit-art-file'); if (fileInput.files && fileInput.files[0]) { const reader = new FileReader(); reader.onload = async (e) => { await fetch(`/api/song/${encodeURIComponent(filename)}/art/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: e.target.result }), }); }; reader.readAsDataURL(fileInput.files[0]); } const modal = document.getElementById('edit-modal'); const opener = modal ? modal._opener : null; if (modal) modal.remove(); // Restore focus to the entry the modal was opened from so subsequent // keyboard navigation resumes correctly (same as Esc / Cancel paths). const focusTarget = (opener && document.body.contains(opener)) ? opener : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null); if (focusTarget) focusTarget.focus({ preventScroll: true }); // Refresh current view const activeScreen = document.querySelector('.screen.active'); if (activeScreen?.id === 'favorites') loadFavorites(); else loadLibrary(); } async function deleteSongFromModal(filename) { const title = (document.getElementById('edit-title')?.value || filename).trim(); const ok = await _confirmDialog({ title: 'Remove from library?', body: `

Remove ${_escAttr(title)} from your library?

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

`, confirmText: 'Remove', cancelText: 'Cancel', danger: true, }); if (!ok) return; let resp; try { resp = await fetch(`/api/song/${encodeURIComponent(filename)}`, { method: 'DELETE' }); } catch (e) { alert(`Delete failed: ${e.message}`); return; } if (!resp.ok) { let msg = resp.statusText; try { msg = (await resp.json()).error || msg; } catch (_) {} alert(`Delete failed: ${msg}`); return; } const modal = document.getElementById('edit-modal'); if (modal) modal.remove(); _treeStats = null; _favTreeStats = null; _tuningNames = null; // Remove the deleted song's card from any currently-rendered grid/tree // so the user sees it disappear without waiting for a refetch. A full // loadLibrary() here would re-call loadGridPage(currentPage), which // uses 'append' mode when currentPage > 0 and re-appends the same // (now-shortened) page on top of what's already rendered — leaving // the deleted card visible. Direct DOM removal also preserves scroll // position, which a refetch from page 0 would lose. _removeLibCardsForFilename(filename); // Tree views group by artist with song counts; a single card removal // leaves stale counts, so refresh the tree for whichever screen we're // looking at (each tree-view renderer replaces innerHTML cleanly). const activeScreen = document.querySelector('.screen.active'); if (activeScreen?.id === 'favorites') { // loadFavorites() routes to either loadFavGridPage (always // 'replace') or loadFavTreeView — both safe for a single delete. loadFavorites(); } else if (libView === 'tree') { loadTreeView(); } // Main library grid view: DOM removal above is sufficient. } function _removeLibCardsForFilename(filename) { // The grid uses data-play="" on each card; the // tree's song rows use the same attribute. encodeURIComponent // matches what renderGridCards / the tree renderer emit. const encoded = encodeURIComponent(filename); const selector = `[data-play="${CSS.escape(encoded)}"]`; let removed = 0; for (const el of document.querySelectorAll(selector)) { el.remove(); removed++; } if (removed === 0) return; // Decrement the visible count badges that loadGridPage / loadTreeView // populated. Counts come from the server's `total` so this is a // best-effort estimate until the next refetch, but it keeps the // displayed number consistent with what's on screen right now. for (const id of ['lib-count', 'fav-count']) { const el = document.getElementById(id); if (!el) continue; const m = (el.textContent || '').match(/^(\d+)/); if (!m) continue; const next = Math.max(0, parseInt(m[1], 10) - removed); el.textContent = (el.textContent || '').replace(/^\d+/, String(next)); } _bumpLibNavGeneration(); } async function syncLibrarySong(providerId, songId, options = {}) { const opts = options && typeof options === 'object' ? options : {}; const { playWhenReady = false } = opts; if (!providerId || !songId) return; const currentState = _librarySyncState(providerId, songId); if (currentState && currentState.status === 'synced' && currentState.localFilename) { if (playWhenReady) playSong(encodeURIComponent(currentState.localFilename), undefined, { bridge: false }); return currentState.result || { filename: currentState.localFilename }; } if (currentState && currentState.status === 'syncing') return null; _setLibrarySyncState(providerId, songId, { status: 'syncing' }); try { const capabilityApi = window.feedBack && window.feedBack.capabilities; let data = null; if (capabilityApi && typeof capabilityApi.command === 'function') { const result = await capabilityApi.command('library', 'sync-song', { requester: 'app.library', target: { providerId, songId }, payload: opts, }); if (result.outcome !== 'handled') throw new Error(result.reason || 'Library provider sync failed'); data = result.payload && result.payload.result; } else { data = await _libraryProviderApi()?.syncSong?.(providerId, songId, opts); } if (!data) throw new Error('Library provider sync did not return a result'); const localFilename = data.filename || data.localFilename || data.local_filename || data.playFilename || data.play_filename || ''; const message = localFilename ? 'Ready to play' : (data.cachedPath ? 'Loaded to local cache' : 'Loaded'); _setLibrarySyncState(providerId, songId, { status: 'synced', message, localFilename, result: data }); _treeStats = null; _favTreeStats = null; _tuningNames = null; _libEpoch++; await loadLibrary(0); if (playWhenReady && localFilename) playSong(encodeURIComponent(localFilename), undefined, { bridge: false }); return data; } catch (error) { _setLibrarySyncState(providerId, songId, { status: 'error', message: error.message || 'Unknown error' }); console.warn('Remote library load failed:', error); return null; } } function _setLibrarySyncState(providerId, songId, state) { _librarySyncStates.set(_librarySyncKey(providerId, songId), state); _renderLibrarySyncState(providerId, songId); } function _renderLibrarySyncState(providerId, songId) { const state = _librarySyncState(providerId, songId); // Filter via dataset rather than building a CSS attribute selector — // CSS.escape is absent in some test environments and older runtimes, // and provider/song IDs are not constrained to CSS-safe strings. const encodedProvider = encodeURIComponent(providerId); const encodedSong = encodeURIComponent(songId); for (const status of document.querySelectorAll('[data-library-sync-status]')) { if (status.dataset.librarySyncProvider !== encodedProvider) continue; if (status.dataset.librarySyncSong !== encodedSong) continue; const layout = status.classList.contains('ml-1') ? 'inline' : 'block'; status.className = _librarySyncStatusClass(state, layout); status.textContent = _librarySyncStatusText(state); } } // Delegated click handlers document.addEventListener('click', e => { // Edit button const edit = e.target.closest('.edit-btn'); if (edit) { e.stopPropagation(); const entry = edit.closest('[data-play]'); openEditModal(JSON.parse(edit.dataset.edit), entry); return; } // Favorite button const fav = e.target.closest('.fav-btn'); if (fav) { e.stopPropagation(); toggleFavorite(decodeURIComponent(fav.dataset.fav)); return; } // Retune button const btn = e.target.closest('.retune-btn'); if (btn) { e.stopPropagation(); retuneSong(btn.dataset.retune, decodeURIComponent(btn.dataset.title), btn.dataset.tuning, btn.dataset.target || 'E Standard'); return; } // Remote song card / row without a local playable file yet. const remoteEntry = e.target.closest('[data-library-song]'); if (remoteEntry && !remoteEntry.dataset.play && !e.target.closest('button')) { const providerId = decodeURIComponent(remoteEntry.dataset.libraryProvider || ''); if (!_providerSupports(providerId, 'song.sync')) return; _setLibSelection(remoteEntry, { focus: false }); syncLibrarySong( providerId, decodeURIComponent(remoteEntry.dataset.librarySong || ''), { playWhenReady: true }, ); return; } // Song card / row — keep persistent selection in sync with mouse // clicks so arrow-keying after a click resumes from where the // user clicked, not from a stale highlight. // Guard: if the click originated from any `; document.body.appendChild(el); } function hideScanBanner() { const el = document.getElementById('scan-banner'); if (el) el.remove(); } let _scanPollId = null; async function pollScanStatus() { try { const resp = await fetch('/api/scan-status'); const data = await resp.json(); if (data.stage === 'error' && data.error) { // Surface the error in the banner and stop polling. showScanBanner(); const file = document.getElementById('scan-file'); const prog = document.getElementById('scan-progress'); const firstNote = document.getElementById('scan-first-note'); if (file) { file.textContent = 'Scan failed: ' + data.error; file.classList.add('text-red-400'); } if (prog) prog.textContent = 'Error'; if (firstNote) firstNote.classList.add('hidden'); clearInterval(_scanPollId); _scanPollId = null; return; } if (data.running) { showScanBanner(); const pct = data.total > 0 ? Math.round(data.done / data.total * 100) : 0; const bar = document.getElementById('scan-bar'); const prog = document.getElementById('scan-progress'); const file = document.getElementById('scan-file'); const firstNote = document.getElementById('scan-first-note'); if (bar) bar.style.width = pct + '%'; if (prog) prog.textContent = `${data.done} / ${data.total} (${pct}%)`; if (file) { const name = (data.current || '').replace(/_p\.archive$/i, '').replace(/_/g, ' '); file.textContent = name || (data.stage === 'listing' ? 'Listing DLC folder...' : 'Processing...'); } if (firstNote) firstNote.classList.toggle('hidden', !data.is_first_scan); } else { if (document.getElementById('scan-banner')) { hideScanBanner(); _treeStats = null; // Refresh stats loadLibrary(); } clearInterval(_scanPollId); _scanPollId = null; } } catch (e) { /* ignore */ } } async function checkScanAndLoad() { const resp = await fetch('/api/scan-status'); const data = await resp.json(); if (data.running) { showScanBanner(); const firstNote = document.getElementById('scan-first-note'); if (firstNote) firstNote.classList.toggle('hidden', !data.is_first_scan); _scanPollId = setInterval(pollScanStatus, 1000); } loadLibrary(); } // ── Plugin loader ─────────────────────────────────────────────────────── let _loadPluginsInFlight = false; const _pluginUiContributions = new Map(); const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu'; function _capabilityInspectorNavEnabled() { try { return localStorage.getItem(CAPABILITY_INSPECTOR_NAV_SETTING) === '1'; } catch (_) { return false; } } // Derive a display label from a (possibly string) nav value. `/api/plugins` // can return `nav` as a plain string (manifest `"nav": "Declared"`) or an // object with a `.label`, and _pluginNav() may synthesize an object (e.g. the // Capability Inspector). Handle all three so string labels and the synthesized // label aren't dropped in favour of the plugin name. function _navLabel(nav, plugin) { if (typeof nav === 'string' && nav.trim()) return nav; if (nav && typeof nav === 'object' && nav.label) return nav.label; return (plugin && (plugin.name || plugin.id)) || ''; } function _pluginNav(plugin) { if (!plugin || !plugin.id) return null; if (plugin.id === 'capability_inspector') { if (!_capabilityInspectorNavEnabled()) return null; return plugin.nav || { label: 'Capabilities', screen: 'plugin-capability_inspector' }; } return plugin.nav || null; } async function _commandUiDomain(domain, command, plugin, payload) { try { if (!window.feedBack?.capabilities?.command) return; await window.feedBack.capabilities.command(domain, command, { requester: plugin.id || 'plugin', target: { id: payload.id, pluginId: plugin.id, region: payload.region }, payload: { ...payload, pluginId: plugin.id }, }); } catch (e) { console.warn(`ui contribution ${command} failed for ${plugin.id}:`, e); } } async function _registerLegacyPluginUiContributions(plugin) { const previous = _pluginUiContributions.get(plugin.id) || []; for (const contribution of previous) { await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution); } const contributions = []; const nav = _pluginNav(plugin); if (nav) { contributions.push({ domain: 'ui.navigation', id: `${plugin.id}:nav`, region: 'plugins', label: _navLabel(nav, plugin), mounted: true }); } if (plugin.has_screen) { contributions.push({ domain: 'ui.plugin-screens', id: `${plugin.id}:screen`, region: 'plugin-screens', label: plugin.name || plugin.id, mounted: true }); } if (plugin.has_settings) { contributions.push({ domain: 'settings', id: `${plugin.id}:settings`, region: 'plugin-settings', label: plugin.name || plugin.id, mounted: true }); } if (plugin.type === 'visualization') { contributions.push({ domain: 'ui.player-overlays', id: `${plugin.id}:visualization`, region: 'visualization-picker', label: plugin.name || plugin.id, mounted: true }); } contributions.sort((a, b) => `${a.domain}:${a.id}`.localeCompare(`${b.domain}:${b.id}`)); _pluginUiContributions.set(plugin.id, contributions); for (const contribution of contributions) { await _commandUiDomain(contribution.domain, 'register-contribution', plugin, contribution); await _commandUiDomain(contribution.domain, 'mount', plugin, contribution); } } // Settings-tab containers that can host plugin
panels on the v3 // tabbed settings page. '#plugin-settings' is the fallback bucket (and the // only container in the classic v2 settings page); the per-tab containers map // to a plugin manifest's settings.category. A plugin with no category, or one // whose tab container is absent (v2, or render not yet run), falls back to // '#plugin-settings'. Body divs injected per plugin use id // `plugin-settings-` and live INSIDE a
, so they are never // direct children of these containers — no id collision in the scans below. const _PLUGIN_SETTINGS_CONTAINER_IDS = [ 'plugin-settings', 'plugin-settings-graphics', 'plugin-settings-mic', 'plugin-settings-progression', ]; function _pluginSettingsContainers() { const out = []; for (const id of _PLUGIN_SETTINGS_CONTAINER_IDS) { const el = document.getElementById(id); if (el) out.push(el); } return out; } function _pluginSettingsTarget(plugin) { const cat = plugin && plugin.settings_category; if (cat) { const el = document.getElementById('plugin-settings-' + cat); if (el) return el; } return document.getElementById('plugin-settings'); } async function loadPlugins() { if (_loadPluginsInFlight) { console.log('[feedBack] loadPlugins: in-flight, skipping'); return null; } _loadPluginsInFlight = true; console.log('[feedBack] loadPlugins: start'); let plugins; const navContainer = document.getElementById('nav-plugins'); const mobileNavContainer = document.getElementById('mobile-nav-plugins'); // Snapshot current nav so we can restore it if the fetch fails. const _savedNav = navContainer ? navContainer.innerHTML : null; const _savedMobileNav = mobileNavContainer ? mobileNavContainer.innerHTML : null; try { const resp = await fetch('/api/plugins'); const fetchedPlugins = await resp.json(); const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || ''))); plugins = fetchedPlugins.slice().sort((a, b) => { const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || '')); return nameDelta || String(a.id || '').localeCompare(String(b.id || '')); }); // NOTE deliberately NO stale-contribution sweep for plugins absent // from this response. Absent ≠ uninstalled: the backend clears its // plugin registry at the start of load_plugins() and repopulates it // incrementally while HTTP stays up, so every backend restart serves a // window of partial (even empty) responses. The old sweep unmounted UI // contributions and unregistered capability participants on mere // absence, permanently breaking still-loaded plugins — their scripts // don't re-run (loadedScripts guard below), so nothing ever // re-registered. A genuine mid-session uninstall now leaves the // (already-evaluated, un-unloadable) script's contributions in place // until reload; its nav entry still disappears because nav is rebuilt // from the response each round. Same invariant as the settings/screen // DOM wipe and _reconcilePluginStyles below. console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins'); try { const capabilityApi = window.feedBack?.capabilities; if (capabilityApi?.registerParticipants) { capabilityApi.registerParticipants(capabilityPlugins); if (capabilityApi.registerCompatibilityShim) { for (const plugin of capabilityPlugins) { for (const shim of Array.isArray(plugin.compatibility_shims) ? plugin.compatibility_shims : []) { capabilityApi.registerCompatibilityShim(shim); } } } capabilityApi.validateRuntime?.({ phase: 'plugin-manifest-load' }); } } catch (e) { console.warn('[feedBack] capability manifest registration failed:', e); } // Plugin settings panels mount into one of several tab containers — // see _pluginSettingsContainers()/_pluginSettingsTarget() above. // Plugins whose screen.js has already been evaluated this session // at the current version AND whose DOM is still in the document. // Their listeners were bound to the existing settings / screen DOM, // so we must preserve that DOM — the script load guard below skips // re-evaluating screen.js, and a fresh empty DOM with no listeners // would leave the plugin half-hydrated on subsequent loadPlugins() // calls (e.g. the streamed refetches in _streamPluginStartup). // // The DOM-existence check is the safety net for plugins that // disappeared and reappeared between calls (uninstall + reinstall, // or a backend snapshot churn that drops a plugin then restores // it). In that case the loadedScripts key would still be set, but // any listeners are bound to elements that have since been removed // — drop the stale key so screen.js re-runs against the fresh DOM // we're about to inject. // Map — one entry per plugin. Storing only the // currently-loaded version (rather than a Set of all (id, version) // pairs ever loaded) means upgrade → downgrade → upgrade cycles // within one session don't leave stale keys that could mistakenly // mark an old version as already-hydrated. Coerce a legacy Set, if // present, to an empty Map — the previous shape never shipped. let loadedScripts = window.feedBack._loadedPluginScripts; if (!(loadedScripts instanceof Map)) { loadedScripts = new Map(); window.feedBack._loadedPluginScripts = loadedScripts; } const _removePluginScriptTags = (pluginId) => { // Filter via dataset rather than a CSS attribute selector — // CSS.escape is not universally available, and plugin IDs // aren't constrained server-side. document.querySelectorAll('script[data-plugin-id]').forEach((s) => { if (s.dataset.pluginId === pluginId) s.remove(); }); }; // Mirror of loadedScripts for the plugin `styles` capability: a single // versioned per plugin lives in , deduped by // id → version so an upgrade swaps it and re-activation doesn't pile up // duplicate tags. The covers both the plugin's screen and its // settings panel. Plugins ship preflight-off (utilities only) CSS, so a // stylesheet that lingers after deactivation can't bleed a base reset. let loadedStyles = window.feedBack._loadedPluginStyles; if (!(loadedStyles instanceof Map)) { loadedStyles = new Map(); window.feedBack._loadedPluginStyles = loadedStyles; } const _removePluginStyleTags = (pluginId) => { // Same dataset-filter rationale as _removePluginScriptTags. document.querySelectorAll('link[data-plugin-id]').forEach((l) => { if (l.dataset.pluginId === pluginId) l.remove(); }); }; const _injectPluginStyles = (plugin) => { // Tear down a we injected earlier this session when the plugin // no longer ships a usable stylesheet — upgraded to drop `styles`, or // to an invalid path — so stale CSS can't keep applying after the // plugin disabled its styling. const teardownStale = () => { if (loadedStyles.has(plugin.id)) { _removePluginStyleTags(plugin.id); loadedStyles.delete(plugin.id); } }; if (!plugin.has_styles || !plugin.styles) { teardownStale(); return; } // `styles` is a plugin-root-relative path (like screen/script/routes) // and must live under assets/ so it serves through the sandboxed // asset route — e.g. "assets/plugin.css". Reject anything that can't // reach a served file or would build a malformed URL: not under // assets/, a `..` traversal segment, a backslash, or a `?`/`#` that // would collide with the cache-busting query we append. The server // also enforces containment via safe_join — this just avoids the // wasted 404 and matches the documented contract. const path = String(plugin.styles).replace(/^\/+/, ''); const unsafe = !path.startsWith('assets/') || /(^|\/)\.\.(\/|$)/.test(path) || /[\\?#]/.test(path); if (unsafe) { console.warn(`Plugin ${plugin.id}: styles must be a path under assets/ with no "..", backslash, or query/fragment (got "${plugin.styles}") — skipping`); teardownStale(); return; } const wantedVersion = plugin.version || ''; // Idempotent: same id+version already injected → nothing to do. if (loadedStyles.get(plugin.id) === wantedVersion) return; // A different version (or none) was loaded — drop the prior // so we never accumulate stale stylesheets across upgrades. _removePluginStyleTags(plugin.id); const link = document.createElement('link'); link.rel = 'stylesheet'; link.dataset.pluginId = plugin.id; link.dataset.pluginVersion = wantedVersion; // Version in the URL (the plugin `version`, mirroring the screen.js // loader's ?v= convention) so a plugin upgrade within one session // fetches fresh CSS instead of a copy cached by path alone. const v = encodeURIComponent(wantedVersion); link.href = `/api/plugins/${plugin.id}/${path}${v ? `?v=${v}` : ''}`; // Cascade ordering: insert this BEFORE core's prebuilt // Tailwind (/static/tailwind.min.css) instead of appending at the // end of . A plugin that ships a full utility build — the // default output of running the Tailwind CLI without a scoped // content config — re-defines core utilities like .grid / // .xl:grid-cols-4; appended last, those equal-specificity rules // would win on source order and clobber core's responsive layout // (e.g. the library grid collapses to 2 columns, the nav bar // breaks). Loading the plugin sheet first means core wins any // EQUAL-specificity collision, while the plugin's own namespaced // classes still apply. A plugin can still deliberately override core // via higher-specificity selectors or !important — this only removes // the accidental source-order clobber. const coreSheet = document.head.querySelector('link[rel="stylesheet"][href*="tailwind.min.css"]') || document.head.querySelector('link[rel="stylesheet"]'); if (coreSheet) { document.head.insertBefore(link, coreSheet); } else { document.head.appendChild(link); } loadedStyles.set(plugin.id, wantedVersion); }; const _reconcilePluginStyles = (currentPlugins) => { // Drop stylesheets for plugins the response KNOWS about but that // are no longer ready+styled this round. _injectPluginStyles below // only visits plugins still returned by the API, so a newly-not- // ready or unstyled plugin would otherwise keep its // applying. Plugins merely ABSENT from the response keep their // stylesheet — a transient partial response during a backend // restart is not an uninstall (same invariant as the screen/ // settings wipe below), and stripping the would leave a // still-loaded plugin visible but unstyled. const responded = new Set(currentPlugins.map((p) => p.id)); const styled = new Set( currentPlugins .filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles) .map((p) => p.id), ); for (const id of Array.from(loadedStyles.keys())) { if (responded.has(id) && !styled.has(id)) { _removePluginStyleTags(id); loadedStyles.delete(id); } } }; const existingSettingsByPluginId = new Map(); for (const container of _pluginSettingsContainers()) { for (const child of container.children) { const pid = child.dataset ? child.dataset.pluginId : null; if (pid) existingSettingsByPluginId.set(pid, child); } } // Plugins named in THIS response. A plugin can be transiently absent // from /api/plugins — the backend clears its registry at the start of // load_plugins() and repopulates it incrementally while HTTP stays up, // so every backend restart serves a window of partial (even empty) // responses. The wipe loops below must never treat that absence as an // uninstall: stripping a still-loaded plugin's DOM while keeping its // loadedScripts entry made the NEXT refetch fail the DOM check and // re-evaluate its screen.js mid-session — which duplicated the desktop // audio_engine's native signal chain (its init re-ran against the // surviving engine chain). Absent plugins keep their DOM and script; // they're re-reconciled when they reappear in a later response. const respondedIds = new Set(plugins.map((p) => p.id)); const alreadyHydrated = new Set(); for (const p of plugins) { if (!p.has_script) continue; // Version must match exactly — an upgrade / downgrade has to // re-run the new script against fresh DOM. if (loadedScripts.get(p.id) !== (p.version || '')) continue; const screenOk = !p.has_screen || !!document.getElementById(`plugin-${p.id}`); const settingsOk = !p.has_settings || existingSettingsByPluginId.has(p.id); if (screenOk && settingsOk) { alreadyHydrated.add(p.id); } else { // DOM was wiped externally (uninstall + reinstall, snapshot // churn) — drop the entry and remove the orphaned