refactor(app): lift the shared player state onto a container (R3a) (#889)

static/js/player-state.js — one exported object, two fields. app.js's 70 reference
sites rewritten. Provably a no-op; nothing shrinks.

WHY NOW. Every slice carved out of app.js so far only ever READ the state it shared
(loopA/loopB, _audioSeekGen, currentFilename), so a read-only getter hook was enough
and no container was needed — twice I checked and twice I got away with it. That
runs out at count-in: it genuinely WRITES `isPlaying` (it starts and stops playback,
4 sites) and `lastAudioTime` (2). `import { isPlaying }` then `isPlaying = true`
THROWS — an imported binding cannot be assigned to. So the state has to live on an
object: `S.isPlaying = true` is a property write, and works from any module holding
the same S. Same shape stems, studio, and editor all converged on.

DELIBERATELY SMALL. app.js has ~104 top-level `let` scalars; lifting all of them is
a ~977-site rewrite for no benefit, because most are private to one cluster and
travel with it. Only what a carved module must WRITE goes here. Add on demand.

THE REWRITE IS AST-DRIVEN, NOT TEXTUAL — and that is not fussiness. Of 100 textual
occurrences of these two names, only 70 resolve to the module binding:
  * 22 are member accesses (`someObj.isPlaying`, `window.feedBack.isPlaying`)
  * 4 are the LOCAL PARAMETER of `function setPlayButtonState(isPlaying)` — a blind
    replace yields `function setPlayButtonState(S.isPlaying)`
  * 1 is an object key
  * 2 are shorthand properties `{ isPlaying }`, which must become
    `{ isPlaying: S.isPlaying }` — and acorn gives a shorthand's key and value the
    SAME range, so rewriting both produced `isPlaying: S.isPlaying: S.isPlaying`
    until I deduped by range
A find-and-replace corrupts all 29. The rewrite walks the AST, skips shadows, member
properties and keys, and replaces identifier RANGES.

`window.feedBack.isPlaying` — the PUBLIC mirror — is a different thing and is
untouched. Two test sandboxes stub it; those were left alone deliberately.

VERIFIED WITH REAL PLAYBACK. A/B against origin/main in two browsers, real song:
togglePlay -> the public mirror goes true -> false -> true across two toggles, the
audio element's paused state follows, seekBy works — IDENTICAL, zero page errors.

Harnesses: 8 vm-sandbox suites slice playback code out of app.js and now see
S.isPlaying — juce_engine_reroute, loop_restart, play_button_reroute_guard,
playback_app_adapter, song_restart, song_seek, speed_reset, and the python
idempotence source-assert. Each gets the same container in its sandbox; every
assertion is unchanged.

pytest 2396, node 1040/1040, ESLint 0, tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-11 21:52:51 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent cb236e6c04
commit 5fb28d5c5a
10 changed files with 140 additions and 87 deletions
+69 -70
View File
@@ -40,6 +40,7 @@ import {
importSettings, importSettings,
} 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 { import {
_loopMutationGen, _loopMutationGen,
clearLoop, clearLoop,
@@ -1042,7 +1043,7 @@ async function showScreen(id) {
if (id !== 'player') { if (id !== 'player') {
const audio = document.getElementById('audio'); const audio = document.getElementById('audio');
const stopTime = _audioTime(); const stopTime = _audioTime();
const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || isPlaying; const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || S.isPlaying;
// Snapshot where we were so leaving the player — especially by accident // Snapshot where we were so leaving the player — especially by accident
// — is recoverable instead of dumping the user back at bar 1 next time. // — is recoverable instead of dumping the user back at bar 1 next time.
// Must run BEFORE highway.stop()/audio unload, while getSongInfo() and // Must run BEFORE highway.stop()/audio unload, while getSongInfo() and
@@ -1063,7 +1064,7 @@ async function showScreen(id) {
// to 0, then emit AFTER stop completes. Mirrors the HTML5 // to 0, then emit AFTER stop completes. Mirrors the HTML5
// pause contract via _songEventPayload (audioT/chartT/perfNow). // pause contract via _songEventPayload (audioT/chartT/perfNow).
const payload = _songEventPayload(); const payload = _songEventPayload();
const wasPlaying = isPlaying; const wasPlaying = S.isPlaying;
await jucePlayer.stop().catch(() => {}); await jucePlayer.stop().catch(() => {});
if (wasPlaying && window.feedBack) { if (wasPlaying && window.feedBack) {
window.feedBack.isPlaying = false; window.feedBack.isPlaying = false;
@@ -1078,7 +1079,7 @@ async function showScreen(id) {
window._currentSongAudio = null; window._currentSongAudio = null;
// Reloading any song later should get a fresh JUCE routing attempt. // Reloading any song later should get a fresh JUCE routing attempt.
window._clearJuceRerouteMemo?.(); window._clearJuceRerouteMemo?.();
isPlaying = false; S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
} }
window.scrollTo(0, 0); window.scrollTo(0, 0);
@@ -3463,7 +3464,6 @@ function retuneSong(filename, title, tuning, target) {
// `audio` now lives in ./js/audio-el.js so carved-out modules can reach the // `audio` now lives in ./js/audio-el.js so carved-out modules can reach the
// player without importing app.js back (which would close a cycle). Same // player without importing app.js back (which would close a cycle). Same
// element, same handle, same lookup — just imported instead of declared here. // element, same handle, same lookup — just imported instead of declared here.
let isPlaying = false;
let _lastSongPositionEventAt = 0; let _lastSongPositionEventAt = 0;
function _emitSongPositionChanged(time, duration) { function _emitSongPositionChanged(time, duration) {
@@ -3710,7 +3710,7 @@ window.addEventListener('unhandledrejection', (e) => {
// (a transient transport-start failure throws instead — also not memoised.) // (a transient transport-start failure throws instead — also not memoised.)
async function _switchHtml5ToJuce(songAudio) { async function _switchHtml5ToJuce(songAudio) {
const url = songAudio.url; const url = songAudio.url;
const wasPlaying = isPlaying; const wasPlaying = S.isPlaying;
const pos = audio.currentTime || 0; const pos = audio.currentTime || 0;
window.feedBack?.playback?.recordRouteChange?.({ window.feedBack?.playback?.recordRouteChange?.({
routeKind: 'desktop-native', routeKind: 'desktop-native',
@@ -3746,7 +3746,7 @@ window.addEventListener('unhandledrejection', (e) => {
// flow audio.src is intact here, but a prior HTML5→JUCE switch // flow audio.src is intact here, but a prior HTML5→JUCE switch
// clears it — re-point + load before resuming so a bounced // clears it — re-point + load before resuming so a bounced
// reroute doesn't try to play() an empty element. // reroute doesn't try to play() an empty element.
if (isPlaying && !_isStale(songAudio)) { if (S.isPlaying && !_isStale(songAudio)) {
if (!audio.src) { audio.src = url; audio.load(); } if (!audio.src) { audio.src = url; audio.load(); }
try { await audio.play(); } catch (_) { /* ignore */ } try { await audio.play(); } catch (_) { /* ignore */ }
} }
@@ -3775,10 +3775,10 @@ window.addEventListener('unhandledrejection', (e) => {
// during the multi-await fetch/IPC chain above. Starting the JUCE // during the multi-await fetch/IPC chain above. Starting the JUCE
// transport off a stale `wasPlaying` snapshot would resume a song // transport off a stale `wasPlaying` snapshot would resume a song
// the user just paused. Only start it if playback is still wanted. // the user just paused. Only start it if playback is still wanted.
if (isPlaying) { if (S.isPlaying) {
const started = await jucePlayer.play(); const started = await jucePlayer.play();
if (started === false) { if (started === false) {
if (!_isStale(songAudio) && isPlaying) { if (!_isStale(songAudio) && S.isPlaying) {
try { await audio.play(); } catch (_) { /* ignore */ } try { await audio.play(); } catch (_) { /* ignore */ }
} }
throw new Error('jucePlayer.play() failed (transient transport start)'); throw new Error('jucePlayer.play() failed (transient transport start)');
@@ -3818,7 +3818,7 @@ window.addEventListener('unhandledrejection', (e) => {
// previously playing song isn't left silently paused, then re-throw // previously playing song isn't left silently paused, then re-throw
// so the caller logs it. The caller does NOT memoise this URL — // so the caller logs it. The caller does NOT memoise this URL —
// transient failures must retry on the next poll. // transient failures must retry on the next poll.
if (isPlaying && !window._juceMode && !_isStale(songAudio)) { if (S.isPlaying && !window._juceMode && !_isStale(songAudio)) {
if (!audio.src) { audio.src = url; audio.load(); } if (!audio.src) { audio.src = url; audio.load(); }
try { await audio.play(); } catch (_) { /* ignore */ } try { await audio.play(); } catch (_) { /* ignore */ }
} }
@@ -3846,7 +3846,7 @@ window.addEventListener('unhandledrejection', (e) => {
async function _switchJuceToHtml5(songAudio) { async function _switchJuceToHtml5(songAudio) {
const url = songAudio.url; const url = songAudio.url;
const wasPlaying = isPlaying; const wasPlaying = S.isPlaying;
const pos = (window.jucePlayer ? jucePlayer.currentTime : 0) || 0; const pos = (window.jucePlayer ? jucePlayer.currentTime : 0) || 0;
window.feedBack?.playback?.recordRouteChange?.({ window.feedBack?.playback?.recordRouteChange?.({
routeKind: 'browser-media', routeKind: 'browser-media',
@@ -3893,7 +3893,7 @@ window.addEventListener('unhandledrejection', (e) => {
// Re-read isPlaying (not the entry snapshot): the user may // Re-read isPlaying (not the entry snapshot): the user may
// have pressed Pause during jucePlayer.pause()/metadata // have pressed Pause during jucePlayer.pause()/metadata
// load — don't resume a song they just paused. // load — don't resume a song they just paused.
if (isPlaying) { if (S.isPlaying) {
audio.play().catch(() => { /* ignore */ }); audio.play().catch(() => { /* ignore */ });
} }
} finally { } finally {
@@ -4458,7 +4458,7 @@ let _resetJuceAudioShimChain = function () {};
if (!forUpcomingPlay) { if (!forUpcomingPlay) {
await jucePlayer.pause(); await jucePlayer.pause();
if (gen !== _juceShimGen) return; if (gen !== _juceShimGen) return;
isPlaying = false; S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
const sm = window.feedBack; const sm = window.feedBack;
if (sm) { if (sm) {
@@ -4474,7 +4474,7 @@ let _resetJuceAudioShimChain = function () {};
enqueue(async (gen) => { enqueue(async (gen) => {
await jucePlayer.pause(); await jucePlayer.pause();
if (gen !== _juceShimGen) return; if (gen !== _juceShimGen) return;
isPlaying = false; S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
const sm = window.feedBack; const sm = window.feedBack;
if (sm) { if (sm) {
@@ -4532,7 +4532,7 @@ let _resetJuceAudioShimChain = function () {};
Object.defineProperty(audio, 'paused', { Object.defineProperty(audio, 'paused', {
get() { get() {
if (window._juceMode) return !isPlaying; if (window._juceMode) return !S.isPlaying;
return pausedDesc.get.call(this); return pausedDesc.get.call(this);
}, },
configurable: true, configurable: true,
@@ -4554,7 +4554,7 @@ let _resetJuceAudioShimChain = function () {};
const p = enqueue(async (gen) => { const p = enqueue(async (gen) => {
const started = await jucePlayer.play(); const started = await jucePlayer.play();
if (gen !== _juceShimGen || !started) return; if (gen !== _juceShimGen || !started) return;
isPlaying = true; S.isPlaying = true;
setPlayButtonState(true); setPlayButtonState(true);
const sm = window.feedBack; const sm = window.feedBack;
if (sm) { if (sm) {
@@ -4588,7 +4588,7 @@ function _songEventPayload() {
} }
function _markPlaybackPaused() { function _markPlaybackPaused() {
isPlaying = false; S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
if (window.feedBack) { if (window.feedBack) {
window.feedBack.isPlaying = false; window.feedBack.isPlaying = false;
@@ -4597,7 +4597,7 @@ function _markPlaybackPaused() {
} }
function _markPlaybackResumed() { function _markPlaybackResumed() {
isPlaying = true; S.isPlaying = true;
setPlayButtonState(true); setPlayButtonState(true);
if (window.feedBack) { if (window.feedBack) {
window.feedBack.isPlaying = true; window.feedBack.isPlaying = true;
@@ -4688,7 +4688,7 @@ async function _audioSeek(s, reason) {
// Sync the jump-fix tracker so the next 60Hz tick doesn't see a // Sync the jump-fix tracker so the next 60Hz tick doesn't see a
// legitimate far seek (e.g. saved-loop jump > 30s) as a browser // legitimate far seek (e.g. saved-loop jump > 30s) as a browser
// bug and revert it. // bug and revert it.
lastAudioTime = to; S.lastAudioTime = to;
// Sync the chart clock too so any song:* emit fired right after // Sync the chart clock too so any song:* emit fired right after
// _audioSeek resolves (e.g. the auto-resume song:play in // _audioSeek resolves (e.g. the auto-resume song:play in
// changeArrangement) sees an in-sync chartT via _songEventPayload. // changeArrangement) sees an in-sync chartT via _songEventPayload.
@@ -4814,7 +4814,7 @@ function _currentPlaybackSnapshot() {
chartTime: (typeof highway !== 'undefined' && highway && typeof highway.getTime === 'function') ? highway.getTime() : null, chartTime: (typeof highway !== 'undefined' && highway && typeof highway.getTime === 'function') ? highway.getTime() : null,
duration: Number.isFinite(_audioDuration()) ? _audioDuration() : (song && song.duration) || null, duration: Number.isFinite(_audioDuration()) ? _audioDuration() : (song && song.duration) || null,
playbackRate: window._juceMode ? (window.jucePlayer && window.jucePlayer._speed || 1) : audio.playbackRate, playbackRate: window._juceMode ? (window.jucePlayer && window.jucePlayer._speed || 1) : audio.playbackRate,
isPlaying, isPlaying: S.isPlaying,
readiness: song ? 'ready' : 'idle', readiness: song ? 'ready' : 'idle',
routeKind: window._juceMode ? 'desktop-native' : 'browser-media', routeKind: window._juceMode ? 'desktop-native' : 'browser-media',
routeState: song || audio.src || window._juceAudioUrl ? 'active' : 'unavailable', routeState: song || audio.src || window._juceAudioUrl ? 'active' : 'unavailable',
@@ -4867,9 +4867,9 @@ function _installPlaybackTransportAdapter() {
return _currentPlaybackSnapshot(); return _currentPlaybackSnapshot();
}, },
async pause() { async pause() {
const wasPlaying = isPlaying; const wasPlaying = S.isPlaying;
if (!window._juceMode && wasPlaying) { if (!window._juceMode && wasPlaying) {
isPlaying = false; S.isPlaying = false;
window.feedBack.isPlaying = false; window.feedBack.isPlaying = false;
audio.pause(); audio.pause();
_markPlaybackPaused(); _markPlaybackPaused();
@@ -4877,7 +4877,7 @@ function _installPlaybackTransportAdapter() {
if (window._juceMode) await jucePlayer.pause(); if (window._juceMode) await jucePlayer.pause();
else audio.pause(); else audio.pause();
if (wasPlaying) _markPlaybackPaused(); if (wasPlaying) _markPlaybackPaused();
else { isPlaying = false; window.feedBack.isPlaying = false; setPlayButtonState(false); } else { S.isPlaying = false; window.feedBack.isPlaying = false; setPlayButtonState(false); }
} }
return _currentPlaybackSnapshot(); return _currentPlaybackSnapshot();
}, },
@@ -4888,7 +4888,7 @@ function _installPlaybackTransportAdapter() {
_markPlaybackResumed(); _markPlaybackResumed();
} else { } else {
await audio.play(); await audio.play();
isPlaying = true; S.isPlaying = true;
window.feedBack.isPlaying = true; window.feedBack.isPlaying = true;
setPlayButtonState(true); setPlayButtonState(true);
} }
@@ -4896,11 +4896,11 @@ function _installPlaybackTransportAdapter() {
}, },
async stop() { async stop() {
const stopTime = _audioTime(); const stopTime = _audioTime();
const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || isPlaying; const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || S.isPlaying;
const wasPlaying = isPlaying; const wasPlaying = S.isPlaying;
if (window._juceMode) await jucePlayer.stop().catch(() => {}); if (window._juceMode) await jucePlayer.stop().catch(() => {});
if (!window._juceMode && wasPlaying) { if (!window._juceMode && wasPlaying) {
isPlaying = false; S.isPlaying = false;
window.feedBack.isPlaying = false; window.feedBack.isPlaying = false;
audio.pause(); audio.pause();
_markPlaybackPaused(); _markPlaybackPaused();
@@ -4911,7 +4911,7 @@ function _installPlaybackTransportAdapter() {
// spurious) song:pause. // spurious) song:pause.
if (!window._juceMode) audio.pause(); if (!window._juceMode) audio.pause();
if (wasPlaying) _markPlaybackPaused(); if (wasPlaying) _markPlaybackPaused();
else { isPlaying = false; window.feedBack.isPlaying = false; setPlayButtonState(false); } else { S.isPlaying = false; window.feedBack.isPlaying = false; setPlayButtonState(false); }
} }
if (hadPlayableSong) _emitPlaybackStopped(stopTime); if (hadPlayableSong) _emitPlaybackStopped(stopTime);
return _currentPlaybackSnapshot(); return _currentPlaybackSnapshot();
@@ -4977,7 +4977,7 @@ audio.addEventListener('pause', () => {
// The JUCE engine-reroute watcher pauses the element on purpose mid-migration // The JUCE engine-reroute watcher pauses the element on purpose mid-migration
// (and the src='' it does fires a trailing async pause too); don't flag those // (and the src='' it does fires a trailing async pause too); don't flag those
// as unexpected — the watcher holds window._juceRerouteInProgress across it. // as unexpected — the watcher holds window._juceRerouteInProgress across it.
if (isPlaying && !window._juceRerouteInProgress) { if (S.isPlaying && !window._juceRerouteInProgress) {
console.log('Audio paused unexpectedly at', audio.currentTime.toFixed(1)); console.log('Audio paused unexpectedly at', audio.currentTime.toFixed(1));
} }
}); });
@@ -4989,7 +4989,7 @@ audio.addEventListener('error', (e) => {
audio.addEventListener('stalled', () => console.log('Audio stalled at', audio.currentTime.toFixed(1))); audio.addEventListener('stalled', () => console.log('Audio stalled at', audio.currentTime.toFixed(1)));
audio.addEventListener('waiting', () => console.log('Audio waiting/buffering at', audio.currentTime.toFixed(1))); audio.addEventListener('waiting', () => console.log('Audio waiting/buffering at', audio.currentTime.toFixed(1)));
audio.addEventListener('ended', () => { audio.addEventListener('ended', () => {
console.log('Audio ended'); isPlaying = false; console.log('Audio ended'); S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
window.feedBack.isPlaying = false; window.feedBack.isPlaying = false;
window.feedBack.emit('song:ended', _songEventPayload()); window.feedBack.emit('song:ended', _songEventPayload());
@@ -5008,7 +5008,7 @@ audio.addEventListener('play', () => {
window.feedBack.emit('song:resume', payload); window.feedBack.emit('song:resume', payload);
}); });
audio.addEventListener('pause', () => { audio.addEventListener('pause', () => {
if (!isPlaying) return; if (!S.isPlaying) return;
// Same as above: suppress the song:pause emitted by a reroute's deliberate // Same as above: suppress the song:pause emitted by a reroute's deliberate
// audio.pause() — the migration is transparent to plugin play-state. // audio.pause() — the migration is transparent to plugin play-state.
if (window._juceRerouteInProgress) return; if (window._juceRerouteInProgress) return;
@@ -5301,7 +5301,7 @@ window.feedBack.holdAutoplay = function () {
window.feedBack.on('song:ready', () => { window.feedBack.on('song:ready', () => {
if (!_pendingAutostart) return; if (!_pendingAutostart) return;
_pendingAutostart = false; _pendingAutostart = false;
if (isPlaying) return; if (S.isPlaying) return;
// Feedpak contributor credits: only real feedpak plays carry authors // Feedpak contributor credits: only real feedpak plays carry authors
// (loose/archive and minigames get []), so a non-empty list is the gate. // (loose/archive and minigames get []), so a non-empty list is the gate.
// Shown over the highway and dismissed the moment real playback begins // Shown over the highway and dismissed the moment real playback begins
@@ -5327,13 +5327,13 @@ window.feedBack.on('song:ready', () => {
// can't double-toggle, and so a stale (released-after-leaving) start never // can't double-toggle, and so a stale (released-after-leaving) start never
// begins playback off the player. // begins playback off the player.
const start = () => { const start = () => {
if (isPlaying) return; if (S.isPlaying) return;
if (!document.getElementById('player')?.classList.contains('active')) { hideSongCreditsOverlay(); return; } if (!document.getElementById('player')?.classList.contains('active')) { hideSongCreditsOverlay(); return; }
if (_countdownBeforeSongEnabled()) { if (_countdownBeforeSongEnabled()) {
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err)); Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
} else { } else {
Promise.resolve(togglePlay()) Promise.resolve(togglePlay())
.then(() => { if (!isPlaying) hideSongCreditsOverlay(); }) .then(() => { if (!S.isPlaying) hideSongCreditsOverlay(); })
.catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); }); .catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); });
} }
}; };
@@ -5454,7 +5454,7 @@ window.feedBack.on('song:ready', () => {
} }
} catch (_) { /* speed restore is best-effort */ } } catch (_) { /* speed restore is best-effort */ }
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume')) Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume'))
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); }) .then(() => { if (_autoplayExitEnabled() && !S.isPlaying) return togglePlay(); })
.catch((err) => console.warn('[app] resume failed:', err)); .catch((err) => console.warn('[app] resume failed:', err));
}); });
@@ -5561,7 +5561,7 @@ window.feedBack.on('song:ready', () => {
window._pendingHighwayLoop = null; window._pendingHighwayLoop = null;
window._highwayReturnCtx = pend.returnCtx || null; window._highwayReturnCtx = pend.returnCtx || null;
Promise.resolve(setLoop(pend.a, pend.b)) Promise.resolve(setLoop(pend.a, pend.b))
.then((ok) => { if (ok && !isPlaying) return togglePlay(); }) .then((ok) => { if (ok && !S.isPlaying) return togglePlay(); })
.catch((err) => console.warn('[app] loop-in-3d apply failed:', err)); .catch((err) => console.warn('[app] loop-in-3d apply failed:', err));
_updateEditRegionBtn(); _updateEditRegionBtn();
}); });
@@ -5678,7 +5678,7 @@ async function playSong(filename, arrangement, options) {
// Snapshot payload BEFORE stop() resets _pos so audioT/chartT // Snapshot payload BEFORE stop() resets _pos so audioT/chartT
// capture the actual paused position. // capture the actual paused position.
const payload = _songEventPayload(); const payload = _songEventPayload();
const wasPlaying = isPlaying; const wasPlaying = S.isPlaying;
await jucePlayer.stop().catch(() => {}); await jucePlayer.stop().catch(() => {});
if (wasPlaying && window.feedBack) { if (wasPlaying && window.feedBack) {
window.feedBack.isPlaying = false; window.feedBack.isPlaying = false;
@@ -5693,7 +5693,7 @@ async function playSong(filename, arrangement, options) {
window._currentSongAudio = null; window._currentSongAudio = null;
// Fresh JUCE routing attempt for whatever song loads next. // Fresh JUCE routing attempt for whatever song loads next.
window._clearJuceRerouteMemo?.(); window._clearJuceRerouteMemo?.();
isPlaying = false; S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
_resetPlaybackSpeedForNewSong(); _resetPlaybackSpeedForNewSong();
clearLoop(); clearLoop();
@@ -5702,7 +5702,7 @@ async function playSong(filename, arrangement, options) {
// Reset so the jump-fix (setInterval, ~line 8979) doesn't mistake the new // Reset so the jump-fix (setInterval, ~line 8979) doesn't mistake the new
// song starting at t=0 for an unexpected seek from the previous song's // song starting at t=0 for an unexpected seek from the previous song's
// position. audio.currentTime may not reset synchronously when src is cleared. // position. audio.currentTime may not reset synchronously when src is cleared.
lastAudioTime = 0; S.lastAudioTime = 0;
currentFilename = filename; currentFilename = filename;
// A fresh load arms autoplay; a pending auto-exit from the previous // A fresh load arms autoplay; a pending auto-exit from the previous
@@ -5756,12 +5756,12 @@ async function changeArrangement(index) {
// the timer, the song:play listener, and the overlay node. // the timer, the song:play listener, and the overlay node.
hideSongCreditsOverlay(); hideSongCreditsOverlay();
window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index }); window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index });
const wasPlaying = isPlaying; const wasPlaying = S.isPlaying;
const time = _audioTime(); const time = _audioTime();
if (isPlaying) { if (S.isPlaying) {
if (window._juceMode) await jucePlayer.pause(); if (window._juceMode) await jucePlayer.pause();
else audio.pause(); else audio.pause();
isPlaying = false; S.isPlaying = false;
} }
// Audio is paused, but the play button is intentionally left // Audio is paused, but the play button is intentionally left
@@ -5850,13 +5850,13 @@ async function changeArrangement(index) {
if (window._juceMode) { if (window._juceMode) {
const started = await jucePlayer.play(); const started = await jucePlayer.play();
if (started) { if (started) {
isPlaying = true; S.isPlaying = true;
window.feedBack.isPlaying = true; window.feedBack.isPlaying = true;
const payload = _songEventPayload(); const payload = _songEventPayload();
window.feedBack.emit('song:play', payload); window.feedBack.emit('song:play', payload);
window.feedBack.emit('song:resume', payload); window.feedBack.emit('song:resume', payload);
} }
} else audio.play().then(() => { isPlaying = true; }).catch(() => {}); } else audio.play().then(() => { S.isPlaying = true; }).catch(() => {});
} }
clearBusy(); clearBusy();
clearMyCallback(); clearMyCallback();
@@ -5886,16 +5886,16 @@ let _playAttemptGen = 0;
async function togglePlay() { async function togglePlay() {
if (window._juceMode) { if (window._juceMode) {
if (isPlaying) { if (S.isPlaying) {
await jucePlayer.pause(); await jucePlayer.pause();
isPlaying = false; S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
window.feedBack.isPlaying = false; window.feedBack.isPlaying = false;
window.feedBack.emit('song:pause', _songEventPayload()); window.feedBack.emit('song:pause', _songEventPayload());
} else { } else {
const started = await jucePlayer.play(); const started = await jucePlayer.play();
if (!started) return; // startBacking() failed — IPC error already logged if (!started) return; // startBacking() failed — IPC error already logged
isPlaying = true; S.isPlaying = true;
setPlayButtonState(true); setPlayButtonState(true);
window.feedBack.isPlaying = true; window.feedBack.isPlaying = true;
const payload = _songEventPayload(); const payload = _songEventPayload();
@@ -5904,8 +5904,8 @@ async function togglePlay() {
} }
return; return;
} }
if (isPlaying) { if (S.isPlaying) {
audio.pause(); isPlaying = false; audio.pause(); S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
} else { } else {
// Flip the UI optimistically before awaiting the play() Promise so // Flip the UI optimistically before awaiting the play() Promise so
@@ -5920,7 +5920,7 @@ async function togglePlay() {
// can't clobber a faster attempt N+1 (Play → Pause → Play). // can't clobber a faster attempt N+1 (Play → Pause → Play).
const sessionGen = _audioSeekGen; const sessionGen = _audioSeekGen;
const attempt = ++_playAttemptGen; const attempt = ++_playAttemptGen;
isPlaying = true; S.isPlaying = true;
setPlayButtonState(true); setPlayButtonState(true);
try { try {
await audio.play(); await audio.play();
@@ -5936,7 +5936,7 @@ async function togglePlay() {
// "two clicks to pause on the first song after a fresh load" bug. // "two clicks to pause on the first song after a fresh load" bug.
if (window._juceRerouteInProgress) return; if (window._juceRerouteInProgress) return;
console.error('[app] audio.play() rejected:', err); console.error('[app] audio.play() rejected:', err);
isPlaying = false; S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
} }
} }
@@ -5978,7 +5978,7 @@ async function restartCurrentSong() {
await startCountIn({ immediate: true }); await startCountIn({ immediate: true });
return true; return true;
} }
if (!isPlaying) await togglePlay(); if (!S.isPlaying) await togglePlay();
return true; return true;
} }
window.restartCurrentSong = restartCurrentSong; window.restartCurrentSong = restartCurrentSong;
@@ -6118,7 +6118,7 @@ function _openExitConfirm() {
// still live on the player — guarding a teardown/seek/end behind the prompt. // still live on the player — guarding a teardown/seek/end behind the prompt.
_cancelCountIn(); _cancelCountIn();
const _resumeGen = _audioSeekGen; const _resumeGen = _audioSeekGen;
const _wasPlaying = isPlaying; const _wasPlaying = S.isPlaying;
if (_wasPlaying) Promise.resolve(togglePlay()).catch(() => {}); if (_wasPlaying) Promise.resolve(togglePlay()).catch(() => {});
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.id = 'fb-exit-confirm'; overlay.id = 'fb-exit-confirm';
@@ -6169,7 +6169,7 @@ function _openExitConfirm() {
// Stay → resume exactly what we paused, but only if the session is still // Stay → resume exactly what we paused, but only if the session is still
// the same live song on the player (not torn down / ended / seeked away // the same live song on the player (not torn down / ended / seeked away
// behind the modal). If the user was already paused, leave them paused. // behind the modal). If the user was already paused, leave them paused.
if (_wasPlaying && !isPlaying && if (_wasPlaying && !S.isPlaying &&
_audioSeekGen === _resumeGen && _audioSeekGen === _resumeGen &&
document.querySelector('.screen.active')?.id === 'player') { document.querySelector('.screen.active')?.id === 'player') {
Promise.resolve(togglePlay()).catch(() => {}); Promise.resolve(togglePlay()).catch(() => {});
@@ -6760,7 +6760,7 @@ async function startCountIn(opts = {}) {
_countingIn = false; _countingIn = false;
return; return;
} }
lastAudioTime = loopA; S.lastAudioTime = loopA;
highway.setTime(loopA); highway.setTime(loopA);
if (window.feedBack) { if (window.feedBack) {
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA }); window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
@@ -6810,8 +6810,8 @@ async function startCountIn(opts = {}) {
// isPlaying must reflect that and the button + plugin // isPlaying must reflect that and the button + plugin
// host must agree. // host must agree.
_countingIn = false; _countingIn = false;
if (isPlaying) { if (S.isPlaying) {
isPlaying = false; S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
if (window.feedBack) { if (window.feedBack) {
window.feedBack.isPlaying = false; window.feedBack.isPlaying = false;
@@ -6826,7 +6826,7 @@ async function startCountIn(opts = {}) {
// loopA` because subscribers treat that as the semantic // loopA` because subscribers treat that as the semantic
// marker for "new iteration starts at A", not the actual // marker for "new iteration starts at A", not the actual
// audio position. // audio position.
lastAudioTime = r.to; S.lastAudioTime = r.to;
highway.setTime(r.to); highway.setTime(r.to);
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA }); window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
beginCount(); beginCount();
@@ -6850,7 +6850,7 @@ async function startCountIn(opts = {}) {
jucePlayer.play().then((started) => { jucePlayer.play().then((started) => {
if (gen !== _countInGen) return; // teardown during play start if (gen !== _countInGen) return; // teardown during play start
if (!started) return; if (!started) return;
isPlaying = true; S.isPlaying = true;
setPlayButtonState(true); setPlayButtonState(true);
window.feedBack.isPlaying = true; window.feedBack.isPlaying = true;
const payload = _songEventPayload(); const payload = _songEventPayload();
@@ -6860,7 +6860,7 @@ async function startCountIn(opts = {}) {
} else { } else {
audio.play().then(() => { audio.play().then(() => {
if (gen !== _countInGen) return; if (gen !== _countInGen) return;
isPlaying = true; S.isPlaying = true;
setPlayButtonState(true); setPlayButtonState(true);
}).catch((err) => { }).catch((err) => {
if (gen !== _countInGen) return; if (gen !== _countInGen) return;
@@ -6871,7 +6871,7 @@ async function startCountIn(opts = {}) {
// Same rationale as togglePlay: don't claim playback // Same rationale as togglePlay: don't claim playback
// started if the Promise rejected. // started if the Promise rejected.
console.error('[app] audio.play() rejected after count-in:', err); console.error('[app] audio.play() rejected after count-in:', err);
isPlaying = false; S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
}); });
} }
@@ -6903,7 +6903,7 @@ async function startSongCountIn() {
audio.pause(); audio.pause();
} }
if (gen !== _countInGen) return; // teardown during pause if (gen !== _countInGen) return; // teardown during pause
const startT = lastAudioTime || 0; const startT = S.lastAudioTime || 0;
let bpm = highway.getBPM(startT); let bpm = highway.getBPM(startT);
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each). // Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120; if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
@@ -6929,7 +6929,6 @@ async function startSongCountIn() {
} }
// Time display + highway sync // Time display + highway sync
let lastAudioTime = 0;
// hud-time write cache: the 60 Hz tick below used to rewrite textContent // hud-time write cache: the 60 Hz tick below used to rewrite textContent
// (and getElementById) every tick even though the mm:ss display only // (and getElementById) every tick even though the mm:ss display only
// changes once a second — each write invalidates layout. Write-on-change // changes once a second — each write invalidates layout. Write-on-change
@@ -6941,8 +6940,8 @@ setInterval(() => {
const dur = _audioDuration(); const dur = _audioDuration();
if (dur && !_countingIn) { if (dur && !_countingIn) {
// JUCE end-of-track: HTML5 fires 'ended'; JUCE needs a manual check // JUCE end-of-track: HTML5 fires 'ended'; JUCE needs a manual check
if (window._juceMode && isPlaying && ct >= dur) { if (window._juceMode && S.isPlaying && ct >= dur) {
isPlaying = false; S.isPlaying = false;
setPlayButtonState(false); setPlayButtonState(false);
window.feedBack.isPlaying = false; window.feedBack.isPlaying = false;
window.feedBack.emit('song:ended', _songEventPayload()); window.feedBack.emit('song:ended', _songEventPayload());
@@ -6950,19 +6949,19 @@ setInterval(() => {
} }
// A-B loop: count-in then seek back to A // A-B loop: count-in then seek back to A
else if (loopA !== null && loopB !== null && ct >= loopB) { else if (loopA !== null && loopB !== null && ct >= loopB) {
lastAudioTime = loopB; S.lastAudioTime = loopB;
startCountIn(); startCountIn();
} }
// Detect and fix audio time jumps (browser seeking bug; skip for JUCE — position is polled) // Detect and fix audio time jumps (browser seeking bug; skip for JUCE — position is polled)
else if (!window._juceMode && isPlaying && Math.abs(ct - lastAudioTime) > 30 && lastAudioTime > 0) { else if (!window._juceMode && S.isPlaying && Math.abs(ct - S.lastAudioTime) > 30 && S.lastAudioTime > 0) {
console.warn(`Audio time jumped from ${lastAudioTime.toFixed(1)} to ${ct.toFixed(1)}, resetting`); console.warn(`Audio time jumped from ${S.lastAudioTime.toFixed(1)} to ${ct.toFixed(1)}, resetting`);
_audioSeek(lastAudioTime, 'jump-fix'); _audioSeek(S.lastAudioTime, 'jump-fix');
// Treat the corrected position as canonical for the rest of this // Treat the corrected position as canonical for the rest of this
// tick. Otherwise we'd write the stale jumped `ct` into // tick. Otherwise we'd write the stale jumped `ct` into
// lastAudioTime below and ping-pong on the next tick. // lastAudioTime below and ping-pong on the next tick.
ct = lastAudioTime; ct = S.lastAudioTime;
} }
lastAudioTime = ct; S.lastAudioTime = ct;
const hudText = `${formatTime(ct)} / ${formatTime(dur)}`; const hudText = `${formatTime(ct)} / ${formatTime(dur)}`;
if (hudText !== _hudTimeLast) { if (hudText !== _hudTimeLast) {
if (!_hudTimeEl || !_hudTimeEl.isConnected) _hudTimeEl = document.getElementById('hud-time'); if (!_hudTimeEl || !_hudTimeEl.isConnected) _hudTimeEl = document.getElementById('hud-time');
+34
View File
@@ -0,0 +1,34 @@
// Shared, MUTABLE player state.
//
// WHY A CONTAINER AND NOT PLAIN EXPORTS. An imported binding is read-only. Every
// slice carved out of app.js so far has only ever READ the state it shares
// (loopA/loopB, _audioSeekGen, currentFilename), so a getter hook was enough and no
// container was needed. That runs out here: count-in genuinely WRITES `isPlaying`
// (it starts and stops playback) and `lastAudioTime`. `import { isPlaying }` then
// `isPlaying = true` throws — the binding cannot be assigned to.
//
// So the state moves onto an object. `S.isPlaying = true` is a property write, which
// works from any module holding the same `S`. This is the same shape the stems,
// studio, and editor migrations converged on.
//
// It is deliberately SMALL. app.js has ~104 top-level `let` scalars; lifting all of
// them would be a ~977-site rewrite for no benefit, since most are private to one
// cluster and travel with it. Only the ones a carved module must WRITE belong here.
// Add to it when a carve actually needs it, not before.
//
// NB app.js's own 71 reference sites were rewritten mechanically — but from the AST,
// not by text substitution. Of 100 textual occurrences of these two names, only 71
// resolve to the module binding: 22 are member accesses (`someObj.isPlaying`), 4 are
// the local parameter of setPlayButtonState(isPlaying), one is an object key, and two
// are shorthand properties (`{ isPlaying }`) that must become `{ isPlaying: S.isPlaying }`.
// A blind find-and-replace corrupts all 29.
export const S = {
/** Is the transport running? Written by playback, count-in, and the JUCE shims. */
isPlaying: false,
/**
* The last audio position we saw, in seconds. Used to detect a seek that did not
* land where it was asked to (JUCE can clamp; HTML5 can round).
*/
lastAudioTime: 0,
};
+5 -1
View File
@@ -84,7 +84,11 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
json: () => Promise.resolve({ path: '/local/song.ogg' }), json: () => Promise.resolve({ path: '/local/song.ogg' }),
}), }),
document: { hidden: false }, document: { hidden: false },
isPlaying: true, // `isPlaying` moved onto the shared player-state container so a carved module
// can WRITE it (an imported binding is read-only). The sliced code now reads and
// writes S.isPlaying, so the sandbox provides the same container — the
// assertions below are unchanged.
S: { isPlaying: true, lastAudioTime: 0 },
audio, audio,
jucePlayer, jucePlayer,
__calls: calls, __calls: calls,
+6 -6
View File
@@ -55,8 +55,10 @@ function buildSandbox() {
loopA: 10, loopA: 10,
loopB: 20, loopB: 20,
_countingIn: false, _countingIn: false,
isPlaying: false, // isPlaying / lastAudioTime moved onto the shared player-state container
lastAudioTime: 0, // (static/js/player-state.js) so a carved module can WRITE them — an imported
// binding is read-only. Same values, same assertions, one indirection.
S: { isPlaying: false, lastAudioTime: 0 },
// Browser-ish globals. // Browser-ish globals.
performance: { now: () => Date.now() }, performance: { now: () => Date.now() },
@@ -135,8 +137,7 @@ test('loop:restart fires once when wrap path runs', async () => {
var _countInGen = 0; var _countInGen = 0;
var _countInTimer = null; var _countInTimer = null;
var _countInRaf = 0; var _countInRaf = 0;
var isPlaying = false; var S = { isPlaying: false, lastAudioTime: 0 };
var lastAudioTime = 0;
${startCountInSrc} ${startCountInSrc}
globalThis.__startCountIn = startCountIn; globalThis.__startCountIn = startCountIn;
`; `;
@@ -180,8 +181,7 @@ test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async
var _countInGen = 0; var _countInGen = 0;
var _countInTimer = null; var _countInTimer = null;
var _countInRaf = 0; var _countInRaf = 0;
var isPlaying = false; var S = { isPlaying: false, lastAudioTime: 0 };
var lastAudioTime = 0;
${startCountInSrc} ${startCountInSrc}
globalThis.__startCountIn = startCountIn; globalThis.__startCountIn = startCountIn;
globalThis.__getCountingIn = () => _countingIn; globalThis.__getCountingIn = () => _countingIn;
+6 -3
View File
@@ -29,8 +29,11 @@ async function runTogglePlayRejecting({ rerouteInProgress }) {
const buttonStates = []; const buttonStates = [];
const sandbox = { const sandbox = {
console: { log() {}, warn() {}, error() {} }, console: { log() {}, warn() {}, error() {} },
// not-playing -> togglePlay takes the HTML5 play branch // not-playing -> togglePlay takes the HTML5 play branch.
isPlaying: false, // isPlaying / lastAudioTime moved onto the shared player-state container
// (static/js/player-state.js) so a carved module can WRITE them — an imported
// binding is read-only. Same values, same assertions, one indirection.
S: { isPlaying: false, lastAudioTime: 0 },
_audioSeekGen: 0, _audioSeekGen: 0,
_playAttemptGen: 0, _playAttemptGen: 0,
setPlayButtonState(v) { buttonStates.push(v); }, setPlayButtonState(v) { buttonStates.push(v); },
@@ -51,7 +54,7 @@ async function runTogglePlayRejecting({ rerouteInProgress }) {
vm.createContext(sandbox); vm.createContext(sandbox);
vm.runInContext(TOGGLE_PLAY_SRC, sandbox, { filename: 'app.js#togglePlay' }); vm.runInContext(TOGGLE_PLAY_SRC, sandbox, { filename: 'app.js#togglePlay' });
await vm.runInContext('togglePlay()', sandbox); await vm.runInContext('togglePlay()', sandbox);
return { buttonStates, isPlaying: sandbox.isPlaying }; return { buttonStates, isPlaying: sandbox.S.isPlaying };
} }
test('reroute-aborted play() leaves the button on Pause (isPlaying stays true)', async () => { test('reroute-aborted play() leaves the button on Pause (isPlaying stays true)', async () => {
+4 -1
View File
@@ -84,5 +84,8 @@ test('playback adapter suppresses duplicate HTML5 pause events before emitting c
const src = fs.readFileSync(APP_JS, 'utf8'); const src = fs.readFileSync(APP_JS, 'utf8');
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()'); const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/); // isPlaying moved onto the shared player-state container so a carved module can
// WRITE it (an imported binding is read-only). window.feedBack.isPlaying — the
// public mirror — is unchanged.
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*S\.isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
}); });
+5 -3
View File
@@ -19,7 +19,9 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
const sandbox = { const sandbox = {
loopA, loopA,
loopB, loopB,
isPlaying, // isPlaying moved onto the shared player-state container so a carved module can
// WRITE it (an imported binding is read-only). Same value, same assertions.
S: { isPlaying, lastAudioTime: 0 },
__cancelCountInCalls: 0, __cancelCountInCalls: 0,
__seekCalls: [], __seekCalls: [],
__startCountInCalls: [], __startCountInCalls: [],
@@ -42,7 +44,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
}, },
__togglePlay() { __togglePlay() {
sandbox.__togglePlayCalls++; sandbox.__togglePlayCalls++;
sandbox.isPlaying = true; sandbox.S.isPlaying = true;
return Promise.resolve(); return Promise.resolve();
}, },
}; };
@@ -53,7 +55,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
function loadRestart(sandbox, src, { audioSeekImpl } = {}) { function loadRestart(sandbox, src, { audioSeekImpl } = {}) {
const restartSrc = extractFunction(src, 'async function restartCurrentSong('); const restartSrc = extractFunction(src, 'async function restartCurrentSong(');
const code = ` const code = `
var isPlaying = ${sandbox.isPlaying}; var S = { isPlaying: ${sandbox.S.isPlaying}, lastAudioTime: 0 };
function _cancelCountIn() { __cancelCountInCalls++; } function _cancelCountIn() { __cancelCountInCalls++; }
async function _audioSeek(s, reason) { async function _audioSeek(s, reason) {
return (${audioSeekImpl || '__audioSeek'})(s, reason); return (${audioSeekImpl || '__audioSeek'})(s, reason);
+4 -1
View File
@@ -77,7 +77,10 @@ function loadFunctions(sandbox, src) {
// _audioSeek now syncs the jump-fix tracker so far seeks don't // _audioSeek now syncs the jump-fix tracker so far seeks don't
// trigger an immediate revert; declare it here so the sandbox // trigger an immediate revert; declare it here so the sandbox
// assignment lands on a real binding rather than an implicit global. // assignment lands on a real binding rather than an implicit global.
let lastAudioTime = 0; // lastAudioTime moved onto the shared player-state container
// (static/js/player-state.js) so a carved module can WRITE it — an imported
// binding is read-only. The sliced code writes S.lastAudioTime now.
let S = { isPlaying: false, lastAudioTime: 0 };
// _audioSeek wraps jucePlayer.seek in a timeout race; pull in the // _audioSeek wraps jucePlayer.seek in a timeout race; pull in the
// helper + constant. Tests can override jucePlayer.seek to vary // helper + constant. Tests can override jucePlayer.seek to vary
// behavior; the timeout (2 s) is well above any test setTimeout. // behavior; the timeout (2 s) is well above any test setTimeout.
+4 -1
View File
@@ -143,7 +143,10 @@ function loadPlaySong(sandbox) {
: ''; : '';
const code = ` const code = `
var artAbortController = null; var artAbortController = null;
var isPlaying = true; // isPlaying moved onto the shared player-state container so a carved module can
// WRITE it (an imported binding is read-only). NB window.feedBack.isPlaying — the
// public mirror stubbed above — is a different thing and is unchanged.
var S = { isPlaying: true, lastAudioTime: 0 };
var currentFilename = null; var currentFilename = null;
var _playerOriginScreen = null; var _playerOriginScreen = null;
var _pendingAutostart = false; var _pendingAutostart = false;
+3 -1
View File
@@ -186,7 +186,9 @@ def test_app_event_bus_dispatches_locally_and_preserves_juce_stop_state():
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8") source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
assert "this.dispatchEvent(new CustomEvent(event, { detail }))" in source assert "this.dispatchEvent(new CustomEvent(event, { detail }))" in source
assert "const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || isPlaying" in source # `isPlaying` moved onto the shared player-state container (static/js/player-state.js)
# so a carved module can WRITE it — an imported binding is read-only.
assert "const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || S.isPlaying" in source
assert "sm.emit('song:resume', payload)" in source assert "sm.emit('song:resume', payload)" in source
assert "window.feedBack.emit('song:resume', payload)" in source assert "window.feedBack.emit('song:resume', payload)" in source