mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 01:14:30 +00:00
refactor(app): carve resume-session out of app.js (R3a)
static/js/resume-session.js (157) — the snapshot taken when you leave a song and the
pill that offers it back. Bodies VERBATIM. app.js 7,727 → 7,601.
Fifth slice out of the strongly-connected core. ONE hook (playSong) + a
currentFilename getter.
S.pendingResume JOINS THE CONTAINER — on demand, exactly as intended. app.js WRITES
it (playSong({ resume }) arms it; the song:ready listener consumes it) while this
module reads it, so it cannot be a plain export: an imported binding is read-only.
Same reason isPlaying is there. The container grows one field per carve that needs
it, never speculatively.
THE CONTRACT TEST CAUGHT THE MISSING HOOK, again on a path nothing executes:
"playSong is read by a module but never wired by app.js — it would throw at runtime".
Second time it has caught a real wiring gap the moment it appeared.
A REAL TRAP, worth remembering: I first did the S.pendingResume rewrite by feeding
acorn's identifier RANGES from node into python, and it corrupted the file
(`_pS.pendingResume null;`). **Acorn's offsets are UTF-16 code units; Python's string
indices are code points.** static/app.js contains emoji, so every offset past one
drifts. Do an AST-driven rewrite in the SAME language that produced the offsets.
`node --check` caught it; a silent version of that bug is very easy to imagine.
VERIFIED. A/B against origin/main in two browsers, real song: the window API
(resumeLastSession / _snapshotResumeSession / _readResumeSession /
_clearResumeSession), snapshot, read-back, and clear — IDENTICAL, zero page errors.
HONEST LIMIT: my probe never got the snapshot to actually PERSIST (there is a guard
beyond the 3s minimum position that a scripted playSong does not satisfy), so that
path is verified only as identical-to-main, not as observed-working. The real
coverage is tests/browser/resume-session.spec.ts, which drives the flow properly.
Zero harnesses broke. pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean),
tailwind clean, Codex 0.
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
dc429ecd16
commit
c295c1993d
+14
-139
@@ -41,6 +41,15 @@ import {
|
||||
} from './js/settings-io.js';
|
||||
import { audio } from './js/audio-el.js';
|
||||
import { S } from './js/player-state.js';
|
||||
import {
|
||||
_clearResumeSession,
|
||||
_hideResumePill,
|
||||
_maybeShowResumePill,
|
||||
_readResumeSession,
|
||||
_snapshotResumeSession,
|
||||
resumeLastSession,
|
||||
} from './js/resume-session.js';
|
||||
|
||||
import {
|
||||
_applyMastery,
|
||||
_applyMasteryAvailability,
|
||||
@@ -5353,87 +5362,11 @@ window.feedBack.on('song:ready', () => {
|
||||
else holdCreditsThen(start);
|
||||
});
|
||||
|
||||
// ── Resume last session ────────────────────────────────────────────────────
|
||||
// Leaving a song snapshots where you were — song, arrangement, position, and
|
||||
// speed — so an exit (especially an accidental one, now that Escape reliably
|
||||
// leaves regardless of focus) is recoverable instead of restarting from bar 1.
|
||||
// The snapshot is offered back through a non-blocking "Resume" pill; it never
|
||||
// gates, blocks, or auto-acts. Cleared on natural song-end and once consumed.
|
||||
// (This is the player-session slice; the broader nav/state-resume work — e.g.
|
||||
// returning to a song after wandering into Settings → Tone Builder — is a
|
||||
// separate, larger track.)
|
||||
const _RESUME_KEY = 'feedBack.resumeSession';
|
||||
const _RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000; // a day-old snapshot is stale
|
||||
const _RESUME_MIN_POSITION_S = 3; // ignore barely-started songs
|
||||
const _RESUME_END_GUARD_S = 5; // ignore basically-finished songs
|
||||
let _pendingResume = null; // {position, speed}, consumed at song:ready
|
||||
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
|
||||
|
||||
|
||||
// Snapshot the live session. Called from showScreen()'s teardown before
|
||||
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||
function _snapshotResumeSession(position) {
|
||||
try {
|
||||
if (!currentFilename) return;
|
||||
const si = (window.highway && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const dur = Number(si.duration) || 0;
|
||||
const pos = Number(position) || 0;
|
||||
// Only worth resuming a song you were genuinely mid-way through — not a
|
||||
// glance at the first seconds, and not one that already basically ended.
|
||||
if (pos < _RESUME_MIN_POSITION_S) { _clearResumeSession(); return; }
|
||||
if (dur && pos > dur - _RESUME_END_GUARD_S) { _clearResumeSession(); return; }
|
||||
const snap = {
|
||||
f: currentFilename,
|
||||
a: (typeof si.arrangement_index === 'number' && si.arrangement_index >= 0)
|
||||
? si.arrangement_index : undefined,
|
||||
t: pos,
|
||||
sp: _curPlaybackSpeed(),
|
||||
title: si.title || '',
|
||||
artist: si.artist || '',
|
||||
ts: Date.now(),
|
||||
};
|
||||
localStorage.setItem(_RESUME_KEY, JSON.stringify(snap));
|
||||
// A fresh snapshot earns one offer — undo any earlier dismissal.
|
||||
_resumePillDismissed = false;
|
||||
} catch (_) { /* storage unavailable — resume is best-effort */ }
|
||||
}
|
||||
|
||||
function _readResumeSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(_RESUME_KEY);
|
||||
if (!raw) return null;
|
||||
const snap = JSON.parse(raw);
|
||||
if (!snap || !snap.f || !(Number(snap.t) > 0)) return null;
|
||||
if (!snap.ts || Date.now() - snap.ts > _RESUME_MAX_AGE_MS) { _clearResumeSession(); return null; }
|
||||
return snap;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function _clearResumeSession() {
|
||||
try { localStorage.removeItem(_RESUME_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
// Re-enter the snapshotted song and restore arrangement + position + speed.
|
||||
async function resumeLastSession() {
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return false; }
|
||||
_hideResumePill();
|
||||
try {
|
||||
await playSong(snap.f, snap.a, {
|
||||
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
// A transient load/connect failure must not strand the user: keep the
|
||||
// snapshot so the pill can re-offer it on the next non-player screen,
|
||||
// rather than consuming the only copy before the song actually loaded.
|
||||
console.warn('[app] resume failed to load; keeping snapshot:', err);
|
||||
_pendingResume = null;
|
||||
return false;
|
||||
}
|
||||
_clearResumeSession(); // consumed only after a successful load
|
||||
return true;
|
||||
}
|
||||
window.resumeLastSession = resumeLastSession;
|
||||
if (window.feedBack) window.feedBack.resumeLastSession = resumeLastSession;
|
||||
|
||||
@@ -5441,9 +5374,9 @@ if (window.feedBack) window.feedBack.resumeLastSession = resumeLastSession;
|
||||
// saved position, then (if autoplay is on) start from there. playSong() does
|
||||
// NOT arm autostart for a resume load, so the two never fight over playback.
|
||||
window.feedBack.on('song:ready', () => {
|
||||
const pend = _pendingResume;
|
||||
const pend = S.pendingResume;
|
||||
if (!pend) return;
|
||||
_pendingResume = null;
|
||||
S.pendingResume = null;
|
||||
try {
|
||||
if (pend.speed && pend.speed > 0) {
|
||||
const slider = document.getElementById('speed-slider');
|
||||
@@ -5460,66 +5393,7 @@ window.feedBack.on('song:ready', () => {
|
||||
// offer "resume" for a song the user just completed.
|
||||
window.feedBack.on('song:ended', _clearResumeSession);
|
||||
|
||||
// ── Resume pill (non-blocking "continue where you left off") ────────────────
|
||||
// Self-contained, inline-styled, body-appended so it works identically in the
|
||||
// classic (v2) and v3 shells with no Tailwind rebuild. It only ever appears off
|
||||
// the player screen, never blocks, and a dismiss forgets the current snapshot
|
||||
// for the session.
|
||||
function _hideResumePill() {
|
||||
const el = document.getElementById('fb-resume-pill');
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
function _maybeShowResumePill() {
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'player') { _hideResumePill(); return; }
|
||||
if (_resumePillDismissed) return;
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return; }
|
||||
if (document.getElementById('fb-resume-pill')) return; // already shown
|
||||
|
||||
const label = (snap.title || decodeURIComponent(snap.f || 'your last song')).toString();
|
||||
const pill = document.createElement('div');
|
||||
pill.id = 'fb-resume-pill';
|
||||
pill.setAttribute('role', 'status');
|
||||
pill.style.cssText = [
|
||||
'position:fixed', 'left:16px', 'bottom:16px', 'z-index:120',
|
||||
'display:flex', 'align-items:center', 'gap:10px',
|
||||
'max-width:min(90vw,360px)', 'padding:10px 12px',
|
||||
'background:rgba(17,24,39,0.96)', 'color:#e5e7eb',
|
||||
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:10px',
|
||||
'box-shadow:0 6px 24px rgba(0,0,0,0.4)',
|
||||
'font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
|
||||
].join(';');
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.style.cssText = 'flex:1;min-width:0';
|
||||
const t1 = document.createElement('div');
|
||||
t1.textContent = 'Resume practice';
|
||||
t1.style.cssText = 'font-weight:600;color:#fff';
|
||||
const t2 = document.createElement('div');
|
||||
t2.textContent = label;
|
||||
t2.style.cssText = 'opacity:0.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
|
||||
text.appendChild(t1); text.appendChild(t2);
|
||||
|
||||
const resumeBtn = document.createElement('button');
|
||||
resumeBtn.type = 'button';
|
||||
resumeBtn.textContent = 'Resume ▸';
|
||||
resumeBtn.style.cssText = 'flex:none;padding:6px 10px;border:0;border-radius:7px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
|
||||
resumeBtn.addEventListener('click', () => { resumeLastSession(); });
|
||||
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.type = 'button';
|
||||
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
||||
dismissBtn.textContent = '✕';
|
||||
dismissBtn.style.cssText = 'flex:none;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:#9ca3af;cursor:pointer;font-size:14px';
|
||||
dismissBtn.addEventListener('click', () => { _resumePillDismissed = true; _hideResumePill(); });
|
||||
|
||||
pill.appendChild(text);
|
||||
pill.appendChild(resumeBtn);
|
||||
pill.appendChild(dismissBtn);
|
||||
(document.body || document.documentElement).appendChild(pill);
|
||||
}
|
||||
if (window.feedBack) window.feedBack._maybeShowResumePill = _maybeShowResumePill;
|
||||
|
||||
// Exposed for tests/debugging (mirrors window._panels / _getCurrentContext).
|
||||
@@ -5709,10 +5583,10 @@ async function playSong(filename, arrangement, options) {
|
||||
// the saved position, then start — so autostart and resume don't both try
|
||||
// to begin playback from different positions.
|
||||
if (options && options.resume && Number(options.resume.position) > 0) {
|
||||
_pendingResume = options.resume;
|
||||
S.pendingResume = options.resume;
|
||||
_pendingAutostart = false;
|
||||
} else {
|
||||
_pendingResume = null;
|
||||
S.pendingResume = null;
|
||||
_pendingAutostart = true;
|
||||
}
|
||||
_clearAutoExit();
|
||||
@@ -7674,6 +7548,7 @@ configureHost({
|
||||
_songEventPayload,
|
||||
togglePlay,
|
||||
handleSliderInput,
|
||||
playSong,
|
||||
// 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.
|
||||
startCountIn,
|
||||
|
||||
@@ -31,4 +31,12 @@ export const S = {
|
||||
* land where it was asked to (JUCE can clamp; HTML5 can round).
|
||||
*/
|
||||
lastAudioTime: 0,
|
||||
|
||||
/**
|
||||
* A resume request armed by playSong({ resume }) and consumed on song:ready.
|
||||
* Written by app.js (playSong, and the song:ready listener that consumes it) and
|
||||
* read by the resume-session module — so, like the two above, it cannot be a plain
|
||||
* export.
|
||||
*/
|
||||
pendingResume: null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// Resume last session — the snapshot taken when you leave a song, and the pill that
|
||||
// offers it back.
|
||||
//
|
||||
// The fifth slice out of app.js's strongly-connected core. Small and self-contained:
|
||||
// ONE hook (playSong) plus a currentFilename getter.
|
||||
//
|
||||
// The armed resume request itself lives on the shared container as S.pendingResume,
|
||||
// not here, because app.js WRITES it — playSong({ resume }) arms it and the song:ready
|
||||
// listener consumes it — while this module reads it. An imported binding is read-only,
|
||||
// so shared mutable state has to live on the container. Same reason isPlaying does.
|
||||
//
|
||||
// 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 { host } from './host.js';
|
||||
import { _curPlaybackSpeed } from './player-controls.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
// ── Resume last session ────────────────────────────────────────────────────
|
||||
// Leaving a song snapshots where you were — song, arrangement, position, and
|
||||
// speed — so an exit (especially an accidental one, now that Escape reliably
|
||||
// leaves regardless of focus) is recoverable instead of restarting from bar 1.
|
||||
// The snapshot is offered back through a non-blocking "Resume" pill; it never
|
||||
// gates, blocks, or auto-acts. Cleared on natural song-end and once consumed.
|
||||
// (This is the player-session slice; the broader nav/state-resume work — e.g.
|
||||
// returning to a song after wandering into Settings → Tone Builder — is a
|
||||
// separate, larger track.)
|
||||
const _RESUME_KEY = 'feedBack.resumeSession';
|
||||
const _RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000; // a day-old snapshot is stale
|
||||
const _RESUME_MIN_POSITION_S = 3; // ignore barely-started songs
|
||||
const _RESUME_END_GUARD_S = 5; // ignore basically-finished songs
|
||||
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
|
||||
|
||||
// Snapshot the live session. Called from showScreen()'s teardown before
|
||||
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||
export function _snapshotResumeSession(position) {
|
||||
try {
|
||||
if (!host.currentFilename()) return;
|
||||
const si = (window.highway && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const dur = Number(si.duration) || 0;
|
||||
const pos = Number(position) || 0;
|
||||
// Only worth resuming a song you were genuinely mid-way through — not a
|
||||
// glance at the first seconds, and not one that already basically ended.
|
||||
if (pos < _RESUME_MIN_POSITION_S) { _clearResumeSession(); return; }
|
||||
if (dur && pos > dur - _RESUME_END_GUARD_S) { _clearResumeSession(); return; }
|
||||
const snap = {
|
||||
f: host.currentFilename(),
|
||||
a: (typeof si.arrangement_index === 'number' && si.arrangement_index >= 0)
|
||||
? si.arrangement_index : undefined,
|
||||
t: pos,
|
||||
sp: _curPlaybackSpeed(),
|
||||
title: si.title || '',
|
||||
artist: si.artist || '',
|
||||
ts: Date.now(),
|
||||
};
|
||||
localStorage.setItem(_RESUME_KEY, JSON.stringify(snap));
|
||||
// A fresh snapshot earns one offer — undo any earlier dismissal.
|
||||
_resumePillDismissed = false;
|
||||
} catch (_) { /* storage unavailable — resume is best-effort */ }
|
||||
}
|
||||
|
||||
export function _readResumeSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(_RESUME_KEY);
|
||||
if (!raw) return null;
|
||||
const snap = JSON.parse(raw);
|
||||
if (!snap || !snap.f || !(Number(snap.t) > 0)) return null;
|
||||
if (!snap.ts || Date.now() - snap.ts > _RESUME_MAX_AGE_MS) { _clearResumeSession(); return null; }
|
||||
return snap;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
export function _clearResumeSession() {
|
||||
try { localStorage.removeItem(_RESUME_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
// Re-enter the snapshotted song and restore arrangement + position + speed.
|
||||
export async function resumeLastSession() {
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return false; }
|
||||
_hideResumePill();
|
||||
try {
|
||||
await host.playSong(snap.f, snap.a, {
|
||||
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
// A transient load/connect failure must not strand the user: keep the
|
||||
// snapshot so the pill can re-offer it on the next non-player screen,
|
||||
// rather than consuming the only copy before the song actually loaded.
|
||||
console.warn('[app] resume failed to load; keeping snapshot:', err);
|
||||
S.pendingResume = null;
|
||||
return false;
|
||||
}
|
||||
_clearResumeSession(); // consumed only after a successful load
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Resume pill (non-blocking "continue where you left off") ────────────────
|
||||
// Self-contained, inline-styled, body-appended so it works identically in the
|
||||
// classic (v2) and v3 shells with no Tailwind rebuild. It only ever appears off
|
||||
// the player screen, never blocks, and a dismiss forgets the current snapshot
|
||||
// for the session.
|
||||
export function _hideResumePill() {
|
||||
const el = document.getElementById('fb-resume-pill');
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
export function _maybeShowResumePill() {
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'player') { _hideResumePill(); return; }
|
||||
if (_resumePillDismissed) return;
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return; }
|
||||
if (document.getElementById('fb-resume-pill')) return; // already shown
|
||||
|
||||
const label = (snap.title || decodeURIComponent(snap.f || 'your last song')).toString();
|
||||
const pill = document.createElement('div');
|
||||
pill.id = 'fb-resume-pill';
|
||||
pill.setAttribute('role', 'status');
|
||||
pill.style.cssText = [
|
||||
'position:fixed', 'left:16px', 'bottom:16px', 'z-index:120',
|
||||
'display:flex', 'align-items:center', 'gap:10px',
|
||||
'max-width:min(90vw,360px)', 'padding:10px 12px',
|
||||
'background:rgba(17,24,39,0.96)', 'color:#e5e7eb',
|
||||
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:10px',
|
||||
'box-shadow:0 6px 24px rgba(0,0,0,0.4)',
|
||||
'font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
|
||||
].join(';');
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.style.cssText = 'flex:1;min-width:0';
|
||||
const t1 = document.createElement('div');
|
||||
t1.textContent = 'Resume practice';
|
||||
t1.style.cssText = 'font-weight:600;color:#fff';
|
||||
const t2 = document.createElement('div');
|
||||
t2.textContent = label;
|
||||
t2.style.cssText = 'opacity:0.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
|
||||
text.appendChild(t1); text.appendChild(t2);
|
||||
|
||||
const resumeBtn = document.createElement('button');
|
||||
resumeBtn.type = 'button';
|
||||
resumeBtn.textContent = 'Resume ▸';
|
||||
resumeBtn.style.cssText = 'flex:none;padding:6px 10px;border:0;border-radius:7px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
|
||||
resumeBtn.addEventListener('click', () => { resumeLastSession(); });
|
||||
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.type = 'button';
|
||||
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
||||
dismissBtn.textContent = '✕';
|
||||
dismissBtn.style.cssText = 'flex:none;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:#9ca3af;cursor:pointer;font-size:14px';
|
||||
dismissBtn.addEventListener('click', () => { _resumePillDismissed = true; _hideResumePill(); });
|
||||
|
||||
pill.appendChild(text);
|
||||
pill.appendChild(resumeBtn);
|
||||
pill.appendChild(dismissBtn);
|
||||
(document.body || document.documentElement).appendChild(pill);
|
||||
}
|
||||
Reference in New Issue
Block a user