feat(player): global autoplay & auto-exit option (songs + lessons) (#558)

* fix(v3): pedal click opens the plugin's screen, not its settings

The v3 Pedalboard's settingsTarget() resolved settings-first, so a
plugin that ships both a screen and a settings panel (notably the
bundled Audio Engine) could only ever reach its settings from the
pedalboard — its actual page was unreachable.

Flip to screen-first (stompbox metaphor: step on the pedal, see the
pedal), falling back to settings when there is no screen. Keep a
settings fallback in openPluginSettings() when a declared screen
isn't mounted yet (installing/failed) so settings-bearing plugins are
never stranded on a toast. Drive the pedal aria-label off the same
target so it never promises the wrong surface. Update the unit test
contract to screen > settings > none.

Fixes #555

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(player): global autoplay & auto-exit option (songs + lessons)

Single Settings toggle (autoplayExit, default ON) that auto-starts a song
once it's ready and returns to the launching menu when it ends. Auto-exit
defers while a results/score overlay is on top (heuristic + holdAutoExit()
contract) so a scoring plugin's screen drives the exit. Player origin is now
context-aware (lessons return to the lessons screen via setReturnScreen()),
fixing lesson completion bouncing to the library.

Core-only; songs and lessons share the playSong -> highway path. Adds a
read-only window.slopsmith.autoplayExit getter + holdAutoExit()/setReturnScreen()
for plugins. Unit tests for the pure helpers (_autoplayExitEnabled,
_resolvePlayerOrigin, _resultsOverlayVisible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos 2026-06-22 11:04:03 +02:00 committed by GitHub
parent f79efe2516
commit 4dc5936712
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 369 additions and 14 deletions

View File

@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.slopsmith.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.slopsmith.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.slopsmith.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff.
- **"Song Editor" promoted to a first-class v3 sidebar item.** The editor
plugin (`id: editor`) now gets its own dedicated sidebar entry — under the
Library group, just below Songs — via the existing `PROMOTED_PLUGINS`

View File

@ -3239,6 +3239,8 @@ async function loadSettings() {
document.getElementById('demucs-server-url').value = data.demucs_server_url || '';
const leftyEl = document.getElementById('setting-lefty');
if (leftyEl) leftyEl.checked = highway.getLefty();
const autoplayExitEl = document.getElementById('setting-autoplay-exit');
if (autoplayExitEl) autoplayExitEl.checked = _autoplayExitEnabled();
// Restore master-difficulty slider from persisted value (defaults
// to 100 when the key is absent — no behaviour change for users
// who've never touched the slider).
@ -5693,6 +5695,135 @@ document.addEventListener('visibilitychange', () => {
}
});
// ── Autoplay & auto-exit (global option, default ON) ──────────────────
// One toggle (`autoplayExit` in localStorage) that (a) auto-starts a song
// once it's ready and (b) returns to the launching menu when the song
// ends. Absence of the key means enabled. The behaviour lives in core
// (app.js, shared by the v3 + classic UIs); the end-of-song *score*
// screen, when present, is a plugin and hooks the contract below.
function _autoplayExitEnabled() {
try { return localStorage.getItem('autoplayExit') !== '0'; } catch (_) { return true; }
}
// Settings checkbox setter (onchange="setAutoplayExit(this.checked)").
window.setAutoplayExit = function (on) {
try { localStorage.setItem('autoplayExit', on ? '1' : '0'); } catch (_) { /* private mode */ }
const el = document.getElementById('setting-autoplay-exit');
if (el && el.checked !== !!on) el.checked = !!on;
};
// Read-only view for plugins (e.g. a scoring plugin deciding whether to
// auto-return after its results screen closes).
Object.defineProperty(window.slopsmith, 'autoplayExit', {
get: _autoplayExitEnabled, configurable: true,
});
// One-shot launcher override for the player's return destination.
window.slopsmith.setReturnScreen = function (id) {
window.slopsmith._nextReturnScreen = id || null;
};
// Resolve where the player should return on Esc / close / auto-exit.
// A one-shot setReturnScreen() override wins (consumed here) — used by the
// lessons catalog so a lesson returns to the lessons screen rather than the
// library, even though the external tutorials plugin owns the playSong call.
// Otherwise remember the actual launch screen; the element-exists guard
// keeps the classic v2 UI (no #v3-* ids) from being stranded on a missing
// screen, and unknown launches fall back to 'home'. The dashboard — classic
// 'home' and the v3 shell's 'v3-home' — returns to the Songs list when it
// exists (dashboard actions call playSong() directly, so its id is the
// active screen at launch).
function _resolvePlayerOrigin() {
const override = window.slopsmith && window.slopsmith._nextReturnScreen;
if (window.slopsmith) window.slopsmith._nextReturnScreen = null;
if (override && document.getElementById(override)) return override;
const launchFrom = document.querySelector('.screen.active');
const launchId = launchFrom && launchFrom.id;
if (launchId && launchId !== 'player' && document.getElementById(launchId)) {
return ((launchId === 'home' || launchId === 'v3-home') && document.getElementById('v3-songs'))
? 'v3-songs' : launchId;
}
return 'home';
}
// Autoplay: one-shot flag armed by each fresh playSong(), consumed by the
// next song:ready. song:ready also fires on arrangement switches / seeks,
// which never arm the flag, so those don't auto-restart.
let _pendingAutostart = false;
window.slopsmith.on('song:ready', () => {
if (!_pendingAutostart) return;
_pendingAutostart = false;
if (!_autoplayExitEnabled() || isPlaying) return;
// Reuse the Play button's start path (handles HTML5 + _juceMode + count-in).
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
});
// Auto-exit: when the song ends, return to the launching menu. A scoring
// plugin that shows an end-of-song results screen calls holdAutoExit() to
// defer this; the user closing that screen (its Close button calls
// window.closeCurrentSong()) performs the exit. With no results screen the
// grace timer returns to the menu on its own.
const AUTO_EXIT_GRACE_MS = 1500;
let _autoExitTimer = null;
let _autoExitHeld = false;
// Bumped every time the auto-exit state is reset (new song via playSong, and
// each song:ended). A hold's release() captures the generation at hold time
// and no-ops once it changes, so a plugin that drops or fires its release
// handle after the player has moved on can never navigate a fresh session —
// callers don't need to balance the handle.
let _autoExitGen = 0;
function _clearAutoExit() {
if (_autoExitTimer) { clearTimeout(_autoExitTimer); _autoExitTimer = null; }
_autoExitHeld = false;
_autoExitGen++;
}
// Heuristic safety net for score-screen plugins that don't (yet) call
// holdAutoExit(): if a visible full-screen results/dialog overlay is on top
// when the grace timer fires, defer the auto-return and let that screen's
// own close button drive the exit (its Close should call closeCurrentSong).
// getClientRects() is used for the visibility test because it reports
// position:fixed overlays correctly, unlike offsetParent.
function _resultsOverlayVisible() {
let nodes;
try {
nodes = document.querySelectorAll('[role="dialog"][aria-modal="true"], .fixed.inset-0');
} catch (_) { return false; }
for (const el of nodes) {
if (!el || el.id === 'player') continue; // never the player itself
if (el.classList && el.classList.contains('hidden')) continue;
if (el.getClientRects && el.getClientRects().length > 0) return true;
}
return false;
}
// Plugins call this synchronously from their own song:ended handler (core
// runs first, so the timer is already pending) to claim the exit.
window.slopsmith.holdAutoExit = function () {
if (_autoExitTimer) { clearTimeout(_autoExitTimer); _autoExitTimer = null; }
_autoExitHeld = true;
const gen = _autoExitGen;
let released = false;
return function release() {
// No-op once released, or once the session has moved on (a newer
// playSong / song:ended bumped the generation) — so a stale handle
// never navigates away from a fresh song.
if (released || gen !== _autoExitGen) return;
released = true;
if (typeof window.closeCurrentSong === 'function') window.closeCurrentSong();
};
};
window.slopsmith.on('song:ended', () => {
_clearAutoExit();
if (!_autoplayExitEnabled()) return;
// Only auto-exit from the player screen (ignore stale/duplicate ends).
const active = document.querySelector('.screen.active');
if (!active || active.id !== 'player') return;
_autoExitTimer = setTimeout(() => {
_autoExitTimer = null;
if (_autoExitHeld) return; // a plugin explicitly claimed the exit
if (_resultsOverlayVisible()) return; // a score/results overlay is up; let it drive the exit
const cur = document.querySelector('.screen.active');
if (cur && cur.id === 'player' && typeof window.closeCurrentSong === 'function') {
window.closeCurrentSong();
}
}, AUTO_EXIT_GRACE_MS);
});
// Abort controller for cancelling pending requests when entering player
let artAbortController = null;
@ -5750,21 +5881,14 @@ async function playSong(filename, arrangement, options) {
_hideSectionPracticeBar();
currentFilename = filename;
// A fresh load arms autoplay; a pending auto-exit from the previous
// song is no longer relevant.
_pendingAutostart = true;
_clearAutoExit();
// Remember which screen the player was launched from so Esc /
// navigation back from the player returns the user there
// (slopsmith#126). Falls back to 'home' if launched from
// somewhere unexpected (settings, a plugin screen, etc.).
const _launchFrom = document.querySelector('.screen.active');
const _launchId = _launchFrom && _launchFrom.id;
const _origin = (_launchId === 'v3-songs' || _launchId === 'home' || _launchId === 'favorites')
? _launchId : 'home';
// In the v3 shell, `home` launches return to the v3 Songs screen. But this
// file is shared with the classic v2 UI, where #v3-songs does not exist —
// remapping there would make Esc call showScreen('v3-songs'), which throws
// on the missing element and strands the user on a blank screen. Only remap
// when the target screen is actually present.
_playerOriginScreen = (_origin === 'home' && document.getElementById('v3-songs'))
? 'v3-songs' : _origin;
// navigation back from the player (and auto-exit) returns the user
// there (slopsmith#126).
_playerOriginScreen = _resolvePlayerOrigin();
showScreen('player');
// Wait for previous WebSocket to fully close before opening new one

View File

@ -299,6 +299,13 @@
<span class="text-sm text-gray-300">Left-handed <span class="text-gray-500">(invert frets on the note highway)</span></span>
</label>
</div>
<div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" id="setting-autoplay-exit" checked onchange="setAutoplayExit(this.checked)"
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
<span class="text-sm text-gray-300">Autoplay &amp; auto-exit <span class="text-gray-500">(start songs/lessons automatically and return to the menu when the score screen closes)</span></span>
</label>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Default Arrangement</label>
<select id="default-arrangement"

View File

@ -402,6 +402,13 @@
<span class="text-sm text-gray-300">Left-handed <span class="text-gray-500">(invert frets on the note highway)</span></span>
</label>
</div>
<div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" id="setting-autoplay-exit" checked onchange="setAutoplayExit(this.checked)"
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
<span class="text-sm text-gray-300">Autoplay &amp; auto-exit <span class="text-gray-500">(start songs/lessons automatically and return to the menu when the score screen closes)</span></span>
</label>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Default Arrangement</label>
<select id="default-arrangement"

View File

@ -218,6 +218,33 @@
// ── navigation into the plugin's own lesson view ─────────────────────---
function launchLesson(packId, lessonId) {
if (!packId || !lessonId) return;
// When the lesson's song auto-exits (or the player is closed), return
// to the lessons catalog to pick the next one — not the song library.
// playSong() consumes this one-shot override even though the external
// tutorials plugin owns the actual playSong call. The legitimate path
// is lessons → plugin-tutorials → player; if the launch is abandoned
// (plugin missing, deep-link fails, user backs out) the override would
// otherwise linger and mis-route the next library song. So clear it on
// the first navigation that leaves the launch flow (any screen other
// than the tutorials waypoint or the player that consumes it).
try {
if (window.slopsmith && typeof window.slopsmith.setReturnScreen === 'function') {
window.slopsmith.setReturnScreen('v3-lessons');
if (sm && typeof sm.on === 'function' && typeof sm.off === 'function') {
const clearStale = (e) => {
const id = e && e.detail && e.detail.id;
if (id === 'plugin-tutorials') return; // expected waypoint — keep waiting
// 'player' means playSong already consumed the override;
// anything else means the launch was abandoned.
if (id !== 'player' && window.slopsmith._nextReturnScreen === 'v3-lessons') {
window.slopsmith.setReturnScreen(null);
}
sm.off('screen:changed', clearStale);
};
sm.on('screen:changed', clearStale);
}
}
} catch (_e) { /* non-fatal */ }
// navigate() stores nav params AND calls showScreen(); the tutorials
// plugin's init() reads getNavParams() to deep-link to {packId, lessonId}.
if (sm && typeof sm.navigate === 'function') {

View File

@ -0,0 +1,186 @@
// Verify the autoplay & auto-exit option's pure decision helpers in app.js:
// - _autoplayExitEnabled() (localStorage; absence = enabled)
// - _resolvePlayerOrigin() (one-shot override → launch screen → 'home')
//
// Same isolation strategy as song_close.test.js — extract the function from
// app.js by brace-matching and run it in a vm sandbox with stubbed deps.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const SRC = fs.readFileSync(APP_JS, 'utf8');
function runEnabled(stored) {
const fnSrc = extractFunction(SRC, 'function _autoplayExitEnabled(');
const sandbox = {
localStorage: {
getItem: () => {
if (stored === '__throw__') throw new Error('private mode');
return stored;
},
},
};
vm.createContext(sandbox);
vm.runInContext(fnSrc + '\nglobalThis.__r = _autoplayExitEnabled();', sandbox);
return sandbox.__r;
}
// Fake element honoring the bits _resultsOverlayVisible() inspects.
function el({ id = '', hidden = false, visible = true } = {}) {
return {
id,
classList: { contains: (c) => c === 'hidden' && hidden },
getClientRects: () => (visible ? [{}] : []),
};
}
function runOverlay(nodes) {
const fnSrc = extractFunction(SRC, 'function _resultsOverlayVisible(');
const sandbox = { document: { querySelectorAll: () => nodes } };
vm.createContext(sandbox);
vm.runInContext(fnSrc + '\nglobalThis.__r = _resultsOverlayVisible();', sandbox);
return sandbox.__r;
}
function runResolve({ override = null, screens = [], active = null } = {}) {
const fnSrc = extractFunction(SRC, 'function _resolvePlayerOrigin(');
const sandbox = {
window: { slopsmith: { _nextReturnScreen: override } },
document: {
getElementById: (id) => (screens.includes(id) ? { id } : null),
querySelector: () => (active ? { id: active } : null),
},
};
vm.createContext(sandbox);
vm.runInContext(fnSrc + '\nglobalThis.__r = _resolvePlayerOrigin();', sandbox);
return { result: sandbox.__r, override: sandbox.window.slopsmith._nextReturnScreen };
}
// holdAutoExit() + _clearAutoExit() share module state; assemble them in one
// sandbox to exercise the generation guard on the returned release handle.
function buildHoldSandbox() {
const clearSrc = extractFunction(SRC, 'function _clearAutoExit(');
const holdSrc = extractFunction(SRC, 'window.slopsmith.holdAutoExit = function ()');
const sandbox = { __closeCount: 0 };
sandbox.window = { slopsmith: {}, closeCurrentSong: () => { sandbox.__closeCount++; } };
vm.createContext(sandbox);
vm.runInContext(`
var _autoExitTimer = null;
var _autoExitHeld = false;
var _autoExitGen = 0;
function clearTimeout() {}
${clearSrc}
${holdSrc}
globalThis.__clearAutoExit = _clearAutoExit;
globalThis.__hold = window.slopsmith.holdAutoExit;
`, sandbox);
return sandbox;
}
test('hold release navigates once while the generation is current', () => {
const sb = buildHoldSandbox();
const release = sb.__hold();
release();
assert.equal(sb.__closeCount, 1);
release(); // idempotent
assert.equal(sb.__closeCount, 1);
});
test('a stale hold release no-ops after the session moves on', () => {
const sb = buildHoldSandbox();
const release = sb.__hold();
sb.__clearAutoExit(); // new playSong / song:ended bumps the generation
release();
assert.equal(sb.__closeCount, 0, 'stale release must not navigate');
});
// ── _autoplayExitEnabled ──────────────────────────────────────────────
test('autoplayExit defaults ON when the key is absent', () => {
assert.equal(runEnabled(null), true);
});
test('autoplayExit is OFF only for the explicit "0"', () => {
assert.equal(runEnabled('0'), false);
assert.equal(runEnabled('1'), true);
assert.equal(runEnabled('anything'), true);
});
test('autoplayExit falls back to ON when localStorage throws', () => {
assert.equal(runEnabled('__throw__'), true);
});
// ── _resultsOverlayVisible ────────────────────────────────────────────
test('no overlays → not visible', () => {
assert.equal(runOverlay([]), false);
});
test('a visible modal overlay defers auto-exit', () => {
assert.equal(runOverlay([el({ id: 'mg-summary' })]), true);
});
test('a hidden (.hidden) overlay does not defer', () => {
assert.equal(runOverlay([el({ id: 'mg-summary', hidden: true })]), false);
});
test('a display:none overlay (no client rects) does not defer', () => {
assert.equal(runOverlay([el({ id: 'mg-summary', visible: false })]), false);
});
test('the player screen itself never counts as a results overlay', () => {
assert.equal(runOverlay([el({ id: 'player' })]), false);
});
test('mixed: ignores player + hidden, honors a visible results overlay', () => {
assert.equal(runOverlay([
el({ id: 'player' }),
el({ id: 'stale', hidden: true }),
el({ id: 'score-card' }),
]), true);
});
// ── _resolvePlayerOrigin ──────────────────────────────────────────────
test('one-shot override wins and is consumed when its screen exists', () => {
const { result, override } = runResolve({
override: 'v3-lessons', screens: ['v3-lessons', 'plugin-tutorials'], active: 'plugin-tutorials',
});
assert.equal(result, 'v3-lessons');
assert.equal(override, null); // consumed, even though it won
});
test('override is ignored (and still consumed) when its screen is missing', () => {
const { result, override } = runResolve({
override: 'ghost-screen', screens: ['favorites'], active: 'favorites',
});
assert.equal(result, 'favorites');
assert.equal(override, null);
});
test('remembers the real launch screen', () => {
assert.equal(runResolve({ screens: ['v3-lessons'], active: 'v3-lessons' }).result, 'v3-lessons');
assert.equal(runResolve({ screens: ['favorites'], active: 'favorites' }).result, 'favorites');
});
test('dashboard launches (classic home + v3-home) return to the Songs list', () => {
assert.equal(runResolve({ screens: ['home', 'v3-songs'], active: 'home' }).result, 'v3-songs');
assert.equal(runResolve({ screens: ['v3-home', 'v3-songs'], active: 'v3-home' }).result, 'v3-songs');
});
test('v3-home falls back to itself when there is no Songs list (defensive)', () => {
assert.equal(runResolve({ screens: ['v3-home'], active: 'v3-home' }).result, 'v3-home');
});
test('classic v2 (no #v3-songs) keeps home', () => {
assert.equal(runResolve({ screens: ['home'], active: 'home' }).result, 'home');
});
test('player / unknown / no active screen fall back to home', () => {
assert.equal(runResolve({ screens: ['player'], active: 'player' }).result, 'home');
assert.equal(runResolve({ screens: [], active: 'plugin-x' }).result, 'home');
assert.equal(runResolve({ screens: [], active: null }).result, 'home');
});

View File

@ -146,6 +146,9 @@ function loadPlaySong(sandbox) {
var isPlaying = true;
var currentFilename = null;
var _playerOriginScreen = null;
var _pendingAutostart = false;
function _clearAutoExit() {}
function _resolvePlayerOrigin() { return 'home'; }
function _recordPlaybackBridge() {}
function _cancelCountIn() {}
function _resetJuceAudioShimChain() {}