mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
refactor(app): carve the player controls out of app.js (R3a) (#891)
static/js/player-controls.js (229) — the speed + mastery sliders and the four
playback-preference reads (autoplay-exit, up-next, countdown-before-song,
confirm-exit). Bodies VERBATIM. app.js 7,914 → 7,727.
The fourth slice out of the strongly-connected core, and by far the easiest: ONE
hook (handleSliderInput) and NO shared mutable state. The three groups are the same
surface — the controls under the highway — and the preference reads are the
one-line localStorage lookups half of app.js consults before deciding whether to
auto-start, show the Up Next pill, run a count-in, or confirm on exit. They travel
with the controls that set them.
Zero missed members on the first build (the no-undef pass was clean), which is the
first time that has happened in this phase.
TWO HARNESSES ARE SPLIT, and both taught something:
* speed_reset spans BOTH files — playSong (app.js) resets the speed controls
(module). Its presence GUARDS still read `src.includes('function setSpeed')`
against app.js, so once the code moved they silently evaluated FALSE and the
helpers were quietly dropped from the sandbox. A guard that disables itself is
worse than no guard. Repointed at the file the code actually lives in.
* Its `host.handleSliderInput` stub had to route at the sandbox's EXISTING spy,
not a fresh `() => {}`. The test asserts the slider was actually refreshed
(`deepEqual(__sliderInputs, ['speed-slider'])`); a fresh stub swallows the call
and the assertion passes VACUOUSLY. Same failure mode as a no-op host default —
the thing this whole seam design exists to prevent.
* autoplay_exit is split too: _autoplayExitEnabled moved, but the auto-exit
machinery around it (_clearAutoExit, holdAutoExit, _resolvePlayerOrigin) stayed.
VERIFIED. A/B against origin/main in two browsers, real song: setSpeed(0.75) ->
playbackRate 0.75; applySpeedPreset(100) -> 1; the speed slider; setMastery;
setAutoplayExit / setCountdownBeforeSong / setShowUpNext — IDENTICAL, zero page errors.
pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
11f8c36b61
commit
dc429ecd16
+16
-202
@@ -41,6 +41,21 @@ import {
|
|||||||
} from './js/settings-io.js';
|
} from './js/settings-io.js';
|
||||||
import { audio } from './js/audio-el.js';
|
import { audio } from './js/audio-el.js';
|
||||||
import { S } from './js/player-state.js';
|
import { S } from './js/player-state.js';
|
||||||
|
import {
|
||||||
|
_applyMastery,
|
||||||
|
_applyMasteryAvailability,
|
||||||
|
_autoplayExitEnabled,
|
||||||
|
_countdownBeforeSongEnabled,
|
||||||
|
_curPlaybackSpeed,
|
||||||
|
_exitConfirmEnabled,
|
||||||
|
_resetPlaybackSpeedForNewSong,
|
||||||
|
_showUpNextEnabled,
|
||||||
|
_wireSpeedPresetsOnce,
|
||||||
|
applySpeedPreset,
|
||||||
|
setMastery,
|
||||||
|
setSpeed,
|
||||||
|
} from './js/player-controls.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
_cancelCountIn,
|
_cancelCountIn,
|
||||||
armCreditsHideOnPlay,
|
armCreditsHideOnPlay,
|
||||||
@@ -5170,15 +5185,6 @@ document.addEventListener('visibilitychange', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Autoplay & auto-exit (global option, default ON) ──────────────────
|
|
||||||
// One toggle (`autoplayExit` in localStorage) that (a) auto-starts a song
|
|
||||||
// once it's ready and (b) returns to the launching menu when the song
|
|
||||||
// ends. Absence of the key means enabled. The behaviour lives in core
|
|
||||||
// (app.js, shared by the v3 + classic UIs); the end-of-song *score*
|
|
||||||
// screen, when present, is a plugin and hooks the contract below.
|
|
||||||
function _autoplayExitEnabled() {
|
|
||||||
try { return localStorage.getItem('autoplayExit') !== '0'; } catch (_) { return true; }
|
|
||||||
}
|
|
||||||
// Settings checkbox setter (onchange="setAutoplayExit(this.checked)").
|
// Settings checkbox setter (onchange="setAutoplayExit(this.checked)").
|
||||||
window.setAutoplayExit = function (on) {
|
window.setAutoplayExit = function (on) {
|
||||||
try { localStorage.setItem('autoplayExit', on ? '1' : '0'); } catch (_) { /* private mode */ }
|
try { localStorage.setItem('autoplayExit', on ? '1' : '0'); } catch (_) { /* private mode */ }
|
||||||
@@ -5191,15 +5197,6 @@ Object.defineProperty(window.feedBack, 'autoplayExit', {
|
|||||||
get: _autoplayExitEnabled, configurable: true,
|
get: _autoplayExitEnabled, configurable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── "Up Next" pill (global option, default ON) ────────────────────────
|
|
||||||
// Gates the v3 player chrome's persistent upcoming-section pill
|
|
||||||
// (#v3-upnext, driven by player-chrome.js's updateUpNext). Client-only
|
|
||||||
// localStorage pref (`showUpNext`); absence of the key means enabled.
|
|
||||||
// player-chrome.js reads window.feedBack.showUpNext each tick and hides
|
|
||||||
// the pill when off.
|
|
||||||
function _showUpNextEnabled() {
|
|
||||||
try { return localStorage.getItem('showUpNext') !== '0'; } catch (_) { return true; }
|
|
||||||
}
|
|
||||||
// Settings checkbox setter (onchange="setShowUpNext(this.checked)").
|
// Settings checkbox setter (onchange="setShowUpNext(this.checked)").
|
||||||
window.setShowUpNext = function (on) {
|
window.setShowUpNext = function (on) {
|
||||||
try { localStorage.setItem('showUpNext', on ? '1' : '0'); } catch (_) { /* private mode */ }
|
try { localStorage.setItem('showUpNext', on ? '1' : '0'); } catch (_) { /* private mode */ }
|
||||||
@@ -5217,12 +5214,6 @@ Object.defineProperty(window.feedBack, 'showUpNext', {
|
|||||||
get: _showUpNextEnabled, configurable: true,
|
get: _showUpNextEnabled, 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)").
|
// Settings checkbox setter (onchange="setCountdownBeforeSong(this.checked)").
|
||||||
// Writes localStorage for the synchronous read above AND persists to the
|
// Writes localStorage for the synchronous read above AND persists to the
|
||||||
// server so it survives a reload / rides along in the settings export bundle.
|
// server so it survives a reload / rides along in the settings export bundle.
|
||||||
@@ -5378,13 +5369,6 @@ const _RESUME_END_GUARD_S = 5; // ignore basically-finished
|
|||||||
let _pendingResume = null; // {position, speed}, consumed at song:ready
|
let _pendingResume = null; // {position, speed}, consumed at song:ready
|
||||||
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
|
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
|
||||||
|
|
||||||
function _curPlaybackSpeed() {
|
|
||||||
try {
|
|
||||||
return window._juceMode
|
|
||||||
? ((window.jucePlayer && window.jucePlayer._speed) || 1)
|
|
||||||
: (document.getElementById('audio')?.playbackRate || 1);
|
|
||||||
} catch (_) { return 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot the live session. Called from showScreen()'s teardown before
|
// Snapshot the live session. Called from showScreen()'s teardown before
|
||||||
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||||
@@ -6088,15 +6072,6 @@ window.feedBack.playQueue = (function () {
|
|||||||
if (window.feedBack) window.feedBack.closeCurrentSong = queueAwareClose;
|
if (window.feedBack) window.feedBack.closeCurrentSong = queueAwareClose;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
|
|
||||||
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
|
|
||||||
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
|
|
||||||
// of leaving immediately. Auto-exit on song-end and a results screen's own
|
|
||||||
// Close never prompt — they call closeCurrentSong() directly, which stays the
|
|
||||||
// unguarded actual-exit.
|
|
||||||
function _exitConfirmEnabled() {
|
|
||||||
try { return localStorage.getItem('confirmExitSong') === '1'; } catch (_) { return false; }
|
|
||||||
}
|
|
||||||
// Settings checkbox setter (onchange="setConfirmExitSong(this.checked)").
|
// Settings checkbox setter (onchange="setConfirmExitSong(this.checked)").
|
||||||
window.setConfirmExitSong = function (on) {
|
window.setConfirmExitSong = function (on) {
|
||||||
try { localStorage.setItem('confirmExitSong', on ? '1' : '0'); } catch (_) { /* private mode */ }
|
try { localStorage.setItem('confirmExitSong', on ? '1' : '0'); } catch (_) { /* private mode */ }
|
||||||
@@ -6217,175 +6192,13 @@ function _openExitConfirm() {
|
|||||||
}
|
}
|
||||||
window._openExitConfirm = _openExitConfirm; // exposed for tests/debugging
|
window._openExitConfirm = _openExitConfirm; // exposed for tests/debugging
|
||||||
|
|
||||||
const SPEED_PRESET_PCTS = [100, 90, 80, 75, 70, 60, 50];
|
|
||||||
const SPEED_SNAP_THRESHOLD = 0.02;
|
|
||||||
let _speedPresetsWired = false;
|
|
||||||
|
|
||||||
function _speedPresetPctFromActive(activePctOrRate) {
|
|
||||||
if (!Number.isFinite(activePctOrRate)) return null;
|
|
||||||
const rate = activePctOrRate <= 1.5 ? activePctOrRate : activePctOrRate / 100;
|
|
||||||
for (const pct of SPEED_PRESET_PCTS) {
|
|
||||||
if (Math.abs(rate - pct / 100) <= SPEED_SNAP_THRESHOLD) return pct;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _updateSpeedPresetButtons(activePctOrRate) {
|
|
||||||
const wrap = document.getElementById('speed-presets');
|
|
||||||
if (!wrap) return;
|
|
||||||
const target = _speedPresetPctFromActive(activePctOrRate);
|
|
||||||
for (const btn of wrap.querySelectorAll('[data-speed-preset]')) {
|
|
||||||
const pct = Number(btn.dataset.speedPreset);
|
|
||||||
btn.classList.toggle('v3-speed-preset-active', target !== null && pct === target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySpeedPreset(percent) {
|
|
||||||
const slider = document.getElementById('speed-slider');
|
|
||||||
if (!slider) return;
|
|
||||||
const pct = Math.max(
|
|
||||||
Number(slider.min) || 15,
|
|
||||||
Math.min(Number(slider.max) || 150, Number(percent)),
|
|
||||||
);
|
|
||||||
if (!Number.isFinite(pct)) return;
|
|
||||||
slider.value = String(pct);
|
|
||||||
handleSliderInput(slider);
|
|
||||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
}
|
|
||||||
window.applySpeedPreset = applySpeedPreset;
|
window.applySpeedPreset = applySpeedPreset;
|
||||||
|
|
||||||
function _wireSpeedPresetsOnce() {
|
|
||||||
if (_speedPresetsWired) return;
|
|
||||||
const presets = document.getElementById('speed-presets');
|
|
||||||
if (!presets) return;
|
|
||||||
_speedPresetsWired = true;
|
|
||||||
presets.addEventListener('click', (e) => {
|
|
||||||
const btn = e.target.closest('[data-speed-preset]');
|
|
||||||
if (!btn) return;
|
|
||||||
applySpeedPreset(Number(btn.dataset.speedPreset));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSpeed(v) {
|
|
||||||
const speedSlider = document.getElementById('speed-slider');
|
|
||||||
const rate = Number(v);
|
|
||||||
if (!Number.isFinite(rate)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (window._juceMode) {
|
|
||||||
window.jucePlayer?.setRate(rate);
|
|
||||||
const juceAudio = window.feedBackDesktop?.audio;
|
|
||||||
Promise.resolve()
|
|
||||||
.then(() => juceAudio?.setBackingSpeed(rate))
|
|
||||||
// Match the HTML5 path: preserve pitch on the JUCE backing track too.
|
|
||||||
// Optional-chained call is a no-op on desktop builds that predate
|
|
||||||
// setBackingPreservePitch, so this is safe to ship unconditionally.
|
|
||||||
.then(() => juceAudio?.setBackingPreservePitch?.(true))
|
|
||||||
.catch(err => console.warn('[setSpeed] backing speed/preserve-pitch failed:', err));
|
|
||||||
} else {
|
|
||||||
audio.playbackRate = rate;
|
|
||||||
}
|
|
||||||
const speedLabel = document.getElementById('speed-label');
|
|
||||||
if (speedLabel) speedLabel.textContent = rate.toFixed(2) + 'x';
|
|
||||||
handleSliderInput(speedSlider);
|
|
||||||
_updateSpeedPresetButtons(rate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _resetPlaybackSpeedForNewSong() {
|
|
||||||
// Reset the *actual* playback rate to 1x, not just the visible slider/label
|
|
||||||
// (feedBack#615). The HTML5 <audio> element and the desktop JUCE/backing
|
|
||||||
// engine each retain their own rate, and which one drives the next song
|
|
||||||
// isn't decided until later in the load, so reset all paths unconditionally.
|
|
||||||
// Every setter is idempotent and optional-chained, so this is safe in web
|
|
||||||
// and desktop builds alike — no need to branch on window._juceMode.
|
|
||||||
const speedSlider = document.getElementById('speed-slider');
|
|
||||||
if (speedSlider) speedSlider.value = 100;
|
|
||||||
audio.playbackRate = 1;
|
|
||||||
window.jucePlayer?.setRate?.(1);
|
|
||||||
const juceAudio = window.feedBackDesktop?.audio;
|
|
||||||
Promise.resolve()
|
|
||||||
.then(() => juceAudio?.setBackingSpeed?.(1))
|
|
||||||
.then(() => juceAudio?.setBackingPreservePitch?.(true))
|
|
||||||
.catch(err => console.warn('[resetSpeed] backing speed/preserve-pitch failed:', err));
|
|
||||||
// Mirror setSpeed's UI side-effects (label text + slider fill styling).
|
|
||||||
const speedLabel = document.getElementById('speed-label');
|
|
||||||
if (speedLabel) speedLabel.textContent = (1).toFixed(2) + 'x';
|
|
||||||
handleSliderInput(speedSlider);
|
|
||||||
_updateSpeedPresetButtons(100);
|
|
||||||
}
|
|
||||||
// Master-difficulty slider (feedBack#48). Persists partial via
|
|
||||||
// /api/settings — the POST handler merges only the keys present, so
|
|
||||||
// this fire-and-forget call doesn't clobber dlc_dir or other settings.
|
|
||||||
//
|
|
||||||
// Debounced trailing-edge (300ms) so dragging the slider — which fires
|
|
||||||
// oninput per pixel — doesn't flood the server with concurrent writes
|
|
||||||
// to config.json. highway.setMastery() still fires every oninput so
|
|
||||||
// the chart re-filters in real time; only disk persistence waits.
|
|
||||||
let _masteryPersistTimer = null;
|
|
||||||
function _persistMastery(pct) {
|
|
||||||
if (_masteryPersistTimer) clearTimeout(_masteryPersistTimer);
|
|
||||||
_masteryPersistTimer = setTimeout(() => {
|
|
||||||
_masteryPersistTimer = null;
|
|
||||||
fetch('/api/settings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ master_difficulty: pct }),
|
|
||||||
}).catch(() => { /* best-effort — next setMastery() will retry */ });
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
function setMastery(v) {
|
|
||||||
_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));
|
|
||||||
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);
|
|
||||||
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
|
|
||||||
// sources (GP imports, legacy sloppak), so hasPhraseData() is the
|
|
||||||
// right signal to enable/disable the slider.
|
|
||||||
function _applyMasteryAvailability(hasPhraseData) {
|
|
||||||
const slider = document.getElementById('mastery-slider');
|
|
||||||
if (!slider) return;
|
|
||||||
if (hasPhraseData) {
|
|
||||||
slider.disabled = false;
|
|
||||||
slider.title = 'Master difficulty — low = simpler chart, high = full';
|
|
||||||
} else {
|
|
||||||
slider.disabled = true;
|
|
||||||
slider.title = 'Source chart has a single difficulty level — slider disabled';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (window.feedBack) {
|
if (window.feedBack) {
|
||||||
window.feedBack.on('song:loaded', syncDefaultArrangementPin);
|
window.feedBack.on('song:loaded', syncDefaultArrangementPin);
|
||||||
window.feedBack.on('arrangement:changed', syncDefaultArrangementPin);
|
window.feedBack.on('arrangement:changed', syncDefaultArrangementPin);
|
||||||
@@ -7860,6 +7673,7 @@ configureHost({
|
|||||||
setPlayButtonState,
|
setPlayButtonState,
|
||||||
_songEventPayload,
|
_songEventPayload,
|
||||||
togglePlay,
|
togglePlay,
|
||||||
|
handleSliderInput,
|
||||||
// count-in is a module now, so section-practice reaches it through the seam too —
|
// count-in is a module now, so section-practice reaches it through the seam too —
|
||||||
// these are simply count-in's own exports, handed across.
|
// these are simply count-in's own exports, handed across.
|
||||||
startCountIn,
|
startCountIn,
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// Player controls — the speed and mastery sliders, and the four playback preference
|
||||||
|
// reads (autoplay-exit, up-next, countdown-before-song, confirm-exit).
|
||||||
|
//
|
||||||
|
// The fourth slice out of app.js's strongly-connected core, and by far the easiest:
|
||||||
|
// ONE hook and NO shared mutable state. It is here because these three groups are the
|
||||||
|
// same surface (the controls under the highway) and all three reach the same helper.
|
||||||
|
//
|
||||||
|
// The preference reads are one-line localStorage lookups that half of app.js consults
|
||||||
|
// before deciding whether to auto-start, show the Up Next pill, run a count-in, or
|
||||||
|
// confirm on exit. They travel with the controls that set them.
|
||||||
|
//
|
||||||
|
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||||
|
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||||
|
import { audio } from './audio-el.js';
|
||||||
|
import { host } from './host.js';
|
||||||
|
|
||||||
|
// ── Autoplay & auto-exit (global option, default ON) ──────────────────
|
||||||
|
// One toggle (`autoplayExit` in localStorage) that (a) auto-starts a song
|
||||||
|
// once it's ready and (b) returns to the launching menu when the song
|
||||||
|
// ends. Absence of the key means enabled. The behaviour lives in core
|
||||||
|
// (app.js, shared by the v3 + classic UIs); the end-of-song *score*
|
||||||
|
// screen, when present, is a plugin and hooks the contract below.
|
||||||
|
export function _autoplayExitEnabled() {
|
||||||
|
try { return localStorage.getItem('autoplayExit') !== '0'; } catch (_) { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── "Up Next" pill (global option, default ON) ────────────────────────
|
||||||
|
// Gates the v3 player chrome's persistent upcoming-section pill
|
||||||
|
// (#v3-upnext, driven by player-chrome.js's updateUpNext). Client-only
|
||||||
|
// localStorage pref (`showUpNext`); absence of the key means enabled.
|
||||||
|
// player-chrome.js reads window.feedBack.showUpNext each tick and hides
|
||||||
|
// the pill when off.
|
||||||
|
export function _showUpNextEnabled() {
|
||||||
|
try { return localStorage.getItem('showUpNext') !== '0'; } catch (_) { return 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.
|
||||||
|
export function _countdownBeforeSongEnabled() {
|
||||||
|
try { return localStorage.getItem('countdownBeforeSong') === '1'; } catch (_) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _curPlaybackSpeed() {
|
||||||
|
try {
|
||||||
|
return window._juceMode
|
||||||
|
? ((window.jucePlayer && window.jucePlayer._speed) || 1)
|
||||||
|
: (document.getElementById('audio')?.playbackRate || 1);
|
||||||
|
} catch (_) { return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
|
||||||
|
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
|
||||||
|
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
|
||||||
|
// of leaving immediately. Auto-exit on song-end and a results screen's own
|
||||||
|
// Close never prompt — they call closeCurrentSong() directly, which stays the
|
||||||
|
// unguarded actual-exit.
|
||||||
|
export function _exitConfirmEnabled() {
|
||||||
|
try { return localStorage.getItem('confirmExitSong') === '1'; } catch (_) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEED_PRESET_PCTS = [100, 90, 80, 75, 70, 60, 50];
|
||||||
|
const SPEED_SNAP_THRESHOLD = 0.02;
|
||||||
|
let _speedPresetsWired = false;
|
||||||
|
|
||||||
|
function _speedPresetPctFromActive(activePctOrRate) {
|
||||||
|
if (!Number.isFinite(activePctOrRate)) return null;
|
||||||
|
const rate = activePctOrRate <= 1.5 ? activePctOrRate : activePctOrRate / 100;
|
||||||
|
for (const pct of SPEED_PRESET_PCTS) {
|
||||||
|
if (Math.abs(rate - pct / 100) <= SPEED_SNAP_THRESHOLD) return pct;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateSpeedPresetButtons(activePctOrRate) {
|
||||||
|
const wrap = document.getElementById('speed-presets');
|
||||||
|
if (!wrap) return;
|
||||||
|
const target = _speedPresetPctFromActive(activePctOrRate);
|
||||||
|
for (const btn of wrap.querySelectorAll('[data-speed-preset]')) {
|
||||||
|
const pct = Number(btn.dataset.speedPreset);
|
||||||
|
btn.classList.toggle('v3-speed-preset-active', target !== null && pct === target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySpeedPreset(percent) {
|
||||||
|
const slider = document.getElementById('speed-slider');
|
||||||
|
if (!slider) return;
|
||||||
|
const pct = Math.max(
|
||||||
|
Number(slider.min) || 15,
|
||||||
|
Math.min(Number(slider.max) || 150, Number(percent)),
|
||||||
|
);
|
||||||
|
if (!Number.isFinite(pct)) return;
|
||||||
|
slider.value = String(pct);
|
||||||
|
host.handleSliderInput(slider);
|
||||||
|
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _wireSpeedPresetsOnce() {
|
||||||
|
if (_speedPresetsWired) return;
|
||||||
|
const presets = document.getElementById('speed-presets');
|
||||||
|
if (!presets) return;
|
||||||
|
_speedPresetsWired = true;
|
||||||
|
presets.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('[data-speed-preset]');
|
||||||
|
if (!btn) return;
|
||||||
|
applySpeedPreset(Number(btn.dataset.speedPreset));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSpeed(v) {
|
||||||
|
const speedSlider = document.getElementById('speed-slider');
|
||||||
|
const rate = Number(v);
|
||||||
|
if (!Number.isFinite(rate)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window._juceMode) {
|
||||||
|
window.jucePlayer?.setRate(rate);
|
||||||
|
const juceAudio = window.feedBackDesktop?.audio;
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => juceAudio?.setBackingSpeed(rate))
|
||||||
|
// Match the HTML5 path: preserve pitch on the JUCE backing track too.
|
||||||
|
// Optional-chained call is a no-op on desktop builds that predate
|
||||||
|
// setBackingPreservePitch, so this is safe to ship unconditionally.
|
||||||
|
.then(() => juceAudio?.setBackingPreservePitch?.(true))
|
||||||
|
.catch(err => console.warn('[setSpeed] backing speed/preserve-pitch failed:', err));
|
||||||
|
} else {
|
||||||
|
audio.playbackRate = rate;
|
||||||
|
}
|
||||||
|
const speedLabel = document.getElementById('speed-label');
|
||||||
|
if (speedLabel) speedLabel.textContent = rate.toFixed(2) + 'x';
|
||||||
|
host.handleSliderInput(speedSlider);
|
||||||
|
_updateSpeedPresetButtons(rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _resetPlaybackSpeedForNewSong() {
|
||||||
|
// Reset the *actual* playback rate to 1x, not just the visible slider/label
|
||||||
|
// (feedBack#615). The HTML5 <audio> element and the desktop JUCE/backing
|
||||||
|
// engine each retain their own rate, and which one drives the next song
|
||||||
|
// isn't decided until later in the load, so reset all paths unconditionally.
|
||||||
|
// Every setter is idempotent and optional-chained, so this is safe in web
|
||||||
|
// and desktop builds alike — no need to branch on window._juceMode.
|
||||||
|
const speedSlider = document.getElementById('speed-slider');
|
||||||
|
if (speedSlider) speedSlider.value = 100;
|
||||||
|
audio.playbackRate = 1;
|
||||||
|
window.jucePlayer?.setRate?.(1);
|
||||||
|
const juceAudio = window.feedBackDesktop?.audio;
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => juceAudio?.setBackingSpeed?.(1))
|
||||||
|
.then(() => juceAudio?.setBackingPreservePitch?.(true))
|
||||||
|
.catch(err => console.warn('[resetSpeed] backing speed/preserve-pitch failed:', err));
|
||||||
|
// Mirror setSpeed's UI side-effects (label text + slider fill styling).
|
||||||
|
const speedLabel = document.getElementById('speed-label');
|
||||||
|
if (speedLabel) speedLabel.textContent = (1).toFixed(2) + 'x';
|
||||||
|
host.handleSliderInput(speedSlider);
|
||||||
|
_updateSpeedPresetButtons(100);
|
||||||
|
}
|
||||||
|
// Master-difficulty slider (feedBack#48). Persists partial via
|
||||||
|
// /api/settings — the POST handler merges only the keys present, so
|
||||||
|
// this fire-and-forget call doesn't clobber dlc_dir or other settings.
|
||||||
|
//
|
||||||
|
// Debounced trailing-edge (300ms) so dragging the slider — which fires
|
||||||
|
// oninput per pixel — doesn't flood the server with concurrent writes
|
||||||
|
// to config.json. highway.setMastery() still fires every oninput so
|
||||||
|
// the chart re-filters in real time; only disk persistence waits.
|
||||||
|
let _masteryPersistTimer = null;
|
||||||
|
function _persistMastery(pct) {
|
||||||
|
if (_masteryPersistTimer) clearTimeout(_masteryPersistTimer);
|
||||||
|
_masteryPersistTimer = setTimeout(() => {
|
||||||
|
_masteryPersistTimer = null;
|
||||||
|
fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ master_difficulty: pct }),
|
||||||
|
}).catch(() => { /* best-effort — next setMastery() will retry */ });
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
export function setMastery(v) {
|
||||||
|
_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).
|
||||||
|
export 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));
|
||||||
|
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;
|
||||||
|
host.handleSliderInput(popSlider);
|
||||||
|
}
|
||||||
|
const setSlider = document.getElementById('setting-highway-speed');
|
||||||
|
if (setSlider) {
|
||||||
|
if (String(setSlider.value) !== String(pct)) setSlider.value = pct;
|
||||||
|
host.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);
|
||||||
|
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
|
||||||
|
// sources (GP imports, legacy sloppak), so hasPhraseData() is the
|
||||||
|
// right signal to enable/disable the slider.
|
||||||
|
export function _applyMasteryAvailability(hasPhraseData) {
|
||||||
|
const slider = document.getElementById('mastery-slider');
|
||||||
|
if (!slider) return;
|
||||||
|
if (hasPhraseData) {
|
||||||
|
slider.disabled = false;
|
||||||
|
slider.title = 'Master difficulty — low = simpler chart, high = full';
|
||||||
|
} else {
|
||||||
|
slider.disabled = true;
|
||||||
|
slider.title = 'Source chart has a single difficulty level — slider disabled';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,10 +14,16 @@ const vm = require('node:vm');
|
|||||||
const { extractFunction } = require('./test_utils');
|
const { extractFunction } = require('./test_utils');
|
||||||
|
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||||
|
// _autoplayExitEnabled was carved out into static/js/player-controls.js (R3a); the
|
||||||
|
// auto-exit machinery around it (_clearAutoExit, holdAutoExit, _resolvePlayerOrigin)
|
||||||
|
// stayed in app.js.
|
||||||
|
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
|
||||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||||
|
// the module is ESM; these sandboxes evaluate plain script text
|
||||||
|
const CONTROLS_SRC = fs.readFileSync(CONTROLS_JS, 'utf8').replace(/^export /gm, '');
|
||||||
|
|
||||||
function runEnabled(stored) {
|
function runEnabled(stored) {
|
||||||
const fnSrc = extractFunction(SRC, 'function _autoplayExitEnabled(');
|
const fnSrc = extractFunction(CONTROLS_SRC, 'function _autoplayExitEnabled(');
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
localStorage: {
|
localStorage: {
|
||||||
getItem: () => {
|
getItem: () => {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ const path = require('node:path');
|
|||||||
const vm = require('node:vm');
|
const vm = require('node:vm');
|
||||||
|
|
||||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||||
|
// The speed controls were carved out into static/js/player-controls.js (R3a); playSong,
|
||||||
|
// which resets them on a new song, stayed in app.js. This test spans both.
|
||||||
|
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
|
||||||
|
|
||||||
function extractFunction(src, signature) {
|
function extractFunction(src, signature) {
|
||||||
const start = src.indexOf(signature);
|
const start = src.indexOf(signature);
|
||||||
@@ -130,15 +133,17 @@ function extractConstLine(src, name) {
|
|||||||
|
|
||||||
function loadPlaySong(sandbox) {
|
function loadPlaySong(sandbox) {
|
||||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||||
const resetHelper = src.includes('function _resetPlaybackSpeedForNewSong')
|
// the module is ESM; the vm sandbox evaluates plain script text
|
||||||
? extractFunction(src, 'function _resetPlaybackSpeedForNewSong')
|
const controls = fs.readFileSync(CONTROLS_JS, 'utf8').replace(/^export /gm, '');
|
||||||
|
const resetHelper = controls.includes('function _resetPlaybackSpeedForNewSong')
|
||||||
|
? extractFunction(controls, 'function _resetPlaybackSpeedForNewSong')
|
||||||
: '';
|
: '';
|
||||||
const speedPresetHelpers = src.includes('function _updateSpeedPresetButtons')
|
const speedPresetHelpers = controls.includes('function _updateSpeedPresetButtons')
|
||||||
? `
|
? `
|
||||||
${extractConstLine(src, 'SPEED_PRESET_PCTS')}
|
${extractConstLine(controls, 'SPEED_PRESET_PCTS')}
|
||||||
${extractConstLine(src, 'SPEED_SNAP_THRESHOLD')}
|
${extractConstLine(controls, 'SPEED_SNAP_THRESHOLD')}
|
||||||
${extractFunction(src, 'function _speedPresetPctFromActive')}
|
${extractFunction(controls, 'function _speedPresetPctFromActive')}
|
||||||
${extractFunction(src, 'function _updateSpeedPresetButtons')}
|
${extractFunction(controls, 'function _updateSpeedPresetButtons')}
|
||||||
`
|
`
|
||||||
: '';
|
: '';
|
||||||
const code = `
|
const code = `
|
||||||
@@ -147,6 +152,11 @@ function loadPlaySong(sandbox) {
|
|||||||
// WRITE it (an imported binding is read-only). NB window.feedBack.isPlaying — the
|
// WRITE it (an imported binding is read-only). NB window.feedBack.isPlaying — the
|
||||||
// public mirror stubbed above — is a different thing and is unchanged.
|
// public mirror stubbed above — is a different thing and is unchanged.
|
||||||
var S = { isPlaying: true, lastAudioTime: 0 };
|
var S = { isPlaying: true, lastAudioTime: 0 };
|
||||||
|
// The speed controls reach app.js through the host seam (static/js/host.js).
|
||||||
|
// Route it at the sandbox's EXISTING handleSliderInput spy — a fresh stub would
|
||||||
|
// swallow the call and the assertion below (which checks the slider was actually
|
||||||
|
// refreshed) would pass vacuously.
|
||||||
|
var host = { handleSliderInput: (el) => handleSliderInput(el) };
|
||||||
var currentFilename = null;
|
var currentFilename = null;
|
||||||
var _playerOriginScreen = null;
|
var _playerOriginScreen = null;
|
||||||
var _pendingAutostart = false;
|
var _pendingAutostart = false;
|
||||||
@@ -166,7 +176,7 @@ function loadPlaySong(sandbox) {
|
|||||||
function _scheduleSectionPracticeRetries() {}
|
function _scheduleSectionPracticeRetries() {}
|
||||||
function loadSavedLoops() {}
|
function loadSavedLoops() {}
|
||||||
function _songEventPayload() { return { time: 7, audioT: 7, chartT: 7, perfNow: 7 }; }
|
function _songEventPayload() { return { time: 7, audioT: 7, chartT: 7, perfNow: 7 }; }
|
||||||
${extractFunction(src, 'function setSpeed')}
|
${extractFunction(controls, 'function setSpeed')}
|
||||||
${speedPresetHelpers}
|
${speedPresetHelpers}
|
||||||
${resetHelper}
|
${resetHelper}
|
||||||
${extractFunction(src, 'async function playSong')}
|
${extractFunction(src, 'async function playSong')}
|
||||||
|
|||||||
Reference in New Issue
Block a user