feedBack/static/js/settings.js
gionnibgud f8012a8ce4 feat(v3): add desktop-only "Start in fullscreen" system option
Adds a "Start in fullscreen" toggle to the Settings → System panel,
addressing the desktop request in feedBack-desktop#97: users want the
app to launch fullscreen without hitting the OS hotkey every time.

The block ships hidden and is gated exactly like the App-updates block:
setupWindowOptions() only unhides + wires it when the feedBack-desktop
bridge exposes window.feedBackDesktop.window.{getStartFullscreen,
setStartFullscreen}. Web/Docker builds have no such bridge, so the
section never appears there. Persistence lives desktop-side because
only the Electron main process can read the pref at window-creation
time — core just proxies through the bridge.

The desktop bridge + launch behaviour land in a follow-up
feedBack-desktop PR.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
2026-07-13 12:38:24 +02:00

540 lines
25 KiB
JavaScript

// 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 } 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);
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'];
export let _appUpdatesWired = false;
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;
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();
}
// 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 <input type=range>.
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 <select> change event fires once
// per selection, unlike the A/V / mastery sliders' per-pixel oninput.
//
// The Settings-dropdown autosaves run through one chain so their POSTs are
// sent one at a time, in the order the user made the changes — the last
// selection is always the last write, for both rapid changes to one
// dropdown and back-to-back changes across different dropdowns. The A/V
// and mastery slider autosaves POST directly (not through this chain);
// the server-side config.json lock is what keeps those from racing the
// dropdown writes (see save_settings() in server.py).
export let _settingSaveChain = Promise.resolve();
export function persistSetting(key, value) {
const next = _settingSaveChain.then(() => _postSetting(key, value));
// Swallow failures so one failed write doesn't poison the chain and
// block every later save.
_settingSaveChain = next.catch(() => {});
return next;
}
export function setInstrumentPathway(value) {
const pathway = _normalizeInstrumentPathway(value);
const el = document.getElementById('setting-instrument-pathway');
if (el) el.value = pathway;
persistSetting('pathway', pathway).then(() => {
if (window.v3Badges && typeof window.v3Badges.reload === 'function') {
try { window.v3Badges.reload(); } catch (_) { /* noop */ }
}
});
}
export async function _postSetting(key, value) {
const status = document.getElementById('settings-status');
try {
const resp = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value }),
});
const data = await resp.json();
if (status) status.textContent = data.message || data.error || '';
} catch (e) {
if (status) status.textContent = 'Save failed: ' + e.message;
}
}