// Settings: load/save, the AV-offset nudge, the default-arrangement pin, the instrument // pathway, and the app-update channel. // // INTERFACE WIDTH 1 — app.js calls loadSettings() and nothing else. It got that clean by // PULLING THE WRITERS IN: _defaultArrangement was the one binding written from outside the // cluster, by saveSettings and pinCurrentArrangementDefault — which are themselves settings // functions. Widening the slice to include them left ZERO outside writes, so every export is a // plain read-only import and no state container is needed. // // (An imported binding is read-only. One write from outside would have forced a setter or a // container, as it did for the player and the library. Here the fix was to draw the boundary in // the right place instead.) // // ─── handleSliderInput STAYS A HOST HOOK, DELIBERATELY ─────────────────────── // // It lives here (it is a settings control), but player-controls.js must NOT import it: this // module already imports player-controls (_applyMastery, _autoplayExitEnabled, …), so a direct // back-import would close a cycle. player-controls keeps reading it through the host seam, and // app.js — the root, which imports both — wires it. That is exactly what the seam is for. import { hwcInitSettingsUI } from './highway-colors.js'; import { _getArrangementNamingMode, _setLibraryProfile } from './library.js'; import { _applyMastery, _autoplayExitEnabled, _exitConfirmEnabled, _showUpNextEnabled, } from './player-controls.js'; // ── Settings ───────────────────────────────────────────────────────────── export let _defaultArrangement = ''; export const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio']; export function _normalizeInstrumentPathway(value) { return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs'; } export 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; } export 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(); } export 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; } export 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(); } export 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(); setupWindowOptions(); 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); // Feed the library its tuning PERSPECTIVE (lead / rhythm / bass) — the // tuning facet, filter, sort and badges all answer for the profile the // player actually plays. _setLibraryProfile(data.active_instrument_profile); 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 = window.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(); } // ── Window options (desktop-only) ──────────────────────────────────────── // Desktop-only window preferences (start-in-fullscreen, …). The whole block // stays hidden in the plain web / Docker app; unhide + wire only when the // feedBack-desktop bridge (window.feedBackDesktop.window) exposes the getter // and setter. Persistence lives desktop-side because only the Electron main // process can read the pref at window-creation time — core just proxies. export let _windowOptionsWired = false; export function setupWindowOptions() { const block = document.getElementById('window-options-block'); if (!block) return; const winApi = window.feedBackDesktop?.window; // Per-method capability check: a partial/older bridge may expose `window` // without this shape. Leave the block hidden rather than half-wiring it. if (!winApi || typeof winApi.getStartFullscreen !== 'function' || typeof winApi.setStartFullscreen !== 'function') { return; } block.classList.remove('hidden'); const cb = document.getElementById('setting-start-fullscreen'); if (!cb) return; // Hydrate from the desktop-persisted value. The getter may be sync or // async (IPC round-trip); Promise.resolve normalises both. Promise.resolve(winApi.getStartFullscreen()).then(function (on) { cb.checked = !!on; }).catch(function () { /* leave unchecked on error */ }); // Guard only the listener against double-binding; unhide + re-hydrate // stay idempotent so re-entering Settings refreshes the checkbox. if (!_windowOptionsWired) { _windowOptionsWired = true; cb.addEventListener('change', function () { try { winApi.setStartFullscreen(cb.checked); } catch (_) { /* best-effort */ } }); } } export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha', 'nightly']; export let _appUpdatesWired = false; // Poll handle for the active-download watcher (module-scoped so re-running // setupAppUpdates on a panel re-render never stacks a second poll). let _appUpdatePollTimer = null; // Last channel main actually acknowledged (initial sync or a successful // user switch). Used to revert the dropdown/localStorage if a switch fails, // so the UI/persisted state can never end up ahead of the real updater state. let _appUpdateAckedChannel = null; // Last [update-diag] renderFrom line logged, so the ~1.5s download poll (and // repeated no-op re-renders) don't flood the diagnostics ring buffer with // byte-identical lines and evict genuinely useful trace. Every real state or // percent change still differs and logs; the structured contribute() snapshot // (with its own ts) is unconditional, so liveness is never lost. let _appUpdateLastRenderLog = null; // Pure status → view model for the App-updates panel. DOM-free and exported so // the button/channel/text state machine can be unit-tested without a browser; // renderFrom() applies the returned shape to the DOM. `canApply` is whether the // bridge exposes apply() (older bridges fall back to text-only), `fmtTimestamp` // formats the "last checked" time, `channelValue` is the dropdown's fallback // when the status omits a channel. export function _appUpdateStatusView(s, { channelValue, canApply = true, fmtTimestamp = (t) => String(t) } = {}) { if (!s) return { kind: 'unavailable' }; if (s.status === 'unsupported' || s.platform === 'linux') return { kind: 'unsupported' }; const base = `Version ${s.currentVersion || '?'} · ${s.channel || channelValue}`; let action; let btnLabel = 'Check for updates'; let btnMode = 'check'; let btnDisabled = false; // Lock the channel selector only while a check/download is in flight — // switching mid-operation abandons it. Enabled for every other status. const channelDisabled = s.status === 'checking' || s.status === 'downloading'; switch (s.status) { case 'checking': action = 'checking for updates…'; btnDisabled = true; break; case 'downloading': { const pct = typeof s.percent === 'number' ? s.percent : null; action = pct === null ? 'update available — downloading…' : `downloading update… ${pct}%`; btnDisabled = true; break; } case 'downloaded': action = 'update ready'; if (canApply) { btnLabel = 'Restart now'; btnMode = 'restart'; } else { action = 'update ready — restart to apply'; } break; case 'error': action = s.message ? `update error: ${s.message}` : 'update check failed'; break; case 'idle': default: action = `up to date · last checked ${fmtTimestamp(s.lastChecked)}`; break; } return { kind: 'status', line: `${base} · ${action}`, btnLabel, btnMode, btnDisabled, channelDisabled }; } export 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; _appUpdateAckedChannel = stored; // Diagnostic: every entry into this function, with whether the one-time // sync gate has already fired. _appUpdatesWired is a MODULE-level `let`, // so it only resets to false on a genuine fresh evaluation of this // script (a real page reload/navigation) — not on loadSettings() simply // being called again within the same page. A second "wired=false" in one // exported log is direct proof of a reload; a series of "wired=true" // entries proves it's just repeated Settings-panel visits (harmless). console.log('[update-diag] setupAppUpdates() entered', JSON.stringify({ wired: _appUpdatesWired, stored })); function showLinuxFallback(message) { // Deliberately leaves channelSelect ENABLED: on Linux "unsupported" // usually just means "the channel isn't Nightly yet", and the dropdown // is the only way to switch to Nightly. Disabling it would trap the // user on whatever channel they booted with. Only the check button and // the note reflect the unsupported state. if (linuxNote) linuxNote.classList.remove('hidden'); checkBtn.disabled = true; // Reset the button out of any leftover "Restart now" state (e.g. an // update was staged on nightly, then the user switched channels). checkBtn.textContent = 'Check for updates'; checkBtn.dataset.mode = 'check'; 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'; } } // Render one status object. Always keeps the current version + channel // visible and appends what's happening, so the download progress never // obscures which build you're on. function renderFrom(s, extra) { // Diagnostic trace: log the raw status object before any branching — // auto-captured by diagnostics.js's console wrap into the exportable // ring buffer, so "Export Diagnostics" in this same Settings → System // panel captures exactly what the app saw and decided, not just what // the UI showed. Deduped so a steady poll doesn't flood the ring buffer // (see _appUpdateLastRenderLog); a real state/percent change differs and // still logs; the structured contribute() snapshot below is unconditional. const logKey = `${JSON.stringify(s)}|${extra || ''}`; if (logKey !== _appUpdateLastRenderLog) { _appUpdateLastRenderLog = logKey; console.log('[update-diag] renderFrom', JSON.stringify(s), extra ? `extra=${extra}` : ''); } const view = _appUpdateStatusView(s, { channelValue: channelSelect.value, canApply: typeof updateApi.apply === 'function', fmtTimestamp, }); if (view.kind === 'unavailable') { statusEl.textContent = extra || 'Updater status unavailable.'; return; } if (view.kind === 'unsupported') { showLinuxFallback('Auto-update requires the AppImage build on the Nightly channel.'); return; } // Healthy for the current channel — clear any "unsupported" UI left // over from a prior channel selection. if (linuxNote) linuxNote.classList.add('hidden'); // The button is a little state machine (dataset.mode drives the click // handler's restart-vs-check branch); the channel selector locks only // while a check/download is active. See _appUpdateStatusView. channelSelect.disabled = view.channelDisabled; checkBtn.textContent = view.btnLabel; checkBtn.dataset.mode = view.btnMode; checkBtn.disabled = view.btnDisabled; const line = view.line; statusEl.textContent = extra ? `${extra} · ${line}` : line; // Live structured snapshot (overwrites, not a log) via the existing // diagnostics contribute() API — 'audio_engine' is feedBack-desktop's // own registered plugin id, so the server's diagnostics export won't // filter it out. Always current, no scrolling through console history // needed to answer "what does the app think is going on right now." try { window.feedBack?.diagnostics?.contribute('audio_engine', { update: { channel: s.channel || channelSelect.value, status: s.status, currentVersion: s.currentVersion ?? null, lastChecked: s.lastChecked ?? null, percent: typeof s.percent === 'number' ? s.percent : null, message: s.message ?? null, rendered: line, ts: Date.now(), }, }); } catch (_) { /* diagnostics.js not loaded — never let this break rendering */ } // A download runs in the background (the check returns immediately), so // poll for the terminal state rather than relying solely on a one-shot // "downloaded" event that could be missed or arrive out of order. if (s.status === 'downloading' || s.status === 'checking') pollWhileBusy(); } 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) => renderFrom(s, extra)) .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.'; } } // While a download (or check) is active, re-read the authoritative status // every ~1.5s and stop once it settles (downloaded / idle / error). This is // what guarantees the panel leaves "downloading… 100%" and lands on "update // ready" (or surfaces a swap error) even if the completion event is lost. function pollWhileBusy() { if (_appUpdatePollTimer) return; _appUpdatePollTimer = setInterval(() => { void Promise.resolve(updateApi.getStatus()).then((s) => { renderFrom(s); const st = s && s.status; if (st !== 'downloading' && st !== 'checking') { clearInterval(_appUpdatePollTimer); _appUpdatePollTimer = null; } }).catch(() => { clearInterval(_appUpdatePollTimer); _appUpdatePollTimer = null; }); }, 1500); } // Inform main of the persisted channel — but ONLY the first time this page // wires up, not on every loadSettings() re-render. This used to run // unconditionally on every call and was caught (via Export Diagnostics) // stomping an in-flight check/download: a redundant setChannel() call // mid-download bumps main's checkGeneration and resets progress state, // so the download silently loses its ability to report completion even // though the file swap itself still happens in the background. Once // wired, the channel select's own 'change' handler is the only thing // that needs to tell main about a channel switch. if (!_appUpdatesWired) { try { // Render from THIS call's own result (same reasoning as the check // button and the 'change' handler below), not just catch its // errors. The unconditional renderStatus() at the bottom of this // function fires a SEPARATE getStatus() round-trip immediately // after — if that resolves before main has processed this // setChannel() (e.g. main is still on its 'stable' boot default), // the UI would render 'unsupported' and — since this call's own // eventual success was never rendered — get stuck there // permanently, even once main correctly switches channel a moment // later. Rendering here too means whichever of the two calls // resolves LAST wins and shows the true state, regardless of // which order they land in. void Promise.resolve(updateApi.setChannel(stored)).then((result) => { _appUpdateAckedChannel = stored; renderFrom(result); }).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; console.log('[update-diag] user switched channel to', val); try { // Render from setChannel()'s own return value (same reasoning // as the check button: it's computed synchronously at the // moment of the switch, so it can't be stale, unlike a // follow-up getStatus() call). const result = await Promise.resolve(updateApi.setChannel(val)); // Only persist once main has actually acknowledged the switch — // a failed setChannel() must never leave localStorage (or the // dropdown) ahead of what main is really using. _appUpdateAckedChannel = val; try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {} renderFrom(result, `Channel set to ${val}.`); } catch (e) { console.warn('[updater] setChannel failed:', e); channelSelect.value = _appUpdateAckedChannel ?? 'stable'; renderStatus(`Failed to set channel to ${val}: ${e?.message || e}`); } }); checkBtn.addEventListener('click', async () => { // In restart mode (set by renderFrom once an update is staged) the // button applies the update instead of checking again. if (checkBtn.dataset.mode === 'restart') { console.log('[update-diag] user clicked Restart now'); checkBtn.disabled = true; checkBtn.textContent = 'Restarting…'; try { const r = await updateApi.apply(); if (r?.status === 'error') { console.warn('[updater] apply returned error:', r.message || 'unknown'); renderFrom(r, 'Restart failed.'); } // On success the app quits + relaunches — nothing to render. } catch (e) { console.warn('[updater] apply failed:', e); statusEl.textContent = `Restart failed: ${e?.message || e}`; checkBtn.textContent = 'Restart now'; checkBtn.disabled = false; } return; } console.log('[update-diag] user clicked Check for updates'); checkBtn.disabled = true; statusEl.textContent = 'Checking for updates…'; let result; try { // The Linux check returns immediately (any download runs in the // background). result = await updateApi.checkNow(); } catch (e) { console.warn('[updater] checkNow failed:', e); statusEl.textContent = `Update check failed: ${e?.message || e}`; checkBtn.disabled = false; return; } // Render straight from checkNow()'s own return value rather than a // follow-up getStatus() call. checkNow() computes that value // synchronously at the moment it decides the outcome, so it can't // be stale; a separate getStatus() round-trip right after it can // race with anything that resets state in between (a concurrent // channel switch, another in-flight check settling) and show a // blanked "up to date · last checked never" even though this check // just succeeded. renderFrom(result); }); // Main-process events (checkNow/download decisions in update-manager.ts) // are invisible to this page's console — forward them into it so a // single "Export Diagnostics" click captures both sides of the story. if (typeof updateApi.onDiag === 'function') { updateApi.onDiag((payload) => { console.log('[update-diag:main]', payload?.message, payload?.data ? JSON.stringify(payload.data) : ''); }); } _appUpdatesWired = true; } renderStatus(); } // 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. export 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. export let _avOffsetMs = 0; export let _avSaveDebounce = null; export 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 (window.highway?.setAvOffset) window.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(); } export 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); } export function nudgeAvOffsetMs(delta) { setAvOffsetMs(Math.max(-1000, Math.min(1000, _avOffsetMs + delta))); } export 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; } // 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