feat(v3): tabbed, card-row settings page + per-plugin settings category (#584)

Replace the single long scrolling v3 settings screen with a horizontal tab
bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins /
System) over card rows (icon + title + description, control on the right) with
a per-category Reset.

- static/v3/index.html: tab bar + card-row markup (ids keep hydrating through
  the unchanged app.js loadSettings()/persistSetting() path).
- static/v3/settings.js (new): tab switching + active-tab persistence
  (localStorage 'v3-settings-tab'), per-category reset, read-only Keybinds
  reference from window.getAllShortcuts().
- static/v3/v3.css: plain CSS, no Tailwind rebuild.
- Per-plugin settings tab: new optional settings.category in plugin.json →
  plugins/__init__.py surfaces settings_category; app.js mounts each plugin
  <details> into #plugin-settings-<category> (fallback: Plugins tab).
  highway_3d ships category: "graphics".
- New gameplay settings: countdown_before_song (wired end-to-end, default off);
  miss_penalty + fail_behavior (persist-only stubs); "Note highway speed"
  surfaces existing master_difficulty.
- New POST /api/settings/reset clears whitelisted keys back to defaults.

Tests: test_settings_api.py, test_plugins.py::test_settings_category_parsed_from_manifest,
tests/browser/settings-tabbed.spec.ts. 179 passed locally.

Ported from the pre-rename feat/v3-settings-tabbed WIP onto current main
(slopsmith→feedBack rename applied; settings-screen markup conflict resolved
in favour of the new tabbed layout — all prior setting ids preserved).

