diff --git a/static/app.js b/static/app.js
index 5b9f6e7..9efc6fd 100644
--- a/static/app.js
+++ b/static/app.js
@@ -196,6 +196,30 @@ import {
openEditModal,
saveEditModal,
} from './js/edit-modal.js';
+import {
+ APP_UPDATE_CHANNELS,
+ INSTRUMENT_PATHWAYS,
+ _appUpdatesWired,
+ _avOffsetMs,
+ _avSaveDebounce,
+ _currentArrangementName,
+ _defaultArrangement,
+ _normalizeInstrumentPathway,
+ _persistAvOffset,
+ _postSetting,
+ _settingSaveChain,
+ _syncDefaultArrangementSelect,
+ handleSliderInput,
+ loadSettings,
+ nudgeAvOffsetMs,
+ persistSetting,
+ pinCurrentArrangementDefault,
+ saveSettings,
+ setAvOffsetMs,
+ setInstrumentPathway,
+ setupAppUpdates,
+ syncDefaultArrangementPin,
+} from './js/settings.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 {
@@ -962,151 +986,6 @@ 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 = 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();
-}
-
// ── 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
@@ -1114,178 +993,6 @@ async function loadSettings() {
// 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
@@ -1409,88 +1116,6 @@ function initAppUpdateBanner() {
}
}
-// 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 (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();
-}
-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() {
@@ -1499,79 +1124,8 @@ async function pickDlcFolder() {
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