diff --git a/static/app.js b/static/app.js index 16f77f45..3af2c823 100644 --- a/static/app.js +++ b/static/app.js @@ -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, diff --git a/static/js/player-state.js b/static/js/player-state.js index c708e027..793c0428 100644 --- a/static/js/player-state.js +++ b/static/js/player-state.js @@ -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, }; diff --git a/static/js/resume-session.js b/static/js/resume-session.js new file mode 100644 index 00000000..5e15d37c --- /dev/null +++ b/static/js/resume-session.js @@ -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); +}