Closes #579

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-23 18:06:46 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent f3a5cb9ed3
commit 3b485fe62b
11 changed files with 1176 additions and 183 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **v3 settings page redesigned as a tabbed, card-row layout.** The single long scrolling settings screen becomes a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows — each a leading icon + title + description with the control (toggle/dropdown/slider) on the right, plus a per-category "Reset" action. The markup lives in `static/v3/index.html` (so existing element ids keep hydrating through the unchanged `app.js` `loadSettings()`/`persistSetting()` path); a new `static/v3/settings.js` owns tab switching + active-tab persistence (`localStorage 'v3-settings-tab'`), the per-category reset, and a read-only **Keybinds** reference built from the live shortcut registry (`window.getAllShortcuts()`); styling is plain CSS in `static/v3/v3.css` (no Tailwind rebuild). **Plugins choose their settings tab** via a new optional `settings.category` field in `plugin.json` (`plugins/__init__.py` surfaces it as `settings_category`; `app.js` mounts each plugin's `<details>` panel into `#plugin-settings-<category>`, falling back to the generic Plugins tab) — `highway_3d` ships `category: "graphics"`; the out-of-repo notedetect/progression plugins should declare `"mic"` / `"progression"`. **New gameplay settings:** **Countdown before song** (a four-beat count-in before playback, wired end-to-end via the existing count-in engine + the song-start autostart path; key `countdown_before_song`, default off); **Miss penalty** (`miss_penalty`) and **Fail behavior** (`fail_behavior`) are persisted now but not yet consumed by scoring (shown with a "Not yet active" badge). "Note highway speed" surfaces the existing `master_difficulty` and stays in sync with the player-popover difficulty slider. New `POST /api/settings/reset` clears chosen keys back to defaults. Tests: `tests/test_settings_api.py` (new keys + reset), `tests/test_plugins.py::test_settings_category_parsed_from_manifest`, `tests/browser/settings-tabbed.spec.ts`.
- **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively.
- **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff.
- **"Song Editor" promoted to a first-class v3 sidebar item.** The editor
+17
View File
@@ -1330,6 +1330,18 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
_category = manifest.get("category")
if not isinstance(_category, str) or not _category:
_category = None
# Settings-tab placement (tabbed settings page). When `settings` is a
# dict, an optional `category` field names which settings tab the
# plugin's panel mounts under (e.g. "graphics", "mic", "progression").
# Distinct from the top-level `category` above (which drives Pedalboard
# grouping) so the two don't collide. Absent/blank → None → the
# frontend falls back to the generic "Plugins" tab.
_settings_manifest = manifest.get("settings")
_settings_category = None
if isinstance(_settings_manifest, dict):
_sc = _settings_manifest.get("category")
if isinstance(_sc, str) and _sc:
_settings_category = _sc
_icon = manifest.get("icon")
if not isinstance(_icon, str) or not _icon:
_icon = None
@@ -1355,6 +1367,7 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
"has_screen": bool(manifest.get("screen")),
"has_script": bool(manifest.get("script")),
"has_settings": bool(manifest.get("settings")),
"settings_category": _settings_category,
"has_tour": _is_valid_tour_manifest(manifest.get("tour")),
# `styles` is an optional relpath (under the plugin's assets/) to a
# compiled, preflight-off stylesheet the frontend injects as a
@@ -2067,6 +2080,9 @@ def register_plugin_api(app: FastAPI):
"has_screen": p["has_screen"],
"has_script": p["has_script"],
"has_settings": p["has_settings"],
# Settings-tab placement; None when the manifest's `settings`
# is absent, a bare string, or omits `category`.
"settings_category": p.get("settings_category"),
"has_tour": p.get("has_tour", False),
# `.get()` fallbacks keep stubbed test entries (built without
# _nav_entry) working — styles is None when unset.
@@ -2115,6 +2131,7 @@ def register_plugin_api(app: FastAPI):
"has_screen": e.get("has_screen", False),
"has_script": e.get("has_script", False),
"has_settings": e.get("has_settings", False),
"settings_category": e.get("settings_category"),
"has_tour": e.get("has_tour", False),
"has_styles": e.get("has_styles", False),
"styles": e.get("styles"),
+1 -1
View File
@@ -6,7 +6,7 @@
"bundled": true,
"script": "screen.js",
"styles": "assets/plugin.css",
"settings": { "html": "settings.html", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
"settings": { "html": "settings.html", "category": "graphics", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
"routes": "routes.py",
"tour": "tour.json"
}
+67
View File
@@ -173,6 +173,7 @@ def _run_janitor_hook(hook) -> None:
_DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
("POST", re.compile(r"^/api/settings$")),
("POST", re.compile(r"^/api/settings/import$")),
("POST", re.compile(r"^/api/settings/reset$")),
("POST", re.compile(r"^/api/rescan$")),
("POST", re.compile(r"^/api/rescan/full$")),
("POST", re.compile(r"^/api/songs/upload$")),
@@ -5379,6 +5380,14 @@ def _default_settings():
# silently undoing the env-var fix on the next load.
return {
"dlc_dir": str(DLC_DIR) if (_DLC_DIR_ENV and DLC_DIR.is_dir()) else "",
# fee[dB]ack v0.3.0 gameplay settings (tabbed settings page). Each
# defaults to its neutral / off value so existing users see no
# behaviour change until they opt in. countdown_before_song is wired
# into the song-start path; miss_penalty / fail_behavior are persisted
# but not yet consumed by scoring (stub rows on the Gameplay tab).
"countdown_before_song": False,
"miss_penalty": "none",
"fail_behavior": "continue",
}
@@ -5505,6 +5514,29 @@ def save_settings(data: dict):
except (TypeError, ValueError, OverflowError):
return {"error": "av_offset_ms must be a number between -1000 and 1000"}
# fee[dB]ack v0.3.0 gameplay settings (tabbed settings page). null is a
# no-op per the merge contract; bad shapes return a structured error
# rather than 500. countdown_before_song is consumed by the song-start
# count-in; miss_penalty / fail_behavior are persisted-only stubs.
if "countdown_before_song" in data:
raw = data["countdown_before_song"]
if raw is not None:
if not isinstance(raw, bool):
return {"error": "countdown_before_song must be a boolean"}
updates["countdown_before_song"] = raw
if "miss_penalty" in data:
raw = data["miss_penalty"]
if raw is not None:
if not isinstance(raw, str) or raw not in ("none", "low", "medium", "high"):
return {"error": "miss_penalty must be one of none, low, medium, high"}
updates["miss_penalty"] = raw
if "fail_behavior" in data:
raw = data["fail_behavior"]
if raw is not None:
if not isinstance(raw, str) or raw not in ("continue", "restart", "stop"):
return {"error": "fail_behavior must be one of continue, restart, stop"}
updates["fail_behavior"] = raw
# fee[dB]ack v0.3.0 — tuner reference pitch + instrument selection.
# These drive the topbar tuner/instrument badges and (when installed) the
# note_detect scoring tuning tables. null is a no-op per the merge contract.
@@ -5574,6 +5606,41 @@ def save_settings(data: dict):
return {"message": ". ".join(messages) if messages else "Settings saved"}
# Keys a client "Reset {category}" action may clear. Resetting removes the key
# from config.json so the next GET falls back to the _default_settings() value
# (or the frontend's own default when the key is then absent). Restricting to a
# known set means a malformed or hostile body can't wipe unrelated config.
_RESETTABLE_SETTINGS_KEYS = frozenset({
"default_arrangement", "demucs_server_url", "master_difficulty",
"av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior",
"reference_pitch", "instrument", "string_count", "tuning",
})
@app.post("/api/settings/reset")
def reset_settings(data: dict):
"""Clear the given settings keys back to their defaults — backs the
per-category "Reset" buttons on the tabbed settings page. Unknown keys are
ignored (not an error) so a newer client asking to reset a key an older
server doesn't recognise degrades gracefully. Shares _settings_lock with
save_settings()/import for the same read-merge-write atomicity reason."""
raw_keys = data.get("keys")
if not isinstance(raw_keys, list):
return {"error": "keys must be a list of setting names"}
keys = [k for k in raw_keys if isinstance(k, str) and k in _RESETTABLE_SETTINGS_KEYS]
config_file = CONFIG_DIR / "config.json"
with _settings_lock:
cfg = _load_config(config_file)
if cfg is None:
# Nothing persisted yet — already at defaults.
return {"message": "Settings reset", "reset": []}
removed = [k for k in keys if k in cfg]
for k in removed:
del cfg[k]
_atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
return {"message": "Settings reset", "reset": removed}
# ── Settings export/import (feedBack#113) ───────────────────────────────────
# Bumped only when the bundle JSON shape changes incompatibly. Importer
+190 -25
View File
@@ -3234,10 +3234,16 @@ async function loadSettings() {
setupAppUpdates();
const resp = await fetch('/api/settings');
const data = await resp.json();
document.getElementById('dlc-path').value = data.dlc_dir || '';
// 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);
document.getElementById('demucs-server-url').value = data.demucs_server_url || '';
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');
@@ -3248,14 +3254,11 @@ async function loadSettings() {
const masteryPct = typeof data.master_difficulty === 'number'
? Math.max(0, Math.min(100, data.master_difficulty))
: 100;
const masterySlider = document.getElementById('mastery-slider');
const masteryLabel = document.getElementById('mastery-label');
if (masterySlider) {
masterySlider.value = masteryPct;
handleSliderInput(masterySlider);
}
if (masteryLabel) masteryLabel.textContent = masteryPct + '%';
highway.setMastery(masteryPct / 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
@@ -3264,6 +3267,18 @@ async function loadSettings() {
// 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;
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');
@@ -5720,6 +5735,22 @@ window.setAutoplayExit = function (on) {
Object.defineProperty(window.feedBack, 'autoplayExit', {
get: _autoplayExitEnabled, configurable: true,
});
// "Countdown before song" (Gameplay tab). Mirrored to localStorage by
// loadSettings so the song-start path can read it synchronously here — no
// async /api/settings fetch on the play hot path. Defaults off.
function _countdownBeforeSongEnabled() {
try { return localStorage.getItem('countdownBeforeSong') === '1'; } catch (_) { return false; }
}
// Settings checkbox setter (onchange="setCountdownBeforeSong(this.checked)").
// Writes localStorage for the synchronous read above AND persists to the
// server so it survives a reload / rides along in the settings export bundle.
window.setCountdownBeforeSong = function (on) {
try { localStorage.setItem('countdownBeforeSong', on ? '1' : '0'); } catch (_) { /* private mode */ }
const el = document.getElementById('setting-countdown-before-song');
if (el && el.checked !== !!on) el.checked = !!on;
persistSetting('countdown_before_song', !!on);
};
// One-shot launcher override for the player's return destination.
window.feedBack.setReturnScreen = function (id) {
window.feedBack._nextReturnScreen = id || null;
@@ -5755,8 +5786,13 @@ window.feedBack.on('song:ready', () => {
if (!_pendingAutostart) return;
_pendingAutostart = false;
if (!_autoplayExitEnabled() || isPlaying) return;
// Reuse the Play button's start path (handles HTML5 + _juceMode + count-in).
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
// "Countdown before song": play a 4-beat count-in, then start. Otherwise
// reuse the Play button's start path directly (handles HTML5 + _juceMode).
if (_countdownBeforeSongEnabled()) {
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
} else {
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
}
});
// Editor → Highway handoff (Editor ⇄ 3D Highway region round-trip). The
@@ -6290,16 +6326,42 @@ function _persistMastery(pct) {
}, 300);
}
function setMastery(v) {
// Guard + clamp: v might be a slider string, a programmatic call
// from a plugin, or a restored settings value with a bad shape.
// Don't let NaN hit the label (would show "NaN%") or the POST.
_applyMastery(v);
}
// Shared mastery applier. Master difficulty has two controls that write the
// same master_difficulty key: the player-popover slider (#mastery-slider) and
// the Gameplay-tab "Note highway speed" slider (#setting-highway-speed). Route
// both — and loadSettings' hydration — through here so their positions,
// labels, and track fills stay in sync regardless of which the user touches,
// plus the live highway re-filter and the debounced persist. All element reads
// are null-guarded since either control may be absent (follower window, or the
// settings markup not yet rendered).
function _applyMastery(v, opts = {}) {
// Guard + clamp: v might be a slider string, a programmatic call from a
// plugin, or a restored settings value with a bad shape. Don't let NaN
// reach a label (would show "NaN%") or the POST.
const parsed = parseInt(v, 10);
if (!Number.isFinite(parsed)) return;
const pct = Math.max(0, Math.min(100, parsed));
document.getElementById('mastery-label').textContent = pct + '%';
handleSliderInput(document.getElementById('mastery-slider'));
const popLabel = document.getElementById('mastery-label');
if (popLabel) popLabel.textContent = pct + '%';
const popSlider = document.getElementById('mastery-slider');
if (popSlider) {
if (String(popSlider.value) !== String(pct)) popSlider.value = pct;
handleSliderInput(popSlider);
}
const setSlider = document.getElementById('setting-highway-speed');
if (setSlider) {
if (String(setSlider.value) !== String(pct)) setSlider.value = pct;
handleSliderInput(setSlider);
}
// The Gameplay-tab label markup appends a literal "%" after this span
// (matching the av-offset "ms" pattern), so write the number alone here —
// unlike #mastery-label above, whose markup carries no trailing unit.
const setLabel = document.getElementById('setting-highway-speed-val');
if (setLabel) setLabel.textContent = pct;
highway.setMastery(pct / 100);
_persistMastery(pct);
if (!opts.skipPersist) _persistMastery(pct);
}
// Reflect phrase-data availability on the slider after every `ready`.
// The server omits the `phrases` message entirely for single-level
@@ -8834,6 +8896,49 @@ async function startCountIn(opts = {}) {
}
}
// Start-of-song count-in: a 4-beat click before playback begins, gated by the
// "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's
// overlay + click + gen-token cancellation, but counts from the song's current
// position (0 at song start) with no loop A/B rewind. startCountIn() is loop-
// coupled (early-returns when loopA/loopB are null), so this is a sibling
// rather than an overload. Hands off to togglePlay() once the count completes.
async function startSongCountIn() {
if (_countingIn) return;
_countingIn = true;
// Snapshot the gen so a teardown (showScreen/playSong calls _cancelCountIn)
// bumps it and every delayed callback below bails.
const gen = _countInGen;
if (window._juceMode) {
await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in song count-in:', err));
} else {
audio.pause();
}
if (gen !== _countInGen) return; // teardown during pause
const startT = lastAudioTime || 0;
let bpm = highway.getBPM(startT);
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
const beatInterval = 60 / bpm;
let count = 0;
function tick() {
if (gen !== _countInGen) return; // teardown mid-count
count++;
if (count > 4) {
hideCountOverlay();
_countingIn = false;
// Hand off to the normal play path — togglePlay() flips isPlaying,
// updates the button, and emits song:play/resume for plugins.
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err));
return;
}
showCountOverlay(count);
playClick(count === 1);
_countInTimer = setTimeout(tick, beatInterval * 1000);
}
// First beat after a short lead-in, matching the loop count-in's 500 ms.
_countInTimer = setTimeout(tick, 500);
}
// Time display + highway sync
let lastAudioTime = 0;
setInterval(() => {
@@ -9044,6 +9149,33 @@ window.registerShortcut = (options) => {
panel.registerShortcut(options);
};
// Flat, read-only snapshot of every registered shortcut across all panels,
// for the Settings → Keybinds reference tab. Dedupes by combo+scope (the same
// shortcut can live in both the active panel and the default panel) and uses
// the same modifier-prefix formatting as the shortcuts modal. Returns
// [{ combo, description, scope }]; remapping is not supported, so this is
// purely informational.
window.getAllShortcuts = () => {
const fmt = (s) => {
const m = s.modifiers || {};
return (m.ctrl ? 'Ctrl+' : '') + (m.alt ? 'Alt+' : '')
+ (m.shift ? 'Shift+' : '') + (m.meta ? 'Meta+' : '') + s.key;
};
const seen = new Set();
const out = [];
for (const [, panel] of _panels) {
if (!panel || !panel.shortcuts) continue;
for (const [, s] of panel.shortcuts) {
const combo = fmt(s);
const dedupe = combo + '|' + (s.scope || '');
if (seen.has(dedupe)) continue;
seen.add(dedupe);
out.push({ combo, description: s.description || '', scope: s.scope || 'global' });
}
}
return out;
};
window.unregisterShortcut = (key, scope) => {
// Try the active panel first to preserve panel isolation; fall back to
// other panels so a shortcut registered before a panel switch is still
@@ -9908,6 +10040,35 @@ async function _registerLegacyPluginUiContributions(plugin) {
}
}
// Settings-tab containers that can host plugin <details> panels on the v3
// tabbed settings page. '#plugin-settings' is the fallback bucket (and the
// only container in the classic v2 settings page); the per-tab containers map
// to a plugin manifest's settings.category. A plugin with no category, or one
// whose tab container is absent (v2, or render not yet run), falls back to
// '#plugin-settings'. Body divs injected per plugin use id
// `plugin-settings-<pluginId>` and live INSIDE a <details>, so they are never
// direct children of these containers — no id collision in the scans below.
const _PLUGIN_SETTINGS_CONTAINER_IDS = [
'plugin-settings', 'plugin-settings-graphics',
'plugin-settings-mic', 'plugin-settings-progression',
];
function _pluginSettingsContainers() {
const out = [];
for (const id of _PLUGIN_SETTINGS_CONTAINER_IDS) {
const el = document.getElementById(id);
if (el) out.push(el);
}
return out;
}
function _pluginSettingsTarget(plugin) {
const cat = plugin && plugin.settings_category;
if (cat) {
const el = document.getElementById('plugin-settings-' + cat);
if (el) return el;
}
return document.getElementById('plugin-settings');
}
async function loadPlugins() {
if (_loadPluginsInFlight) { console.log('[feedBack] loadPlugins: in-flight, skipping'); return null; }
_loadPluginsInFlight = true;
@@ -9959,7 +10120,8 @@ async function loadPlugins() {
console.warn('[feedBack] capability manifest registration failed:', e);
}
const settingsContainer = document.getElementById('plugin-settings');
// Plugin settings panels mount into one of several tab containers —
// see _pluginSettingsContainers()/_pluginSettingsTarget() above.
// Plugins whose screen.js has already been evaluated this session
// at the current version AND whose DOM is still in the document.
@@ -10097,8 +10259,8 @@ async function loadPlugins() {
}
};
const existingSettingsByPluginId = new Map();
if (settingsContainer) {
for (const child of settingsContainer.children) {
for (const container of _pluginSettingsContainers()) {
for (const child of container.children) {
const pid = child.dataset ? child.dataset.pluginId : null;
if (pid) existingSettingsByPluginId.set(pid, child);
}
@@ -10127,8 +10289,8 @@ async function loadPlugins() {
// so always rebuild them.
navContainer.innerHTML = '';
mobileNavContainer.innerHTML = '<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>';
if (settingsContainer) {
[...settingsContainer.children].forEach((el) => {
for (const container of _pluginSettingsContainers()) {
[...container.children].forEach((el) => {
const pid = el.dataset ? el.dataset.pluginId : null;
if (!pid || !alreadyHydrated.has(pid)) el.remove();
});
@@ -10290,7 +10452,10 @@ async function loadPlugins() {
// Skip for already-hydrated plugins — preserved details element
// still carries listeners wired by its inline settings script
// and by screen.js on first load.
if (plugin.has_settings && settingsContainer && !alreadyHydrated.has(plugin.id)) {
// Resolve which settings tab this plugin's panel mounts under
// (manifest settings.category), falling back to '#plugin-settings'.
const settingsTarget = plugin.has_settings ? _pluginSettingsTarget(plugin) : null;
if (plugin.has_settings && settingsTarget && !alreadyHydrated.has(plugin.id)) {
const details = document.createElement('details');
details.className = 'bg-dark-700/40 border border-gray-800 rounded-xl overflow-hidden group';
details.dataset.pluginId = plugin.id;
@@ -10382,7 +10547,7 @@ async function loadPlugins() {
body.className = 'px-4 py-4 border-t border-gray-800 space-y-4';
details.appendChild(body);
settingsContainer.appendChild(details);
settingsTarget.appendChild(details);
const settingsResp = await fetch(`/api/plugins/${plugin.id}/settings.html`);
body.innerHTML = await settingsResp.text();
+326 -154
View File
@@ -343,76 +343,60 @@
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
<div id="settings" class="screen">
<div class="max-w-2xl mx-auto px-6 pt-24 pb-16">
<button onclick="showScreen('home')" class="text-gray-500 hover:text-white text-sm mb-6 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Back
<div class="fb-settings">
<button onclick="showScreen('home')" class="fb-settings-back">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Home
</button>
<h2 class="text-3xl font-bold text-white mb-8">Settings</h2>
<h2 class="fb-settings-title">Settings</h2>
<div class="space-y-10">
<!-- App Updates — Velopack auto-update, desktop only. Stays
hidden in the plain web app; setupAppUpdates() unhides
this block when window.feedBackDesktop.update exists,
and shows a disabled "not available on Linux" fallback
when running on Linux. -->
<div id="app-updates-block" class="hidden border border-gray-800 rounded-xl bg-dark-800/40 p-5">
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">App Updates</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block" for="app-update-channel">Update channel</label>
<select id="app-update-channel"
class="w-full bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="stable">Stable</option>
<option value="rc">Release candidate</option>
<option value="beta">Beta</option>
<option value="alpha">Alpha</option>
</select>
<!-- Tab bar — click handling + active-tab persistence wired by
static/v3/settings.js. data-tab keys match the .fb-tabpanel
below; the fallback "Plugins" tab hosts uncategorized plugin
panels (manifest settings.category routes the others). -->
<div class="fb-tabbar" id="settings-tabbar">
<button type="button" class="fb-tab" data-tab="gameplay">Gameplay</button>
<button type="button" class="fb-tab" data-tab="audio">Audio</button>
<button type="button" class="fb-tab" data-tab="graphics">Graphics</button>
<button type="button" class="fb-tab" data-tab="keybinds">Keybinds</button>
<button type="button" class="fb-tab" data-tab="progression">Progression</button>
<button type="button" class="fb-tab" data-tab="mic">Mic</button>
<button type="button" class="fb-tab" data-tab="plugins">Plugins</button>
<button type="button" class="fb-tab" data-tab="system">System</button>
</div>
<!-- ══ GAMEPLAY ════════════════════════════════════════════════ -->
<div class="fb-tabpanel" data-tab="gameplay">
<div class="fb-tabpanel-head">
<h3>Gameplay Settings</h3>
<button type="button" class="fb-reset-btn" data-reset="gameplay">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Reset Gameplay Settings
</button>
</div>
<div class="fb-srows">
<!-- Left-handed -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4M16 17H4m0 0l4 4m-4-4l4-4"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Left-handed mode</div>
<div class="fb-srow-desc">Invert frets on the note highway for left-handed players.</div>
</div>
<div class="flex items-end">
<button id="app-update-check-now"
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
Check for updates
</button>
<div class="fb-srow-control">
<label class="fb-switch">
<input type="checkbox" id="setting-lefty" onchange="highway.setLefty(this.checked)">
<span class="fb-switch-track"></span>
</label>
</div>
</div>
<p id="app-update-status" class="text-xs text-gray-500 mt-3">Loading updater status…</p>
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 mt-2">
Auto-update is not available on Linux —
<a href="https://github.com/got-feedback/feedBack-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
</p>
</div>
<!-- ── Core FeedBack settings ─────────────────────────────── -->
<section>
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">FeedBack</h3>
<div class="space-y-6">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Library Folder Path</label>
<div class="flex gap-3">
<input type="text" id="dlc-path" placeholder="/path/to/your/library"
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="pickDlcFolder()" id="btn-pick-dlc" class="hidden bg-dark-600 hover:bg-dark-500 px-4 py-2.5 rounded-xl text-sm text-gray-300 transition whitespace-nowrap">📂 Browse</button>
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
<!-- Default arrangement -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Default arrangement</div>
<div class="fb-srow-desc">Which arrangement loads first when you open a song.</div>
</div>
<div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" id="setting-lefty" onchange="highway.setLefty(this.checked)"
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
<span class="text-sm text-gray-300">Left-handed <span class="text-gray-500">(invert frets on the note highway)</span></span>
</label>
</div>
<div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" id="setting-autoplay-exit" checked onchange="setAutoplayExit(this.checked)"
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
<span class="text-sm text-gray-300">Autoplay &amp; auto-exit <span class="text-gray-500">(start songs/lessons automatically and return to the menu when the score screen closes)</span></span>
</label>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Default Arrangement</label>
<select id="default-arrangement"
onchange="persistSetting('default_arrangement', this.value)"
<div class="fb-srow-control">
<select id="default-arrangement" onchange="persistSetting('default_arrangement', this.value)"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="">Most notes (auto)</option>
<option value="Lead">Lead</option>
@@ -420,112 +404,299 @@
<option value="Bass">Bass</option>
</select>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Arrangement Names</label>
<select id="arrangement-naming-mode"
onchange="_onNamingModeChange(this.value)"
</div>
<!-- Arrangement routes (naming mode) -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Arrangement routes</div>
<div class="fb-srow-desc">How arrangement variants are labelled across the app.</div>
</div>
<div class="fb-srow-control">
<select id="arrangement-naming-mode" onchange="_onNamingModeChange(this.value)"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="smart">Smart (Lead, Alt. Lead, Rhythm, Bass…)</option>
<option value="legacy">Legacy (Combo, Bass)</option>
</select>
</div>
<div>
<label for="setting-av-offset" class="text-sm font-medium text-gray-400 mb-2 block">
A/V Sync Offset: <span id="setting-av-offset-val">0</span> ms
</label>
<input type="range" id="setting-av-offset" min="-1000" max="1000" step="1" value="0"
oninput="setAvOffsetMs(this.value)"
class="w-full slider-input">
<p class="text-xs text-gray-600 mt-1">Positive = audio plays ahead of visual notes; raise this value to catch the highway up. Adjust live with the [ and ] keys (Shift for ±50 ms). Auto-saves on every change.</p>
</div>
<!-- A/V sync offset -->
<div class="fb-srow fb-srow-stack">
<div style="display:flex; align-items:center; gap:1rem;">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">A/V sync offset: <span id="setting-av-offset-val" style="margin-left:.35rem;">0</span> ms</div>
<div class="fb-srow-desc">Positive = audio plays ahead of visual notes; raise to catch the highway up. Adjust live with the [ and ] keys (Shift for ±50 ms). Auto-saves.</div>
</div>
</div>
<div>
<label for="setting-live-guitar-tone-source" class="text-sm font-medium text-gray-400 mb-2 block">Live guitar tone source</label>
<select id="setting-live-guitar-tone-source"
<input type="range" id="setting-av-offset" min="-1000" max="1000" step="1" value="0"
oninput="setAvOffsetMs(this.value)" class="fb-srow-wide slider-input">
</div>
<!-- Note highway speed (master difficulty) -->
<div class="fb-srow fb-srow-stack">
<div style="display:flex; align-items:center; gap:1rem;">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Note highway speed: <span id="setting-highway-speed-val" style="margin-left:.35rem;">100</span>%</div>
<div class="fb-srow-desc">Master difficulty — lower simplifies the chart (fewer notes); 100% plays the full arrangement.</div>
</div>
</div>
<input type="range" id="setting-highway-speed" min="0" max="100" step="5" value="100"
oninput="setMastery(this.value)" class="fb-srow-wide slider-input">
</div>
<!-- Miss penalty (stub) -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19H19a2 2 0 001.71-3L13.71 4a2 2 0 00-3.42 0L3.34 16a2 2 0 001.73 3z"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Miss penalty <span class="fb-stub-note">Not yet active</span></div>
<div class="fb-srow-desc">How harshly missed notes are scored. Saved now; scoring wiring lands in a later release.</div>
</div>
<div class="fb-srow-control">
<select id="setting-miss-penalty" onchange="persistSetting('miss_penalty', this.value)"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="internal">fee[dB]ack internal tone</option>
<option value="external_hardware">External amp / hardware pedalboard</option>
<option value="spark_control_x">Spark LIVE + Spark Control X</option>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Standard</option>
<option value="high">High</option>
</select>
<p class="text-xs text-gray-600 mt-1">Choose External/Spark if your guitar tone comes from hardware like Spark LIVE. fee[dB]ack will still score your playing but won&rsquo;t warn that no internal amp tone is loaded.</p>
</div>
<div>
<label for="demucs-server-url" class="text-sm font-medium text-gray-400 mb-2 block">Demucs Server (for stem separation)</label>
<div class="flex gap-3">
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
<p class="text-xs text-gray-600 mt-1">Optional. Run <a href="https://github.com/got-feedBack/feedBack-demucs-server" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">feedBack-demucs-server</a> on a machine with a GPU to offload stem splitting and avoid resource exhaustion on the host running FeedBack.</p>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Library</label>
<div class="flex items-center gap-3">
<button onclick="rescanLibrary()" id="btn-rescan" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Rescan Library</button>
<button onclick="fullRescanLibrary()" id="btn-full-rescan" class="bg-dark-600 hover:bg-red-900/30 px-5 py-2.5 rounded-xl text-sm text-gray-400 transition">Full Rescan</button>
<span id="rescan-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Rescan checks for new songs. Full Rescan clears the cache and re-imports everything.</p>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Backup</label>
<div class="flex items-center gap-3">
<button onclick="exportSettings()" id="btn-export-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Settings</button>
<button onclick="document.getElementById('import-settings-file').click()" id="btn-import-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Import Settings</button>
<input type="file" id="import-settings-file" accept="application/json,.json" class="hidden" onchange="importSettings(this.files[0]); this.value=''">
<span id="backup-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Export bundles server config, browser preferences, and opted-in plugin data into one JSON file. Import overwrites current settings and reloads.</p>
</div>
<!-- ── Diagnostics (feedBack#166) ────────────────────── -->
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Diagnostics</label>
<div class="grid grid-cols-2 gap-2 mb-3 text-xs text-gray-400">
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-system" checked class="rounded border-gray-600 bg-dark-700 text-accent"> System info</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-hardware" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Hardware (CPU/GPU/RAM)</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-logs" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Server logs (last 5 MB)</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-console" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Browser console + errors</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-plugins" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Plugin diagnostics</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-redact" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Redact paths &amp; song names</label>
</div>
<div class="flex items-center gap-3">
<button onclick="previewDiagnostics()" id="btn-diag-preview" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Preview Bundle</button>
<button onclick="exportDiagnostics()" id="btn-diag-export" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Diagnostics</button>
<span id="diag-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Bundles server logs, hardware info, plugin inventory, and the browser console transcript into one zip for bug reports. Redaction strips DLC paths, song filenames, and IP addresses by default. Attach to GitHub issues; AI agents can parse the included <code>manifest.json</code>.</p>
<div id="diag-preview" class="hidden mt-3 bg-dark-700 border border-gray-800 rounded-xl p-3 text-xs text-gray-400 max-h-96 overflow-auto"></div>
</div>
<div id="settings-status" class="text-sm text-gray-500"></div>
</div>
</section>
<!-- Countdown before song -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m-3 5a9 9 0 100-18 9 9 0 000 18z"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Countdown before song</div>
<div class="fb-srow-desc">Play a four-beat count-in before a song starts so you can get ready.</div>
</div>
<div class="fb-srow-control">
<label class="fb-switch">
<input type="checkbox" id="setting-countdown-before-song" onchange="setCountdownBeforeSong(this.checked)">
<span class="fb-switch-track"></span>
</label>
</div>
</div>
<!-- Fail behavior (stub) -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Fail behavior <span class="fb-stub-note">Not yet active</span></div>
<div class="fb-srow-desc">What happens when you fail a section. Saved now; gameplay wiring lands in a later release.</div>
</div>
<div class="fb-srow-control">
<select id="setting-fail-behavior" onchange="persistSetting('fail_behavior', this.value)"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="continue">Continue playing</option>
<option value="restart">Restart section</option>
<option value="stop">Stop song</option>
</select>
</div>
</div>
<!-- Autoplay & auto-exit -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Autoplay &amp; auto-exit</div>
<div class="fb-srow-desc">Start songs/lessons automatically and return to the menu when the score screen closes.</div>
</div>
<div class="fb-srow-control">
<label class="fb-switch">
<input type="checkbox" id="setting-autoplay-exit" checked onchange="setAutoplayExit(this.checked)">
<span class="fb-switch-track"></span>
</label>
</div>
</div>
</div>
</div>
<!-- ── Plugin settings ─────────────────────────────────────── -->
<section id="plugin-settings-area" class="hidden">
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">Plugins</h3>
<div class="space-y-6">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Plugin Updates</label>
<div class="flex items-center gap-3 mb-2">
<button onclick="checkPluginUpdates()" id="btn-check-updates" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Check for Updates</button>
<span id="updates-status" class="text-xs text-gray-500"></span>
<!-- ══ AUDIO ═══════════════════════════════════════════════════ -->
<div class="fb-tabpanel" data-tab="audio">
<div class="fb-tabpanel-head"><h3>Audio Settings</h3></div>
<div class="fb-srows">
<!-- Live guitar tone source -->
<div class="fb-srow fb-srow-stack">
<div style="display:flex; align-items:center; gap:1rem;">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M19 5a9 9 0 010 14M5 9v6h4l5 5V4L9 9H5z"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Live guitar tone source</div>
<div class="fb-srow-desc">Choose External/Spark if your guitar tone comes from hardware like Spark LIVE. fee[dB]ack still scores your playing but won't warn that no internal amp tone is loaded.</div>
</div>
<div id="plugin-updates-list" class="space-y-2"></div>
</div>
<!-- Per-plugin collapsible sections injected here -->
<div id="plugin-settings" class="space-y-3"></div>
<select id="setting-live-guitar-tone-source"
class="fb-srow-wide bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="internal">fee[dB]ack internal tone</option>
<option value="external_hardware">External amp / hardware pedalboard</option>
<option value="spark_control_x">Spark LIVE + Spark Control X</option>
</select>
</div>
</section>
<!-- Demucs server -->
<div class="fb-srow fb-srow-stack">
<div style="display:flex; align-items:center; gap:1rem;">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Demucs server (for stem separation)</div>
<div class="fb-srow-desc">Optional. Run <a href="https://github.com/got-feedback/feedBack-demucs-server" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">feedBack-demucs-server</a> on a GPU machine to offload stem splitting and avoid resource exhaustion on the host.</div>
</div>
</div>
<div class="fb-srow-control">
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
</div>
</div>
</div>
<!-- ── About / Source / License (AGPL §13 disclosure) ──────── -->
<section>
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">About</h3>
<div class="space-y-2 text-sm text-gray-400">
<div>FeedBack <span id="app-version-about" class="text-gray-500"></span></div>
<div>Licensed under <a id="about-license-link" href="https://github.com/got-feedback/feedBack/blob/main/LICENSE" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">GNU AGPL v3.0</a>.</div>
<div><a id="about-source-link" href="https://github.com/got-feedback/feedBack" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">Source code repository</a></div>
<p class="text-xs text-gray-600 mt-2">FeedBack is free software. You can redistribute it and modify it under the terms of the AGPL. If you run a modified version that interacts with users over a network, you must make the modified source available to those users.</p>
<!-- ══ GRAPHICS ════════════════════════════════════════════════ -->
<div class="fb-tabpanel" data-tab="graphics">
<div class="fb-tabpanel-head"><h3>Graphics Settings</h3></div>
<!-- highway_3d (manifest settings.category="graphics") mounts here. -->
<div id="plugin-settings-graphics" class="space-y-3"></div>
<p class="fb-tabpanel-empty" data-empty-for="plugin-settings-graphics">No graphics plugins are installed.</p>
</div>
<!-- ══ KEYBINDS ════════════════════════════════════════════════ -->
<div class="fb-tabpanel" data-tab="keybinds">
<div class="fb-tabpanel-head"><h3>Keyboard Shortcuts</h3></div>
<!-- Populated by settings.js from the live shortcut registry. -->
<div id="settings-keybinds"></div>
</div>
<!-- ══ PROGRESSION ═════════════════════════════════════════════ -->
<div class="fb-tabpanel" data-tab="progression">
<div class="fb-tabpanel-head"><h3>Progression Settings</h3></div>
<div id="plugin-settings-progression" class="space-y-3"></div>
<p class="fb-tabpanel-empty" data-empty-for="plugin-settings-progression">No progression plugins are installed.</p>
</div>
<!-- ══ MIC ═════════════════════════════════════════════════════ -->
<div class="fb-tabpanel" data-tab="mic">
<div class="fb-tabpanel-head"><h3>Microphone &amp; Detection</h3></div>
<div id="plugin-settings-mic" class="space-y-3"></div>
<p class="fb-tabpanel-empty" data-empty-for="plugin-settings-mic">No microphone/detection plugins are installed.</p>
</div>
<!-- ══ PLUGINS (fallback for uncategorized panels) ═════════════ -->
<div class="fb-tabpanel" data-tab="plugins">
<div class="fb-tabpanel-head"><h3>Plugins</h3></div>
<div class="fb-srows">
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">Plugin updates</div>
<div class="fb-srow-desc">Check installed plugins for newer versions.</div>
</div>
<div class="fb-srow-control" style="justify-content:flex-start;">
<button onclick="checkPluginUpdates()" id="btn-check-updates" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Check for Updates</button>
<span id="updates-status" class="text-xs text-gray-500"></span>
</div>
<div id="plugin-updates-list" class="space-y-2 fb-srow-wide"></div>
</div>
</section>
</div>
<!-- Per-plugin collapsible sections injected here (uncategorized). -->
<div id="plugin-settings" class="space-y-3" style="margin-top:.6rem;"></div>
</div>
<!-- ══ SYSTEM ══════════════════════════════════════════════════ -->
<div class="fb-tabpanel" data-tab="system">
<div class="fb-tabpanel-head"><h3>System</h3></div>
<div class="fb-srows">
<!-- App Updates — desktop-only; setupAppUpdates() unhides. -->
<div id="app-updates-block" class="hidden fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">App updates</div>
<div class="fb-srow-desc">Velopack auto-update channel for the desktop app.</div>
</div>
<div class="fb-srow-control" style="flex-wrap:wrap;">
<select id="app-update-channel"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="stable">Stable</option>
<option value="rc">Release candidate</option>
<option value="beta">Beta</option>
<option value="alpha">Alpha</option>
</select>
<button id="app-update-check-now"
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
Check for updates
</button>
</div>
<p id="app-update-status" class="text-xs text-gray-500 fb-srow-wide">Loading updater status…</p>
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 fb-srow-wide">
Auto-update is not available on Linux —
<a href="https://github.com/got-feedback/feedback-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
</p>
</div>
<!-- Library folder path -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">Library folder path</div>
<div class="fb-srow-desc">The folder fee[dB]ack scans for your song library.</div>
</div>
<div class="fb-srow-control">
<input type="text" id="dlc-path" placeholder="/path/to/your/library"
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="pickDlcFolder()" id="btn-pick-dlc" class="hidden bg-dark-600 hover:bg-dark-500 px-4 py-2.5 rounded-xl text-sm text-gray-300 transition whitespace-nowrap">📂 Browse</button>
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
</div>
<!-- Library rescan -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">Library</div>
<div class="fb-srow-desc">Rescan checks for new songs. Full Rescan clears the cache and re-imports everything.</div>
</div>
<div class="fb-srow-control">
<button onclick="rescanLibrary()" id="btn-rescan" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Rescan Library</button>
<button onclick="fullRescanLibrary()" id="btn-full-rescan" class="bg-dark-600 hover:bg-red-900/30 px-5 py-2.5 rounded-xl text-sm text-gray-400 transition">Full Rescan</button>
<span id="rescan-status" class="text-xs text-gray-500"></span>
</div>
</div>
<!-- Backup -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">Backup</div>
<div class="fb-srow-desc">Export bundles server config, browser preferences, and opted-in plugin data into one JSON file. Import overwrites current settings and reloads.</div>
</div>
<div class="fb-srow-control">
<button onclick="exportSettings()" id="btn-export-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Settings</button>
<button onclick="document.getElementById('import-settings-file').click()" id="btn-import-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Import Settings</button>
<input type="file" id="import-settings-file" accept="application/json,.json" class="hidden" onchange="importSettings(this.files[0]); this.value=''">
<span id="backup-status" class="text-xs text-gray-500"></span>
</div>
</div>
<!-- Diagnostics -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">Diagnostics</div>
<div class="fb-srow-desc">Bundles server logs, hardware info, plugin inventory, and the browser console transcript into one zip for bug reports. Redaction strips DLC paths, song filenames, and IP addresses by default.</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-system" checked class="rounded border-gray-600 bg-dark-700 text-accent"> System info</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-hardware" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Hardware (CPU/GPU/RAM)</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-logs" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Server logs (last 5 MB)</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-console" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Browser console + errors</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-plugins" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Plugin diagnostics</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-redact" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Redact paths &amp; song names</label>
</div>
<div class="fb-srow-control">
<button onclick="previewDiagnostics()" id="btn-diag-preview" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Preview Bundle</button>
<button onclick="exportDiagnostics()" id="btn-diag-export" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Diagnostics</button>
<span id="diag-status" class="text-xs text-gray-500"></span>
</div>
<div id="diag-preview" class="hidden mt-1 bg-dark-700 border border-gray-800 rounded-xl p-3 text-xs text-gray-400 max-h-96 overflow-auto fb-srow-wide"></div>
</div>
<!-- About -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">About</div>
<div class="fb-srow-desc">
fee[dB]ack <span id="app-version-about" class="text-gray-500"></span> · Licensed under
<a id="about-license-link" href="https://github.com/got-feedback/feedback/blob/main/LICENSE" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">GNU AGPL v3.0</a> ·
<a id="about-source-link" href="https://github.com/got-feedback/feedback" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">Source code repository</a>.
Free software — if you run a modified version that interacts with users over a network, you must make the modified source available to those users.
</div>
</div>
</div>
<div id="settings-status" class="text-sm text-gray-500"></div>
</div>
</div>
</div>
</div>
@@ -900,6 +1071,7 @@
<script src="/static/v3/songs.js"></script>
<script src="/static/v3/lessons.js"></script>
<script src="/static/v3/dashboard.js"></script>
<script src="/static/v3/settings.js"></script>
<!-- First-run home tour: spotlights the home cards via the shared tour
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
(triggered from profile.js finish()); replayable from the "?" menu. -->
+208
View File
@@ -0,0 +1,208 @@
// ════════════════════════════════════════════════════════════════════════
// 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', '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();
}
})();
+106
View File
@@ -968,3 +968,109 @@ body.font-display { font-family: Rubik, system-ui, sans-serif; }
color: #ddd6fe; background: rgba(76, 29, 149, .6);
border: 1px solid rgba(139, 92, 246, .4); border-radius: .3rem; padding: .04rem .28rem;
}
/*
Tabbed settings page (v3) feat/v3-settings-tabbed
Card-row layout: leading icon + title/description + right-aligned control,
grouped under a horizontal tab bar. Pure CSS (no Tailwind rebuild); colors
mirror the fb-* design tokens so the page matches the rest of the v3 shell.
*/
#settings .fb-settings { max-width: 56rem; margin: 0 auto; padding: 6rem 1.5rem 4rem; }
.fb-settings-back {
display: inline-flex; align-items: center; gap: .25rem;
font-size: .8rem; color: #94a3b8; background: none; border: none; cursor: pointer;
padding: 0; margin-bottom: 1rem; transition: color .15s;
}
.fb-settings-back:hover { color: #f8fafc; }
.fb-settings-back svg { width: 1rem; height: 1rem; }
.fb-settings-title { font-size: 1.875rem; font-weight: 800; color: #f8fafc; }
/* Tab bar */
.fb-tabbar {
display: flex; flex-wrap: wrap; gap: .25rem;
border-bottom: 1px solid rgba(51, 65, 85, .6);
margin: 1.25rem 0 1.5rem;
}
.fb-tab {
appearance: none; background: none; border: none; cursor: pointer;
padding: .55rem .85rem; font-size: .85rem; font-weight: 600;
color: #94a3b8; border-bottom: 2px solid transparent;
margin-bottom: -1px; transition: color .15s, border-color .15s; white-space: nowrap;
}
.fb-tab:hover { color: #e2e8f0; }
.fb-tab.active { color: #f8fafc; border-bottom-color: #0ea5e9; }
/* Panels */
.fb-tabpanel { display: none; }
.fb-tabpanel.active { display: block; }
.fb-tabpanel-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.fb-tabpanel-head h3 { font-size: 1.1rem; font-weight: 700; color: #f8fafc; }
/* Card rows */
.fb-srows { display: flex; flex-direction: column; gap: .6rem; }
.fb-srow {
display: flex; align-items: center; gap: 1rem;
background: #1e293b; border: 1px solid rgba(51, 65, 85, .55);
border-radius: .75rem; padding: .85rem 1rem;
}
.fb-srow-stack { flex-direction: column; align-items: stretch; gap: .65rem; }
.fb-srow-icon {
flex: none; width: 2.25rem; height: 2.25rem; border-radius: .6rem;
display: flex; align-items: center; justify-content: center;
background: rgba(14, 165, 233, .12); color: #38bdf8;
}
.fb-srow-icon svg { width: 1.15rem; height: 1.15rem; }
.fb-srow-main { flex: 1 1 auto; min-width: 0; }
.fb-srow-title { font-size: .9rem; font-weight: 600; color: #e2e8f0; display: flex; align-items: center; }
.fb-srow-desc { font-size: .75rem; color: #94a3b8; margin-top: .15rem; }
.fb-srow-control { flex: none; display: flex; align-items: center; gap: .5rem; }
.fb-srow-control select,
.fb-srow-control input[type="text"] { min-width: 11rem; }
.fb-srow-stack .fb-srow-control { width: 100%; }
.fb-srow-stack .fb-srow-control input[type="text"] { flex: 1 1 auto; min-width: 0; }
.fb-srow-wide { width: 100%; }
/* Toggle switch */
.fb-switch { position: relative; display: inline-block; width: 2.6rem; height: 1.5rem; flex: none; }
.fb-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.fb-switch .fb-switch-track {
position: absolute; inset: 0; cursor: pointer;
background: #334155; border-radius: 999px; transition: background .15s;
}
.fb-switch .fb-switch-track::before {
content: ""; position: absolute; height: 1.1rem; width: 1.1rem; left: .2rem; top: .2rem;
background: #f8fafc; border-radius: 50%; transition: transform .15s;
}
.fb-switch input:checked + .fb-switch-track { background: #0ea5e9; }
.fb-switch input:checked + .fb-switch-track::before { transform: translateX(1.1rem); }
.fb-switch input:focus-visible + .fb-switch-track { box-shadow: 0 0 0 2px rgba(56, 189, 248, .5); }
/* "Not yet active" stub badge */
.fb-stub-note {
font-size: .6rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
color: #fde68a; background: rgba(234, 179, 8, .12);
border-radius: 999px; padding: .1rem .45rem; margin-left: .5rem; white-space: nowrap;
}
/* Per-category reset */
.fb-reset-btn {
display: inline-flex; align-items: center; gap: .35rem;
font-size: .8rem; color: #94a3b8; background: none; border: none; cursor: pointer;
padding: .35rem .5rem; border-radius: .5rem; transition: color .15s, background .15s;
}
.fb-reset-btn:hover { color: #fca5a5; background: rgba(239, 68, 68, .08); }
.fb-reset-btn svg { width: .9rem; height: .9rem; }
/* Keybinds reference */
.fb-kbd {
display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .72rem; color: #e2e8f0; background: #0b1220;
border: 1px solid rgba(51, 65, 85, .8); border-bottom-width: 2px;
border-radius: .35rem; padding: .1rem .4rem; min-width: 1.4rem; text-align: center;
}
.fb-kbd-group-title {
font-size: .7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
color: #94a3b8; margin: 1.25rem 0 .5rem;
}
.fb-settings-note { font-size: .75rem; color: #64748b; margin-top: 1rem; }
.fb-tabpanel-empty { font-size: .8rem; color: #64748b; padding: .5rem 0; }
+138
View File
@@ -0,0 +1,138 @@
import { test, expect } from '@playwright/test';
// Verifies the v3 tabbed settings page (feat/v3-settings-tabbed): the tab bar
// renders, tabs switch panels, the active tab persists, existing controls
// still hydrate from /api/settings, the new countdown toggle persists, and the
// per-category reset hits /api/settings/reset.
interface SettingsPayload {
dlc_dir: string;
default_arrangement: string;
demucs_server_url: string;
master_difficulty: number;
av_offset_ms: number;
countdown_before_song: boolean;
miss_penalty: string;
fail_behavior: string;
}
const basePayload: SettingsPayload = {
dlc_dir: '',
default_arrangement: 'Rhythm',
demucs_server_url: '',
master_difficulty: 70,
av_offset_ms: 0,
countdown_before_song: false,
miss_penalty: 'none',
fail_behavior: 'continue',
};
// A fresh profile shows the blocking onboarding overlay; onboard via the API
// so the tab clicks below aren't intercepted (idempotent once onboarded).
test.beforeEach(async ({ request }) => {
await request.post('/api/profile', { data: { display_name: 'Settings Tester' } });
await request.post('/api/progression/paths', { data: { add: ['guitar'] } });
await request.post('/api/progression/onboarding', { data: { action: 'skip' } });
});
// Open the v3 settings screen with the first-run onboarding overlay neutralised
// (the API skip in beforeEach handles the common path; this also hides the
// overlay element so a slow async profile render can't intercept tab clicks).
async function openSettings(page) {
await page.goto('/');
await page.waitForSelector('#settings-tabbar', { state: 'attached' });
await page.addStyleTag({ content: '#v3-onboarding{display:none!important;pointer-events:none!important}' });
await page.evaluate(() => (window as any).showScreen('settings'));
}
async function mockSettings(page, posts: any[], resets: any[]) {
await page.route('**/api/settings', async route => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: basePayload });
return;
}
posts.push(route.request().postDataJSON());
await route.fulfill({ json: { message: 'Settings saved' } });
});
await page.route('**/api/settings/reset', async route => {
resets.push(route.request().postDataJSON());
await route.fulfill({ json: { message: 'Settings reset', reset: [] } });
});
}
test('tab bar renders the settings tabs and Gameplay is default', async ({ page }) => {
await mockSettings(page, [], []);
await openSettings(page);
const tabs = await page.locator('#settings-tabbar .fb-tab').allTextContents();
expect(tabs).toEqual(['Gameplay', 'Audio', 'Graphics', 'Keybinds', 'Progression', 'Mic', 'Plugins', 'System']);
// Gameplay panel is active by default and its controls are present.
await expect(page.locator('.fb-tabpanel[data-tab="gameplay"]')).toHaveClass(/active/);
await expect(page.locator('#setting-lefty')).toBeAttached();
await expect(page.locator('#setting-countdown-before-song')).toBeAttached();
});
test('clicking a tab switches the visible panel', async ({ page }) => {
await mockSettings(page, [], []);
await openSettings(page);
await page.locator('#settings-tabbar .fb-tab[data-tab="audio"]').click();
await expect(page.locator('.fb-tabpanel[data-tab="audio"]')).toHaveClass(/active/);
await expect(page.locator('.fb-tabpanel[data-tab="gameplay"]')).not.toHaveClass(/active/);
await expect(page.locator('#setting-live-guitar-tone-source')).toBeVisible();
});
test('active tab persists across reload', async ({ page }) => {
await mockSettings(page, [], []);
await openSettings(page);
await page.locator('#settings-tabbar .fb-tab[data-tab="system"]').click();
await expect(page.locator('.fb-tabpanel[data-tab="system"]')).toHaveClass(/active/);
await page.reload();
await page.waitForSelector('#settings-tabbar', { state: 'attached' });
// Restored from localStorage even before navigating back to settings.
await expect(page.locator('#settings-tabbar .fb-tab[data-tab="system"]')).toHaveClass(/active/);
});
test('existing controls hydrate from /api/settings', async ({ page }) => {
await mockSettings(page, [], []);
await openSettings(page);
await expect(page.locator('#default-arrangement')).toHaveValue('Rhythm');
// Note highway speed shares master_difficulty (70 in the mock).
await expect(page.locator('#setting-highway-speed')).toHaveValue('70');
await expect(page.locator('#setting-highway-speed-val')).toHaveText('70'); // span holds number; '%' is literal in markup
});
test('countdown toggle persists countdown_before_song', async ({ page }) => {
const posts: any[] = [];
await mockSettings(page, posts, []);
await openSettings(page);
await page.locator('label.fb-switch:has(#setting-countdown-before-song) .fb-switch-track').click();
await expect.poll(() => posts.some(p => p && p.countdown_before_song === true)).toBe(true);
});
test('reset gameplay posts to /api/settings/reset', async ({ page }) => {
const resets: any[] = [];
await mockSettings(page, [], resets);
await openSettings(page);
await page.locator('[data-reset="gameplay"]').click();
// _confirmDialog modal — confirm it.
await page.locator('.slopsmith-modal [data-confirm]').click();
await expect.poll(() => resets.length).toBeGreaterThan(0);
expect(resets[0].keys).toContain('countdown_before_song');
expect(resets[0].keys).toContain('master_difficulty');
});
test('keybinds tab renders the shortcut reference', async ({ page }) => {
await mockSettings(page, [], []);
await openSettings(page);
await page.locator('#settings-tabbar .fb-tab[data-tab="keybinds"]').click();
// Either real shortcuts (kbd chips) or the empty-state note — never blank.
await expect(page.locator('#settings-keybinds')).not.toBeEmpty();
});
+29
View File
@@ -4006,3 +4006,32 @@ def test_loader_honors_persisted_disable_without_memory_flip(tmp_path, reset_plu
assert not (config_dir / "z_marker").exists()
assert plugins.PENDING_PLUGINS["z_target"]["status"] == "disabled"
assert plugins.PENDING_PLUGINS["z_target"]["enabled"] is False
def test_settings_category_parsed_from_manifest(tmp_path, reset_plugin_state):
"""A plugin manifest's settings.category is parsed into settings_category
on the loaded entry (drives the v3 settings-tab placement). Absent or a
bare-string `settings` value yields None the frontend's fallback tab."""
plugins = reset_plugin_state
def _write(pid, settings_value):
d = tmp_path / pid
d.mkdir()
(d / "plugin.json").write_text(json.dumps({
"id": pid, "name": pid, "routes": "routes.py", "settings": settings_value,
}))
(d / "routes.py").write_text("def setup(app, ctx):\n pass\n")
_write("graphy", {"html": "settings.html", "category": "graphics"})
_write("plainset", {"html": "settings.html"}) # dict, no category
_write("noset", None) # no settings at all
_run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path)
rows = {p["id"]: p for p in plugins.LOADED_PLUGINS}
assert rows["graphy"]["settings_category"] == "graphics"
assert rows["graphy"]["has_settings"] is True
assert rows["plainset"]["settings_category"] is None
assert rows["plainset"]["has_settings"] is True
assert rows["noset"]["settings_category"] is None
assert rows["noset"]["has_settings"] is False
+93 -3
View File
@@ -35,9 +35,11 @@ class _DirectSettingsClient:
return _DirectResponse(self._server.get_settings())
def post(self, path, json):
if path != "/api/settings":
raise ValueError(f"unsupported path: {path}")
return _DirectResponse(self._server.save_settings(json))
if path == "/api/settings":
return _DirectResponse(self._server.save_settings(json))
if path == "/api/settings/reset":
return _DirectResponse(self._server.reset_settings(json))
raise ValueError(f"unsupported path: {path}")
def close(self):
pass
@@ -682,3 +684,91 @@ def test_skip_startup_tasks_clears_stale_plugin_registry(tmp_path, monkeypatch,
if conn is not None:
conn.close()
_restore_loaded_plugins(plugins_snapshot)
# ── v0.3.0 gameplay settings (tabbed settings page) ─────────────────────────
def test_countdown_before_song_persists_bool(client, tmp_path):
r = client.post("/api/settings", json={"countdown_before_song": True})
assert r.status_code == 200
assert _read_cfg(tmp_path)["countdown_before_song"] is True
client.post("/api/settings", json={"countdown_before_song": False})
assert _read_cfg(tmp_path)["countdown_before_song"] is False
@pytest.mark.parametrize("bad_value", [1, 0, "true", "yes", [], {}])
def test_countdown_before_song_rejects_non_bool(client, tmp_path, bad_value):
(tmp_path / "config.json").write_text(json.dumps({"countdown_before_song": True}))
r = client.post("/api/settings", json={"countdown_before_song": bad_value})
assert "error" in r.json()
# Previous value preserved on bad input.
assert _read_cfg(tmp_path)["countdown_before_song"] is True
@pytest.mark.parametrize("key,good,bad", [
("miss_penalty", "high", "extreme"),
("fail_behavior", "restart", "explode"),
])
def test_enum_settings_validate(client, tmp_path, key, good, bad):
r = client.post("/api/settings", json={key: good})
assert r.status_code == 200
assert _read_cfg(tmp_path)[key] == good
# Bad enum value is rejected and doesn't clobber the persisted good one.
r = client.post("/api/settings", json={key: bad})
assert "error" in r.json()
assert _read_cfg(tmp_path)[key] == good
def test_defaults_include_gameplay_keys(client, tmp_path):
# Fresh install (no config.json) — GET should expose the new keys at their
# neutral defaults so the frontend hydrates predictably.
data = client.get("/api/settings").json()
assert data["countdown_before_song"] is False
assert data["miss_penalty"] == "none"
assert data["fail_behavior"] == "continue"
# ── /api/settings/reset ─────────────────────────────────────────────────────
def test_reset_clears_requested_keys(client, tmp_path):
(tmp_path / "config.json").write_text(json.dumps({
"master_difficulty": 40,
"countdown_before_song": True,
"default_arrangement": "Lead",
"demucs_server_url": "http://demucs.example:9000",
}))
r = client.post("/api/settings/reset",
json={"keys": ["master_difficulty", "countdown_before_song"]})
assert r.status_code == 200
body = r.json()
assert set(body["reset"]) == {"master_difficulty", "countdown_before_song"}
cfg = _read_cfg(tmp_path)
# Reset removes the key so GET falls back to the default.
assert "master_difficulty" not in cfg
assert "countdown_before_song" not in cfg
# Unlisted keys are untouched.
assert cfg["default_arrangement"] == "Lead"
assert cfg["demucs_server_url"] == "http://demucs.example:9000"
def test_reset_ignores_unknown_keys(client, tmp_path):
(tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 40}))
# Unknown / non-resettable keys are silently ignored, not an error, and
# can't be used to delete arbitrary config.
r = client.post("/api/settings/reset",
json={"keys": ["dlc_dir", "not_a_real_key", "master_difficulty"]})
assert r.status_code == 200
assert r.json()["reset"] == ["master_difficulty"]
assert "master_difficulty" not in _read_cfg(tmp_path)
def test_reset_bad_body_returns_error(client, tmp_path):
r = client.post("/api/settings/reset", json={"keys": "master_difficulty"})
assert "error" in r.json()
def test_reset_with_no_config_is_noop(client, tmp_path):
# No config.json yet — already at defaults, nothing to remove.
r = client.post("/api/settings/reset", json={"keys": ["master_difficulty"]})
assert r.status_code == 200
assert r.json()["reset"] == []