mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-14 08:50:03 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df367e5cce |
@@ -6328,6 +6328,11 @@ let artAbortController = null;
|
|||||||
|
|
||||||
async function playSong(filename, arrangement, options) {
|
async function playSong(filename, arrangement, options) {
|
||||||
console.log('playSong called:', filename);
|
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) {
|
if (!options || options.bridge !== false) {
|
||||||
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
_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
|
// (Esc shortcut uses the same origin-aware target). showScreen() owns the
|
||||||
// full teardown: song:stop, audio unload, highway.stop(), count-in cancel.
|
// full teardown: song:stop, audio unload, highway.stop(), count-in cancel.
|
||||||
function closeCurrentSong() {
|
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');
|
return showScreen(_playerOriginScreen || 'home');
|
||||||
}
|
}
|
||||||
window.closeCurrentSong = closeCurrentSong;
|
window.closeCurrentSong = closeCurrentSong;
|
||||||
if (window.feedBack) window.feedBack.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) ────────────────
|
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
|
||||||
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
|
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
|
||||||
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
|
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
|
||||||
|
|||||||
+13
-2
@@ -139,19 +139,30 @@
|
|||||||
'<button id="v3-pl-back" class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Playlists</button>' +
|
'<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">' +
|
'<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>' +
|
'<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 ? '' :
|
(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>' +
|
'<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>' : '') +
|
(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-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>' +
|
'<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>' +
|
'</div>' +
|
||||||
(pl.songs.length
|
(pl.songs.length
|
||||||
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '</ul>'
|
? '<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>') +
|
: '<p class="text-fb-textDim">Empty — add songs from the library.</p>') +
|
||||||
'</div>';
|
'</div>';
|
||||||
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
|
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');
|
const listEl = root.querySelector('#v3-pl-songs');
|
||||||
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
|
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
|
||||||
root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {
|
root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -1567,7 +1567,6 @@
|
|||||||
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
|
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
|
||||||
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
|
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
|
||||||
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
|
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
|
||||||
'<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' +
|
|
||||||
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
|
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
|
||||||
'</div></div></div>' +
|
'</div></div></div>' +
|
||||||
// Practice-aware library home: a repertoire progress meter + a
|
// Practice-aware library home: a repertoire progress meter + a
|
||||||
@@ -1625,16 +1624,6 @@
|
|||||||
if (legacy) { legacy.click(); watchUploadScan(); }
|
if (legacy) { legacy.click(); watchUploadScan(); }
|
||||||
});
|
});
|
||||||
byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));
|
byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));
|
||||||
byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary);
|
|
||||||
// Reflect a scan already in progress (Settings button or a background
|
|
||||||
// pass) on the Refresh button, so its state isn't just tied to clicks here.
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/scan-status');
|
|
||||||
const sd = r.ok ? await r.json() : null;
|
|
||||||
if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); }
|
|
||||||
} catch (e) { /* */ }
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Bulletproof multi-select: in select mode, a capture-phase click on the
|
// Bulletproof multi-select: in select mode, a capture-phase click on the
|
||||||
// grid toggles the card and STOPS the event, so nothing (a per-card
|
// grid toggles the card and STOPS the event, so nothing (a per-card
|
||||||
@@ -1764,73 +1753,6 @@
|
|||||||
// legacy screens, so without this newly-uploaded songs wouldn't appear in
|
// legacy screens, so without this newly-uploaded songs wouldn't appear in
|
||||||
// v3 until a manual refresh. Bounded so a no-op upload can't poll forever.
|
// v3 until a manual refresh. Bounded so a no-op upload can't poll forever.
|
||||||
let _uploadScanTimer = null;
|
let _uploadScanTimer = null;
|
||||||
// ── Library refresh (rescan) from the Songs toolbar ───────────────────────
|
|
||||||
// The tester ask: a media-server-style "I dropped files in my folder, hit
|
|
||||||
// refresh" button, with live progress. Reuses the SAME machinery the Settings
|
|
||||||
// Rescan buttons drive (/api/rescan + /api/scan-status) and emits
|
|
||||||
// library:changed so the grid reloads. A scan already running (Settings or a
|
|
||||||
// background pass) is reflected on the button too.
|
|
||||||
let _refreshPoll = null;
|
|
||||||
function _setRefreshState(sd) {
|
|
||||||
const btn = document.getElementById('v3-songs-refresh');
|
|
||||||
if (!btn) return;
|
|
||||||
if (sd && sd.running) {
|
|
||||||
// Determinate count only exists in the 'scanning' stage; 'listing' is
|
|
||||||
// indeterminate (total 0), so show a plain "Scanning…" then.
|
|
||||||
const det = (sd.stage === 'scanning' && sd.total) ? ' ' + sd.done + '/' + sd.total : '';
|
|
||||||
btn.textContent = '⟳ Scanning' + det + '…';
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.classList.add('opacity-70');
|
|
||||||
const pct = sd.total ? Math.round((sd.done / sd.total) * 100) + '% · ' : '';
|
|
||||||
btn.title = 'Scanning new/changed songs… ' + pct + (sd.current || '');
|
|
||||||
} else {
|
|
||||||
btn.textContent = '⟳ Refresh';
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.classList.remove('opacity-70');
|
|
||||||
btn.title = 'Refresh library (scan for new songs)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Show a completion toast (reuses the shared fbNotify surface), suppressed
|
|
||||||
// while in a song. Honest + never-punishing copy; the precise "N added" count
|
|
||||||
// arrives with the background-scan delta work — until then this is a generic,
|
|
||||||
// truthful confirmation.
|
|
||||||
function _scanCompleteToast(sd) {
|
|
||||||
if (document.querySelector('.screen.active') && document.querySelector('.screen.active').id === 'player') return;
|
|
||||||
if (!window.fbNotify) return;
|
|
||||||
const msg = (sd && sd.error) ? 'Scan finished with an error' : 'Your library is up to date';
|
|
||||||
try { window.fbNotify.show({ title: 'Library scan complete', message: msg, icon: '🔄', accent: '#22C55E' }); } catch (e) { /* */ }
|
|
||||||
}
|
|
||||||
// Poll scan-status until the scan finishes, driving the button state. On
|
|
||||||
// completion, emit library:changed (grid reloads via the listener below) and,
|
|
||||||
// for a user-initiated refresh (announce), show the toast. announce:false is
|
|
||||||
// used when we only attached to a scan we didn't start.
|
|
||||||
function _watchScan(opts) {
|
|
||||||
if (_refreshPoll) return;
|
|
||||||
const announce = !opts || opts.announce !== false;
|
|
||||||
let sawRunning = false, ticks = 0;
|
|
||||||
_refreshPoll = setInterval(async () => {
|
|
||||||
ticks++;
|
|
||||||
let sd = null;
|
|
||||||
try { const r = await fetch('/api/scan-status'); if (r.ok) sd = await r.json(); } catch (e) { /* */ }
|
|
||||||
_setRefreshState(sd);
|
|
||||||
if (sd && sd.running) sawRunning = true;
|
|
||||||
// A user-initiated refresh that never saw a running scan = nothing to
|
|
||||||
// do (already up to date); give prompt feedback instead of waiting.
|
|
||||||
const noopDone = announce && !sawRunning && ticks >= 3;
|
|
||||||
if ((sawRunning && sd && !sd.running) || noopDone || ticks >= 180) {
|
|
||||||
clearInterval(_refreshPoll); _refreshPoll = null;
|
|
||||||
_setRefreshState(null);
|
|
||||||
if (sawRunning && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'rescan' }); } catch (e) { /* */ } }
|
|
||||||
if (announce) _scanCompleteToast(sd);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
async function refreshLibrary() {
|
|
||||||
if (_refreshPoll) return; // a scan is already in progress
|
|
||||||
try { await fetch('/api/rescan', { method: 'POST' }); } catch (e) { /* */ }
|
|
||||||
_watchScan({ announce: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchUploadScan() {
|
function watchUploadScan() {
|
||||||
if (_uploadScanTimer) clearInterval(_uploadScanTimer);
|
if (_uploadScanTimer) clearInterval(_uploadScanTimer);
|
||||||
let sawRunning = false, ticks = 0;
|
let sawRunning = false, ticks = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user