Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,122 changes: 19 additions & 1,103 deletions static/app.js

Large diffs are not rendered by default.

994 changes: 994 additions & 0 deletions static/js/juce-audio.js

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions static/js/player-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
157 changes: 157 additions & 0 deletions static/js/resume-session.js
Original file line number Diff line number Diff line change
@@ -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');
Comment on lines +108 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== resume-session outline ==\n'
ast-grep outline static/js/resume-session.js --view expanded || true

printf '\n== resume-session lines 1-220 ==\n'
nl -ba static/js/resume-session.js | sed -n '1,220p'

printf '\n== search call sites ==\n'
rg -n "_maybeShowResumePill|screen:changed|decodeURIComponent\(snap\.f" static/js -S

Repository: got-feedBack/feedBack

Length of output: 757


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== resume-session slice ==\n'
sed -n '1,220p' static/js/resume-session.js | cat -n

printf '\n== call sites ==\n'
rg -n "_maybeShowResumePill|screen:changed|DOMContentLoaded" static/js -S

Repository: got-feedBack/feedBack

Length of output: 9077


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== search entire repo for call sites ==\n'
rg -n "_maybeShowResumePill|screen:changed|DOMContentLoaded|resume-session" . -S

printf '\n== search for try/catch around show/hide flow ==\n'
rg -n "try\s*\{|catch\s*\(|_maybeShowResumePill\(" . -S

Repository: got-feedBack/feedBack

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== app.js resume pill call site ==\n'
sed -n '4438,4459p' static/app.js | cat -n

printf '\n== decodeURIComponent edge case probe ==\n'
node - <<'JS'
for (const s of ['50% Done.mp3', 'good%20name.mp3', 'plain.mp3']) {
  try {
    console.log(JSON.stringify(s), '=>', decodeURIComponent(s));
  } catch (e) {
    console.log(JSON.stringify(s), '=> THROW', e.name + ': ' + e.message);
  }
}
JS

Repository: got-feedBack/feedBack

Length of output: 1545


Guard decodeURIComponent before rendering the resume pill. screen:changed calls _maybeShowResumePill() without a surrounding try/catch, so a saved filename containing a bare % (for example 50% Done.mp3) will throw URIError and skip the pill render.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/resume-session.js` around lines 108 - 119, Update
_maybeShowResumePill so decoding snap.f cannot throw when the saved filename
contains malformed percent encoding; guard the decodeURIComponent call and fall
back to the raw filename (or the existing default) before assigning label, while
preserving the current title preference and pill-rendering flow.

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);
}
18 changes: 15 additions & 3 deletions tests/js/juce_engine_reroute.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Behavioral tests for the JUCE engine-reroute watcher in static/app.js.
// Behavioral tests for the JUCE engine-reroute watcher in static/js/juce-audio.js.
//
// The watcher (an IIFE, `_installJuceEngineRoutingWatcher`) migrates a loaded
// song between the HTML5 <audio> element and the native JUCE backing transport
Expand All @@ -14,14 +14,15 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');

const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The JUCE audio shims were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'juce-audio.js');

// Brace-balanced extraction of the watcher IIFE, starting at its `(function`
// and ending after the matching `})();`.
function extractWatcherIIFE(src) {
const marker = '(function _installJuceEngineRoutingWatcher() {';
const start = src.indexOf(marker);
assert.ok(start !== -1, 'watcher IIFE not found in app.js');
assert.ok(start !== -1, 'watcher IIFE not found in static/js/juce-audio.js');
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
Expand Down Expand Up @@ -100,6 +101,17 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A

const src = fs.readFileSync(APP_JS, 'utf8');
const iife = extractWatcherIIFE(src);
// The shims reach back into app.js through the host seam (static/js/host.js).
// Route it at the SAME stubs this sandbox already had — a fresh `() => {}` would
// swallow the calls and the assertions below would pass vacuously.
sandbox.host = {
jucePlayer: () => sandbox.jucePlayer,
playSong: (...a) => (sandbox.playSong ? sandbox.playSong(...a) : undefined),
_audioSeek: (...a) => (sandbox._audioSeek ? sandbox._audioSeek(...a) : Promise.resolve({ completed: true })),
setPlayButtonState: (...a) => (sandbox.setPlayButtonState ? sandbox.setPlayButtonState(...a) : undefined),
_songEventPayload: (...a) => (sandbox._songEventPayload ? sandbox._songEventPayload(...a) : ({})),
showScreen: (...a) => (sandbox.showScreen ? sandbox.showScreen(...a) : undefined),
};
vm.createContext(sandbox);
vm.runInContext(iife, sandbox);
return sandbox;
Expand Down
18 changes: 15 additions & 3 deletions tests/js/renderer_bus_feeder.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
// Behavioral tests for the renderer-audio bus feeder in static/js/juce-audio.js.
//
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
Expand All @@ -16,12 +16,13 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');

const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The JUCE audio shims were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'juce-audio.js');

function extractFeederIIFE(src) {
const marker = '(function _installRendererBusFeeder() {';
const start = src.indexOf(marker);
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
assert.ok(start !== -1, 'feeder IIFE not found in static/js/juce-audio.js');
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
Expand Down Expand Up @@ -123,6 +124,17 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, disp
sandbox.globalThis = sandbox;

const src = fs.readFileSync(APP_JS, 'utf8');
// The shims reach back into app.js through the host seam (static/js/host.js).
// Route it at the SAME stubs this sandbox already had — a fresh `() => {}` would
// swallow the calls and the assertions below would pass vacuously.
sandbox.host = {
jucePlayer: () => sandbox.jucePlayer,
playSong: (...a) => (sandbox.playSong ? sandbox.playSong(...a) : undefined),
_audioSeek: (...a) => (sandbox._audioSeek ? sandbox._audioSeek(...a) : Promise.resolve({ completed: true })),
setPlayButtonState: (...a) => (sandbox.setPlayButtonState ? sandbox.setPlayButtonState(...a) : undefined),
_songEventPayload: (...a) => (sandbox._songEventPayload ? sandbox._songEventPayload(...a) : ({})),
showScreen: (...a) => (sandbox.showScreen ? sandbox.showScreen(...a) : undefined),
};
vm.createContext(sandbox);
vm.runInContext(extractFeederIIFE(src), sandbox);
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
Expand Down
6 changes: 5 additions & 1 deletion tests/test_plugin_runtime_idempotence.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,13 @@ def test_app_event_bus_dispatches_locally_and_preserves_juce_stop_state():
# `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 "window.feedBack.emit('song:resume', payload)" in source

# The JUCE audio-element shim — which re-emits song:resume through the session
# manager when JUCE owns the transport — was carved out into its own module (R3a).
juce = (ROOT / "static" / "js" / "juce-audio.js").read_text(encoding="utf-8")
assert "sm.emit('song:resume', payload)" in juce


def test_nam_and_stems_use_owner_claim_dispatch_semantics():
nam_source = _sibling_text("feedBack-plugin-nam-tone", "screen.js", "NAM_STEM_CLAIM_ID = 'nam.amp-active'")
Expand Down
Loading