feat(v3): play a playlist as a queue with auto-advance

Playing a playlist previously played one song and returned to the menu -- a
play-queue was never implemented. Add window.feedBack.playQueue (start / advance
/ hasNext / clear) and a "Play all" button on the playlist detail.

Advancing rides the same exit choke point as auto-exit and a results-card close:
song-end paths call window.closeCurrentSong() (the auto-exit grace timer and a
results screen's release()), so wrapping it plays the next track instead of
returning to the menu -- advancing AFTER the user dismisses a score card, not
through it. A user-initiated exit (Escape / the close button) uses the bareword
closeCurrentSong(), left untouched, so leaving the player still leaves and
abandons the queue. playSong gains a fromQueue guard (a manual play abandons a
stale queue) and closeCurrentSong clears the queue on a real close. Binds via
song:ended / the choke point, not the <audio> element, so it advances on the
desktop (JUCE) route too. The no-queue path is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
This commit is contained in:
ChrisBeWithYou 2026-07-01 05:34:35 -05:00
parent f9607c5c94
commit df367e5cce
2 changed files with 82 additions and 2 deletions

View File

@ -6328,6 +6328,11 @@ let artAbortController = null;
async function playSong(filename, arrangement, options) {
console.log('playSong called:', filename);
// A manual (non-queue) play abandons any active play-queue, so a stale queue
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
window.feedBack.playQueue.clear();
}
if (!options || options.bridge !== false) {
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
}
@ -6667,11 +6672,75 @@ if (window.feedBack) window.feedBack.restartCurrentSong = restartCurrentSong;
// (Esc shortcut uses the same origin-aware target). showScreen() owns the
// full teardown: song:stop, audio unload, highway.stop(), count-in cancel.
function closeCurrentSong() {
// A real close (user Escape/✕, or the queue-aware wrapper once the queue is
// exhausted) abandons any play-queue so a stale one can't advance later.
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.clear();
return showScreen(_playerOriginScreen || 'home');
}
window.closeCurrentSong = closeCurrentSong;
if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
// ── Play-queue: sequential playback of a playlist / album ──────────────────
// Playing a list should advance to the next track when a song ends, instead of
// returning to the menu (the long-standing "plays one song then boots to menu"
// gap — a queue was simply never implemented). Advancing rides the SAME exit
// choke point as auto-exit and a results-card close: window.closeCurrentSong().
// Song-end paths call window.closeCurrentSong() (the auto-exit grace timer, and
// a results screen's release()), so wrapping it lets the queue advance on song
// end AND after the user dismisses a score card. A *user* exit (Escape / the ✕)
// calls the bareword closeCurrentSong(), which we deliberately leave alone, so
// leaving the player still leaves — and abandons the queue.
window.feedBack.playQueue = (function () {
let list = [], idx = -1, source = '', arrangements = null;
const active = () => idx >= 0 && idx < list.length;
const hasNext = () => active() && idx < list.length - 1;
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
function _play(i) {
const fn = list[i];
// fromQueue keeps the queue from clearing itself; playSong decodeURIs.
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
}
function start(files, opts) {
files = (files || []).filter(Boolean);
if (!files.length) return false;
list = files.slice(); idx = 0;
source = (opts && opts.source) || '';
arrangements = (opts && opts.arrangements) || null;
if (window.fbNotify) {
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
}
_play(idx);
return true;
}
function advance() {
if (!hasNext()) { clear(); return false; }
idx++;
_play(idx);
return true;
}
return {
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
source: function () { return source; },
remaining: function () { return active() ? list.length - idx - 1 : 0; },
};
})();
// Make the song-end exit queue-aware (see above). Wrap window.closeCurrentSong
// (and feedBack.closeCurrentSong) so that when a queue has a next track, we play
// it instead of returning to the menu. The bareword closeCurrentSong() used by a
// user-initiated exit is unaffected.
(function () {
const realClose = window.closeCurrentSong;
function queueAwareClose() {
const q = window.feedBack.playQueue;
if (q && q.hasNext()) { q.advance(); return; }
if (q) q.clear();
return realClose.apply(this, arguments);
}
window.closeCurrentSong = queueAwareClose;
if (window.feedBack) window.feedBack.closeCurrentSong = queueAwareClose;
})();
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead

View File

@ -139,19 +139,30 @@
'<button id="v3-pl-back" class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Playlists</button>' +
'<div class="flex items-center justify-between mb-6 gap-3">' +
'<h2 class="text-3xl font-bold text-fb-text truncate">' + esc(pl.name) + '</h2>' +
'<div class="flex gap-2 shrink-0 items-center">' +
(pl.songs.length ? '<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play all</button>' : '') +
(isSystem ? '' :
'<div class="flex gap-2 shrink-0">' +
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
'<button id="v3-pl-rename" class="text-sm text-fb-textDim hover:text-fb-text px-2">Rename</button>' +
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button>' +
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden"></div>') +
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden">') +
'</div>' +
'</div>' +
(pl.songs.length
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '</ul>'
: '<p class="text-fb-textDim">Empty — add songs from the library.</p>') +
'</div>';
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
// Play all: start the play-queue with this playlist's songs (auto-advances
// track to track). Falls back to playing the first song on an older core
// without the queue, so the button always does something.
root.querySelector('#v3-pl-playall')?.addEventListener('click', () => {
const files = (pl.songs || []).map((s) => s.filename).filter(Boolean);
if (!files.length) return;
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: pl.name });
else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
});
const listEl = root.querySelector('#v3-pl-songs');
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {