import { bootstrapPluginsAndUi, checkPluginUpdates, loadPlugins, updatePlugin, } from './js/plugin-loader.js'; import { _autoMatchViz, _maybeShowNotationViewHint, _populateVizPicker, setViz, } from './js/viz.js'; import { exportDiagnostics, previewDiagnostics, } from './js/diagnostics-export.js'; import { _confirmDialog, _escAttr, _isElementVisible, _trapFocusInModal, esc, uiPrompt, } from './js/dom.js'; import { hwcInitSettingsUI, initHighwayColors, } from './js/highway-colors.js'; import { displayTuningName, displayTuningTargetDetails, displayTuningTargets, effectiveStringCount, isBassArrangement, parseRawTuningOffsets, songTuningContext, } from './js/tuning-display.js'; import { exportSettings, importSettings, } from './js/settings-io.js'; import { audio } from './js/audio-el.js'; import { S } from './js/player-state.js'; // Side-effect import: the three JUCE shims are IIFEs that install themselves and // publish through window.*. _resetJuceAudioShimChain is the one binding app.js needs. import { _resetJuceAudioShimChain } from './js/juce-audio.js'; import { _clearResumeSession, _hideResumePill, _maybeShowResumePill, _readResumeSession, _snapshotResumeSession, resumeLastSession, } from './js/resume-session.js'; import { _applyMastery, _applyMasteryAvailability, _autoplayExitEnabled, _countdownBeforeSongEnabled, _curPlaybackSpeed, _exitConfirmEnabled, _resetPlaybackSpeedForNewSong, _showUpNextEnabled, _wireSpeedPresetsOnce, applySpeedPreset, setMastery, setSpeed, } from './js/player-controls.js'; import { _cancelCountIn, armCreditsHideOnPlay, hideCountOverlay, hideSongCreditsOverlay, holdCreditsThen, isCountingIn, playClick, scheduleCreditsHide, showCountOverlay, showSongCreditsOverlay, startCountIn, startSongCountIn, } from './js/count-in.js'; import { _loopMutationGen, clearLoop, deleteSelectedLoop, loadSavedLoop, loadSavedLoops, loopA, loopB, saveCurrentLoop, setLoop, setLoopEnd, setLoopStart, updateLoopUI, } from './js/loops.js'; import { _buildSectionParents, _ensureSectionPracticeBar, _hideSectionPracticeBar, _installSectionPracticeDrawHook, _maybeRefreshSectionPracticeDuration, _placeSectionPracticeControlForChrome, _resetSectionPracticeLog, _scheduleSectionPracticeRetries, _sectionPracticeBarContains, _sectionPracticeBarIsReady, _sectionPracticePopoverOpen, _sectionPracticeSourceSections, _sectionPracticeStartTime, _setSectionPracticeMode, _syncSectionPracticeFromLoop, _updateSectionPracticeHighlight, invalidateParentCount, onPhraseNext, onPhrasePrev, onSectionParentClick, onSectionPracticeModeChange, onSectionPracticeWholeChange, practiceSection, renderSectionPracticeBar, resetSelection, toggleSectionPracticePopover, } from './js/section-practice.js'; import { configureHost } from './js/host.js'; // 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). // `_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 _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 ${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 || S.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 = S.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?.(); S.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', 'difficulty', 'difficulty-desc', ]); 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` : ''} ${song.user_difficulty != null ? `◆${esc(song.user_difficulty)}` : ''} ${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 (song.user_difficulty != null) html += `◆${esc(song.user_difficulty)}`; 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); } window.displayTuningName = displayTuningName; window.feedBack = window.feedBack || {}; window.slopsmith = window.feedBack; window.feedBack.displayTuningName = displayTuningName; window.feedBack.isBassArrangement = isBassArrangement; window.feedBack.effectiveStringCount = effectiveStringCount; window.feedBack.songTuningContext = songTuningContext; window.displayTuningTargets = displayTuningTargets; window.displayTuningTargetDetails = displayTuningTargetDetails; window.parseRawTuningOffsets = parseRawTuningOffsets; window.feedBack.displayTuningTargets = displayTuningTargets; window.feedBack.displayTuningTargetDetails = displayTuningTargetDetails; window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets; // 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(); } 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

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(); } // Load library on start. loadSettings is awaited alongside so persisted // values (A/V offset, mastery, etc.) are applied to the highway + HUD // before any playSong runs — otherwise a fast click could start // playback with stale settings before /api/settings returned. (async () => { // Splitscreen pop-out windows (`?ssFollower=1`) load this same app but // get driven into "follower mode" by the splitscreen plugin once it // loads — which is *after* this init runs. Without this, the library // (`#home`, marked `active` in index.html) renders and paints first, so // the popup briefly flashes the song grid before swapping to the player. // Switch to the player screen up front so the popup shows player chrome // (empty, then populated by the plugin) the whole time. The wasted // library fetch below is negligible next to the whole-app + every-plugin // re-load a popup already does. const isFollowerWindow = (() => { try { return new URLSearchParams(location.search).get('ssFollower') === '1'; } catch (_) { return false; } })(); if (isFollowerWindow) { // Await it — showScreen is async, so a bare call would turn even a // synchronous DOM error into an unhandled rejection that this try // couldn't catch. Surface failures (e.g. `#player` missing/renamed) // instead of silently bringing the library flash back. try { await showScreen('player'); } catch (e) { console.warn('[feedBack] follower-window: showScreen("player") failed:', e); } } await loadLibraryProviders({ restoreSaved: true }); // Restore library-filter UI state from localStorage before the first // grid fetch so the badge/chips are accurate immediately // (feedBack#129). _renderLibFilterChips(); _updateLibFiltersBadge(); // Restore the persisted sort and format-filter dropdowns BEFORE // the first setLibView() call — setLibView triggers loadLibrary, // which reads `lib-sort` / `lib-format` to build the API query // string. Without this, the first page would always load with // "Artist A-Z" / "All formats" regardless of what the user had // picked previously. const savedSort = _readPersistedChoice(_LIB_SORT_KEY, _LIB_SORT_VALUES, 'artist'); const savedFormat = _readPersistedChoice(_LIB_FORMAT_KEY, _LIB_FORMAT_VALUES, ''); const sortEl = document.getElementById('lib-sort'); const fmtEl = document.getElementById('lib-format'); if (sortEl) sortEl.value = savedSort; if (fmtEl) fmtEl.value = savedFormat; // Treat the initial page load the same as a screen entry so the // restored selection scrolls into view exactly once on hard // reload. Without this, the scroll-on-screen-entry flag only // ever triggered when the user navigated away and back via // showScreen — a hard refresh in tree mode would land on the // top of the tree and force the user to scroll back to find // their selection. _libScrollOnNextRender.home = true; // `libView` was already initialized from localStorage at module // load; passing it through setLibView replays the visibility // toggling and triggers the initial load. setLibView(libView); try { await loadSettings(); } catch (e) { console.warn('initial loadSettings failed:', e); } // Re-apply any saved per-string highway colors to both highways. try { initHighwayColors(); } catch (e) { console.warn('initHighwayColors failed:', e); } // App-wide restart banner — must wire once, outside loadSettings(), so a // download finishing while the user is on a non-Settings screen still // pops the banner. try { initAppUpdateBanner(); } catch (e) { console.warn('initAppUpdateBanner failed:', e); } // Seed the track fill on every themed slider so they render correctly // before any interaction — e.g. the speed slider (untouched by // loadSettings) before the first playSong, or follower windows that // enter the player screen via showScreen('player') without playSong. document.querySelectorAll('.slider-input').forEach(el => handleSliderInput(el)); try { _wireSpeedPresetsOnce(); } catch (e) { console.warn('_wireSpeedPresetsOnce failed:', e); } checkScanAndLoad(); const plugins = await bootstrapPluginsAndUi(); await loadLibraryProviders({ restoreSaved: true, reloadOnChange: true }); // Viz picker depends on plugin scripts having loaded (to find // window.feedBackViz_ factories), so run it after loadPlugins. // Reuse the plugin list loadPlugins just fetched — no need to // round-trip /api/plugins a second time. _populateVizPicker(plugins); // Alpha-build heads-up banner — only revealed when the running version // string contains "alpha" (case-insensitive). Stays hidden on stable, // beta, RC, or any other channel. The banner element lives in the // library-section markup; toggling the `hidden` Tailwind utility is the // entire surface area, so a test harness can sandbox this against a // minimal document stub. function _updateAlphaWarningBanner(version) { const banner = document.getElementById('alpha-warning-banner'); if (!banner) return; const isAlpha = typeof version === 'string' && version.toLowerCase().includes('alpha'); banner.classList.toggle('hidden', !isAlpha); } fetch('/api/version') .then(r => { if (!r.ok) throw new Error(); return r.json(); }) .then(d => { const v = typeof d.version === 'string' ? d.version.trim() : ''; if (v && v.toLowerCase() !== 'unknown') { const navEl = document.getElementById('app-version'); if (navEl) navEl.textContent = 'v' + v; const aboutEl = document.getElementById('app-version-about'); if (aboutEl) aboutEl.textContent = 'v' + v; } _updateAlphaWarningBanner(v); // Defense-in-depth: server validates the env-var-supplied URLs, // but the About values are configurable so the UI also // rejects anything that isn't http(s) with a non-empty hostname. // A bare regex prefix check would accept malformed values like // "https://" — `new URL` + protocol + hostname catches them // (and `hostname`, not `host`, so port-only authorities like // "http://:80/path" are rejected too). // The source and license links are checked independently so a // rejected source_url doesn't gate a valid license_url. const isSafeHref = (u) => { if (typeof u !== 'string' || !u) return false; try { const parsed = new URL(u); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; // `host` includes the port — "http://:80/path" has // host ":80" but no real hostname. `hostname` is what // we actually want. return !!parsed.hostname; } catch (_) { return false; } }; if (isSafeHref(d.source_url)) { const srcLink = document.getElementById('about-source-link'); if (srcLink) srcLink.href = d.source_url; } if (isSafeHref(d.license_url)) { const licLink = document.getElementById('about-license-link'); if (licLink) licLink.href = d.license_url; } }) .catch(() => {}); })(); // ─── The window contract ──────────────────────────────────────────────────── // app.js is a classic script today, so every top-level `function foo()` here is // implicitly a property of `window`. The R3a migration turns this file into an // ES module, where that stops being true — module scope is not global scope, and // each of these names would silently vanish from `window`. // // Everything below is reached by NAME from outside this file, so each one is // made explicit BEFORE the flip. While app.js is still classic this whole block // is a no-op (it just re-assigns what is already there), which is exactly what // makes it safe to land on its own. // // The consumers are: inline on*= handlers in static/v3/index.html; on*= handlers // this file builds inside template literals; static/v3/*.js; the capabilities; // bundled plugins; and — easy to forget, since they live in other repos — // feedback-desktop and the external plugins. Constitution II names // `window.playSong` / `window.showScreen` / `window.feedBack` as the public // extension contract. // // Guarded by tests/js/window_contract.test.js. Add a name here the moment // anything outside app.js calls it. // ── The host seam ─────────────────────────────────────────────────────────── // Hand app.js's own functions DOWN to the carved modules. // // This runs at TOP LEVEL, during app.js's synchronous module evaluation, and // deliberately sits immediately before the window contract below — because that // contract is what makes the carved handlers (onPhraseNext, practiceSection, …) // clickable. Wiring the seam inside the async boot function instead would leave a // real window: app.js's body finishes, the handlers go live on `window`, and a user // clicking one before the awaits resolve would hit // `[host] … was read before configureHost() ran`. Synchronous, and ordered ahead of // the handlers, closes that. // // ./js/host.js THROWS on an unwired hook rather than quietly returning undefined, and // tests/js/host_contract.test.js fails CI if this list and the host.* uses under // static/js/ ever drift apart. configureHost({ _audioTime, _audioDuration, formatTime, setPlayButtonState, _songEventPayload, togglePlay, handleSliderInput, playSong, // count-in is a module now, so section-practice reaches it through the seam too — // these are simply count-in's own exports, handed across. startCountIn, _cancelCountIn, _audioSeek, _updateEditRegionBtn, // section-practice reaches the loop module through the seam, not by importing it: // loops imports section-practice (clearLoop drops its selection), so the reverse // edge has to be indirection or the graph cycles. These are simply the loop // module's own exports, handed across. setLoop, clearLoop, // Read-only getters. The module only ever READS these reassigned scalars, so no // state container is needed. loopA/loopB/_loopMutationGen are owned by // ./js/loops.js now and imported here as live bindings; _audioSeekGen and // currentFilename are still app.js's. loopA: () => loopA, loopB: () => loopB, _loopMutationGen: () => _loopMutationGen, _audioSeekGen: () => _audioSeekGen, currentFilename: () => currentFilename, jucePlayer: () => jucePlayer, }); Object.assign(window, { _confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl, _librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal, changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop, deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites, filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput, hideScanBanner, importSettings, loadPlugins, loadSavedLoop, loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting, pickDlcFolder, pinCurrentArrangementDefault, playSong, previewDiagnostics, previewEditArt, renderGridCards, renderTreeInto, rescanLibrary, retuneSong, saveCurrentLoop, saveSettings, seekBy, setAvOffsetMs, setFavView, setInstrumentPathway, setLibView, setLibraryProvider, setLoopEnd, setLoopStart, setMastery, setSpeed, setViz, showScreen, sortFavorites, sortLibrary, syncLibrarySong, toggleAllArtists, toggleAllFavoriteArtists, toggleLibFilters, togglePlay, toggleSectionPracticePopover, uiPrompt, updatePlugin, uploadSongs, // These four are invisible to every static scan. app.js:2156-2157 picks the // handler NAME at runtime — // const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter'; // — and interpolates it: `onclick="${letterFn}('A')"`. So the names never // appear as identifiers anywhere, and ESLint / no-undef / a grep for // `onclick="fn` all miss them. They are the library A-Z rail and its // pagination; drop one and those buttons throw at click time, nowhere else. filterFavTreeLetter, filterTreeLetter, goFavTreePage, goTreePage, });