feedBack/static/v3/settings.js
ChrisBeWithYou fef870047b
fix(player): reliable Escape "Back" + resumable, optionally-confirmed song exit (#619)
* fix(player): make Escape a reliable Back; resumable + optionally-confirmed song exit

Escape didn't always leave a song: clicking a transport control (play/FF/RW/
restart) left that <button> focused, and _shortcutDispatchBlocked() bails the
shortcut dispatcher for any focused INPUT/SELECT/TEXTAREA/BUTTON — so the
player-scope Escape=Back shortcut never fired until the user clicked empty
canvas to blur the control. Space already had a player-screen carve-out (#593);
Escape did not. That asymmetry was the bug.

Phase 1 — focus fix: generalize the Space carve-out in _shortcutDispatchBlocked
to Escape, on the player AND settings screens (both register Escape=Back;
settings had the identical latent bug). The earlier guards still win: text
inputs are exempted first, the Section Practice popover already claims Escape,
and a true modal (role=dialog aria-modal=true / .feedBack-modal) still traps it.
Plugins' player-scope Escape shortcuts are fixed identically.

Phase 2 — resume: leaving the player snapshots {song, arrangement, position,
speed} to localStorage; a non-blocking "Resume practice" pill offers it back on
the next non-player screen / next launch. playSong() gains a {resume} option
that restores speed + seeks to the saved position on song:ready instead of the
normal autostart. Conservative (ignores <3s / near-end), cleared on natural
song-end and once consumed, expires after 24h.

Phase 3 — opt-in "Ask before leaving a song" (Gameplay tab, default OFF). A
true-modal confirm with monotonic Escape (the second Escape leaves) and
Space/Enter = Leave. The player Escape shortcut and the v3 close button route
through window.requestExitSong(); auto-exit on song-end and a results screen's
own Close stay unguarded.

Design rationale: a multi-seat design charrette (engagement, learning-design,
operability, codebase-reality) — leaving a song should be reliable and
recoverable, not gated; the confirm is opt-in only.

Tests: tests/browser/{keyboard-shortcuts,resume-session,exit-confirm}.spec.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* test(browser): suppress first-run onboarding in keyboard/resume/exit specs

The first-run onboarding overlay (#v3-onboarding) is a modal that intercepts
pointer/keyboard events; on a fresh profile it covers the player and breaks any
test that presses Escape or clicks. Stub GET /api/profile to an onboarded
profile in each beforeEach so the app behaves like a returning user (the state
these tests assume).

Also tighten the Section Practice Escape test to assert the guarantee the fix
actually provides — Escape does not exit the song while the popover is open (the
line-447 guard wins over the carve-out) — rather than asserting the popover's
own close handler fires, which isn't wired for a synthetic bar.

Verified locally against a worktree server (Chromium): all 16 new specs pass
(5 Escape + 6 resume + 5 exit-confirm) plus the existing #593 Space tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(player): exit-confirm — Escape cancels back to song, pause on open/resume on stay

Refinements from tester feedback on the exit-confirm (default stays OFF):

- Escape on the open prompt now = Stay (dismiss + return to the song), matching
  every other modal and the generic _confirmDialog (Esc=cancel). A second
  Escape therefore returns to the song instead of leaving it. Leaving stays the
  explicit, default-focused "Leave" button, so Space/Enter/click = "just get me
  out" (the OP's "Space always hits leave").
- Opening the prompt PAUSES the song (via the canonical togglePlay path, HTML5
  + _juceMode) so it isn't running/being scored behind the modal; Stay resumes
  exactly what we paused. Guards: cancel any count-in on open; resume only if we
  paused (wasPlaying), only if still the same live song on the player
  (_audioSeekGen unchanged), and never auto-resume a song the user had paused.
- Trap Tab inside the dialog; backdrop click was already Stay.

Specs: exit-confirm.spec.ts updated — the monotonic "second Escape leaves" test
becomes "second Escape stays", plus a backdrop-click-stays test. The audio
pause/resume itself is verified manually on web + desktop (the mock song has no
backing track); these specs lock the navigation + keyboard semantics.

NOTE: the pause/resume adds a new pause→resume cycle on the desktop JUCE
transport (known play/pause-desync path) — smoke-test on the desktop build
before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(player): accurate exit-confirm copy + keep resume snapshot on failed load

Two review follow-ups on the Escape/resume/confirm work:

- Settings copy said "a second Escape (or Space/Enter) still leaves",
  but Escape dismisses the confirm (Stay) like every other modal — only
  Space/Enter/Leave exit. Corrected the Gameplay-tab description so it
  matches the implementation (and the committed exit-confirm specs).

- resumeLastSession() cleared the snapshot BEFORE awaiting playSong(), so
  a transient load/connect failure permanently lost the Resume pill with
  no retry. Clear only after the load resolves; on failure keep the
  snapshot (and drop the pending in-memory resume) so the pill re-offers
  it on the next non-player screen.

All 16 Escape/resume/exit-confirm Playwright specs still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 12:32:33 +02:00

209 lines
10 KiB
JavaScript

// ════════════════════════════════════════════════════════════════════════
// v3 tabbed settings page — behaviour layer (feat/v3-settings-tabbed)
//
// The markup (tab bar, card rows, per-tab plugin mount containers) lives
// statically in static/v3/index.html so the element ids exist before app.js's
// loadSettings() hydrates them. This module owns the *behaviour*:
// • tab switching + active-tab persistence (localStorage 'v3-settings-tab')
// • the per-category "Reset" button(s)
// • the read-only Keybinds reference (from window.getAllShortcuts())
// • empty-state notes for plugin tabs with no installed plugins
//
// It is a plain non-module script (matches the rest of static/v3/*). All
// reads are null-guarded so it no-ops gracefully on the classic v2 page (which
// ships its own settings markup and never creates #settings-tabbar).
// ════════════════════════════════════════════════════════════════════════
(function () {
'use strict';
var TAB_KEY = 'v3-settings-tab';
var DEFAULT_TAB = 'gameplay';
// Per-category reset descriptors. `server` keys are cleared via
// POST /api/settings/reset (so the next GET falls back to defaults);
// `local` keys are client-only localStorage prefs; `after` re-applies any
// live-object default that won't pick itself back up from a cleared key.
// Only tabs with a [data-reset] button in the markup need an entry — today
// that's Gameplay; others can be added alongside a button later.
var RESET_MAP = {
gameplay: {
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
after: function () {
// Left-handed is held on the highway object, not re-derived
// from localStorage on load — flip it back to the default.
try { if (window.highway && window.highway.setLefty) window.highway.setLefty(false); } catch (_) { /* noop */ }
},
},
};
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── Tab switching ────────────────────────────────────────────────────
function knownTabs() {
var out = [];
document.querySelectorAll('#settings-tabbar .fb-tab').forEach(function (b) {
if (b.dataset.tab) out.push(b.dataset.tab);
});
return out;
}
function activateTab(tab) {
var tabs = knownTabs();
if (tabs.indexOf(tab) === -1) tab = tabs.length ? tabs[0] : DEFAULT_TAB;
document.querySelectorAll('#settings-tabbar .fb-tab').forEach(function (b) {
b.classList.toggle('active', b.dataset.tab === tab);
});
document.querySelectorAll('#settings .fb-tabpanel').forEach(function (p) {
p.classList.toggle('active', p.dataset.tab === tab);
});
try { localStorage.setItem(TAB_KEY, tab); } catch (_) { /* private mode */ }
}
function wireTabs() {
var bar = document.getElementById('settings-tabbar');
if (!bar || bar.dataset.wired === '1') return;
bar.dataset.wired = '1';
bar.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.fb-tab') : null;
if (btn && btn.dataset.tab) activateTab(btn.dataset.tab);
});
var saved = DEFAULT_TAB;
try { saved = localStorage.getItem(TAB_KEY) || DEFAULT_TAB; } catch (_) { /* noop */ }
activateTab(saved);
}
// ── Per-category reset ────────────────────────────────────────────────
function wireResets() {
document.querySelectorAll('#settings [data-reset]').forEach(function (btn) {
if (btn.dataset.wired === '1') return;
btn.dataset.wired = '1';
btn.addEventListener('click', function () { resetCategory(btn.dataset.reset); });
});
}
function resetCategory(cat) {
var map = RESET_MAP[cat];
if (!map) return;
var confirmFn = (typeof window._confirmDialog === 'function')
? window._confirmDialog({
title: 'Reset ' + cat.charAt(0).toUpperCase() + cat.slice(1) + ' Settings',
body: '<p class="text-sm text-gray-300">Restore these settings to their defaults? This can\'t be undone.</p>',
confirmText: 'Reset', cancelText: 'Cancel', danger: true,
})
: Promise.resolve(window.confirm('Reset ' + cat + ' settings to defaults?'));
confirmFn.then(function (ok) {
if (!ok) return;
var done = Promise.resolve();
if (map.server && map.server.length) {
done = fetch('/api/settings/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keys: map.server }),
}).catch(function () { /* best-effort */ });
}
done.then(function () {
(map.local || []).forEach(function (k) {
try { localStorage.removeItem(k); } catch (_) { /* noop */ }
});
if (typeof map.after === 'function') { try { map.after(); } catch (_) { /* noop */ } }
// Re-hydrate every control from the now-default server + local state.
if (typeof window.loadSettings === 'function') {
try { window.loadSettings(); } catch (_) { /* noop */ }
}
});
});
}
// ── Keybinds reference (read-only) ────────────────────────────────────
var SCOPE_TITLES = {
global: 'Global', player: 'Player', library: 'Library', settings: 'Settings',
};
function scopeTitle(scope) {
if (SCOPE_TITLES[scope]) return SCOPE_TITLES[scope];
if (scope && scope.indexOf('plugin-') === 0) return 'Plugin: ' + scope.slice(7);
return scope || 'Other';
}
function renderKeybinds() {
var host = document.getElementById('settings-keybinds');
if (!host) return;
var list = [];
try { if (typeof window.getAllShortcuts === 'function') list = window.getAllShortcuts() || []; } catch (_) { list = []; }
if (!list.length) {
host.innerHTML = '<p class="fb-tabpanel-empty">No keyboard shortcuts are registered yet.</p>';
return;
}
// Group by scope, stable scope order with anything unknown last.
var order = ['global', 'player', 'library', 'settings'];
var groups = {};
list.forEach(function (s) {
(groups[s.scope] = groups[s.scope] || []).push(s);
});
var scopes = Object.keys(groups).sort(function (a, b) {
var ia = order.indexOf(a), ib = order.indexOf(b);
if (ia === -1) ia = order.length;
if (ib === -1) ib = order.length;
return ia - ib || a.localeCompare(b);
});
var html = '';
scopes.forEach(function (scope) {
html += '<div class="fb-kbd-group-title">' + esc(scopeTitle(scope)) + '</div>';
html += '<div class="fb-srows">';
groups[scope].forEach(function (s) {
html += '<div class="fb-srow">'
+ '<div class="fb-srow-main"><div class="fb-srow-title">' + esc(s.description || s.combo) + '</div></div>'
+ '<div class="fb-srow-control"><span class="fb-kbd">' + esc(s.combo) + '</span></div>'
+ '</div>';
});
html += '</div>';
});
html += '<p class="fb-settings-note">Remapping shortcuts is not yet supported.</p>';
host.innerHTML = html;
}
// ── Empty-state notes for plugin tabs ─────────────────────────────────
function refreshEmptyStates() {
document.querySelectorAll('#settings [data-empty-for]').forEach(function (note) {
var target = document.getElementById(note.dataset.emptyFor);
var empty = !target || target.children.length === 0;
note.style.display = empty ? '' : 'none';
});
}
// ── Boot + refresh on settings entry ──────────────────────────────────
function init() {
if (!document.getElementById('settings-tabbar')) return; // not the v3 page
wireTabs();
wireResets();
renderKeybinds();
refreshEmptyStates();
// Safety net for plugin-panel injection ordering: tell app.js the
// settings containers exist now (it injects plugin <details> into the
// per-category containers). Harmless if no listener is attached.
try { document.dispatchEvent(new CustomEvent('v3:settings-rendered')); } catch (_) { /* noop */ }
}
// Re-derive the dynamic bits whenever the user enters Settings: shortcuts
// and plugin panels may have registered/mounted since the last visit.
if (window.feedBack && typeof window.feedBack.on === 'function') {
window.feedBack.on('screen:changed', function (e) {
if (e && e.id === 'settings') {
// Plugin panels (and shortcuts) may have mounted since the last
// visit — re-derive the dynamic bits on every Settings entry.
wireTabs(); wireResets(); renderKeybinds(); refreshEmptyStates();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();