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'; import { formatTime } from './js/format.js'; import { L } from './js/library-state.js'; import { _LIB_FORMAT_KEY, _LIB_FORMAT_VALUES, _LIB_SORT_KEY, _LIB_SORT_VALUES, _activeLibraryProviderId, _applyLibFiltersToParams, _bumpLibNavGeneration, _getArrangementNamingMode, _lastLibSelected, _libNavItems, _libScrollOnNextRender, _libraryLocalFilename, _libraryProviderApi, _librarySongArtUrl, _librarySongId, _librarySyncState, _moveSelectionInItems, _onHeaderClick, _onNamingModeChange, _pollScanAndRefresh, _providerSupports, _readPersistedChoice, _removeLibCardsForFilename, _renderLibFilterChips, _resetLibraryProviderViewState, _setLibSelection, _setLibrarySyncState, _toggleHeader, _updateLibFiltersBadge, checkScanAndLoad, clearLibFilters, editBtn, filterFavTreeLetter, filterFavorites, filterLibrary, filterTreeLetter, fullRescanLibrary, goFavPage, goFavTreePage, goTreePage, hideScanBanner, libView, loadFavorites, loadLibrary, loadLibraryProviders, loadTreeView, renderGridCards, renderTreeInto, rescanLibrary, setFavView, setLibView, setLibraryProvider, sortFavorites, sortLibrary, stopInfiniteScroll, toggleAllArtists, toggleAllFavoriteArtists, toggleFavorite, toggleLibFilters, } from './js/library.js'; // The playback transport. These used to BE app.js — they are imported back now, and the // four modules that reached for them through the host seam import them directly instead. import { setPlayButtonState, jucePlayer, _audioTime, _audioDuration, _songEventPayload, _markPlaybackPaused, _markPlaybackResumed, _emitPlaybackStopped, _emitSongPositionChanged, _waitForSongReady, _resetAudioSeekState, _audioSeek, togglePlay, seekBy, audioSeekGen, } from './js/transport.js'; // Demo analytics — real impl set by demo.js; no-op in normal builds window.feedBackDemoTrack = window.feedBackDemoTrack ?? null; // ── Global keyboard shortcuts ───────────────────────────────────────────── // // `/` focuses the active screen's search input (Library / Favorites); // `Esc` while focused blurs and clears it. Mirrors the GitHub / Gmail // convention. The listener bails when the user is already typing in // any text-accepting element so it can't intercept normal typing — // including inputs inside the filters drawer, plugin settings, or // modal dialogs. function _isTextInput(el) { if (!el) return false; const tag = el.tagName; if (tag === 'INPUT') { // Some types (button, checkbox, radio, range, ...) don't // accept text; only intercept the ones that do. const t = (el.type || 'text').toLowerCase(); return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t); } if (tag === 'TEXTAREA') return true; if (tag === 'SELECT') return true; if (el.isContentEditable) return true; return false; } function _isShortcutHelpKey(e) { return e.key === '?' || (e.shiftKey && (e.code === 'Slash' || e.key === '/')); } function _isShortcutHelpSuppressedTarget(el) { if (!el) return false; const tag = el.tagName; if (tag === 'INPUT') { const t = (el.type || 'text').toLowerCase(); return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t); } if (tag === 'TEXTAREA') return true; if (el.isContentEditable) return true; if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal, .feedBack-modal')) return true; return false; } function _activeSearchInput() { // Pick the search field for whichever screen is currently active. // No match (e.g. on the player or settings screen) means `/` does // nothing — the shortcut only fires where a search box exists. const active = document.querySelector('.screen.active'); if (!active) return null; if (active.id === 'home') return document.getElementById('lib-filter'); if (active.id === 'favorites') return document.getElementById('fav-filter'); return null; } // ── Library keyboard navigation ────────────────────────────────────────── // // Arrow keys move a single "selected" item among the visible cards // (grid view) or song rows (tree view). Enter plays the selected // song. The selected element gets: // - native keyboard focus via .focus() so :focus-visible draws the // accessible ring (announced by screen readers, follows scroll) // - a `.selected` class that persists when focus drifts elsewhere // so the user can glance back and still see their place. // // Grid columns are inferred from the live computed grid template at // the moment of navigation, so up/down works correctly across all // breakpoints (1 / 2 / 3 / 4 cols depending on viewport). function _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); } // 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'; 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 { L.libEpoch++; L.currentPage = 0; L.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 ────────────────────────────────────────────────────────────── const _LIB_PROVIDER_KEY = 'feedBack.libProvider'; // Bumped on filter/sort/view changes so in-flight page fetches can detect // they've been superseded and skip rendering stale results. // cached from /api/library/tuning-names // ── 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(); }; // ── Grid View (server-side pagination, infinite scroll) ──────────────── // ── Tree View (server-side) ───────────────────────────────────────────── 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; // ── 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(); L.treeStats = null; L.favTreeStats = null; L.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. } 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 }); L.treeStats = null; L.favTreeStats = null; L.tuningNames = null; L.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; } } // 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