diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 12:10:25 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 12:10:25 +0200 |
| commit | 9cbff21274604e37c0986d57937deef85819c396 (patch) | |
| tree | bdfb55978bb105fd78d1d35db33ff8bb35457f67 /packages/meshbay-hub/src/meshbay_hub/static/music-player.js | |
| parent | 2e973795383b71f63ae9e3bef0e5dfc7930b4c90 (diff) | |
| download | meshbay-9cbff21274604e37c0986d57937deef85819c396.tar.gz | |
music: the playlist menus
One button in Music's sticky toolbar — load, create, delete, remove a
track, sync now — and "add to playlist" on every cover and row. Both
surfaces share one list, read from the manifest, so they open instantly
with every node offline and no body is fetched until one is wanted.
Submenus expand in place rather than flying out: the account menu's
language list already does this, and a flyout has nowhere to go at 400px.
The tracklist under "remove a track" loads when it is expanded.
A name is typed into a field. Electron has no prompt — it throws.
Also splits the two playback failures: a decode failure belongs to that
file and keeps the bounded counter, a connection failure belongs to the
group and skips all of its queued tracks at once. Six dead tracks are
one more than the bound, which is where a playlist would otherwise stop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/music-player.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/music-player.js | 103 |
1 files changed, 90 insertions, 13 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js index fcf122f..c3a2868 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -119,7 +119,7 @@ function saveRepeat(v) { // opening this told you nothing you didn't already know. It now shows the // current track's own title/artist right under the header, and scrolls the // highlighted row into view on open rather than leaving it to be found. -function QueuePanel({ tracks, order, pos, onSelect, onClose }) { +function QueuePanel({ tracks, order, pos, onSelect, onClose, onSaveAsPlaylist }) { const activeRowRef = useRef(null); useEffect(() => { if (activeRowRef.current) { @@ -136,6 +136,16 @@ function QueuePanel({ tracks, order, pos, onSelect, onClose }) { <div class="music-detail"> <div class="video-top-bar"> <span class="video-title">${t('music.queue_title')}</span> + ${/* "Save the current queue as a playlist" lives here rather than in + Music's toolbar menu, because this panel is where the current + queue is a thing the reader can actually see — and because the + queue is the player's own state, which a menu in a different + component would have to have lifted out of it. */''} + ${onSaveAsPlaylist && html` + <button class="video-close" title=${t('playlists.save_queue')} + onClick=${() => onSaveAsPlaylist(order.map((i) => tracks[i]))}> + <${Icon} name="playlist" /></button> + `} <button class="video-close" onClick=${onClose} title=${t('video.close')}> <${Icon} name="close" /></button> </div> @@ -165,7 +175,7 @@ function QueuePanel({ tracks, order, pos, onSelect, onClose }) { `; } -function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { +function MusicPlayerBar({ getConnection, queue, onClose, userPrefs, onSaveQueue }) { // Pinned to the bottom of the window, over the bottom of the sidebar; the // sidebar subtracts this so its last entry is not underneath. const barBand = useStickyBand('--music-bar-h'); @@ -199,15 +209,51 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { const consecutiveFailuresRef = useRef(0); const MAX_CONSECUTIVE_FAILURES = 5; const prefetchAfterInsertRef = useRef(false); + // Groups that did not answer this session. A playlist crosses groups, and + // one of them being off is a property of that group rather than of each of + // its tracks in turn — see advancePastFailure below (docs/playlists.md §11.3). + const downGroupsRef = useRef(new Set()); const currentTrack = tracks[order[pos]] || null; - const advancePastFailure = useCallback(() => { - consecutiveFailuresRef.current += 1; - if (consecutiveFailuresRef.current > MAX_CONSECUTIVE_FAILURES || order.length <= 1) return; - if (pos + 1 < order.length) dispatch({ type: 'skipTo', pos: pos + 1 }); - else if (repeat === 'all') dispatch({ type: 'skipTo', pos: 0 }); - }, [order.length, pos, repeat]); + /** + * Move past a track that will not play. + * + * Two failures, and they are properties of different things: + * + * A **decode** failure belongs to that file — a truncated download, a format + * with no decoder. The bounded counter is right for it: a queue that turns + * out to be entirely bad fails once, visibly, rather than burning through + * the whole list in an instant. + * + * A **connection** failure belongs to that *group*. The same bound applied + * to it is a regression playlists introduce into code that is correct today: + * a playlist whose next six tracks all come from one node that is off stops + * at the sixth, with an error, and the reader sees "the playlist is broken". + * So the group is marked down and every one of its queued tracks is skipped + * in one step, with the counter reset — which is what the bound was + * protecting in the first place. + */ + const advancePastFailure = useCallback((groupDown) => { + if (order.length <= 1) return; + let next = pos + 1; + if (groupDown) { + downGroupsRef.current.add(groupDown); + consecutiveFailuresRef.current = 0; + while (next < order.length + && downGroupsRef.current.has(tracks[order[next]] + && tracks[order[next]].groupId)) { + next += 1; + } + } else { + consecutiveFailuresRef.current += 1; + if (consecutiveFailuresRef.current > MAX_CONSECUTIVE_FAILURES) return; + } + if (next < order.length) { dispatch({ type: 'skipTo', pos: next }); return; } + if (repeat === 'all') dispatch({ type: 'skipTo', pos: 0 }); + // Nothing left that can play. Stopping once, with the error already on + // screen, is the honest end — and is what the bound above exists to reach. + }, [tracks, order, pos, repeat]); // Stops playback the moment this bar goes away for any reason -- the // close button below, or the shell tearing it down on its own (leaving @@ -279,10 +325,23 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { const fetchTrackBlob = useCallback(async (entry) => { const cached = blobCacheRef.current.get(entry.id); if (cached) return cached.url; - const { transport, gek } = await getConnection(entry.groupId); - if (!transport) throw new Error(t('music.err_transport')); + // A group that does not answer is marked as such, so the queue can skip + // all of its tracks at once rather than one failure at a time. + const groupDown = () => { + const e = new Error(t('music.err_transport')); + e.isGroupDown = true; + return e; + }; + let transport; + let gek; + try { + ({ transport, gek } = await getConnection(entry.groupId)); + } catch { + throw groupDown(); + } + if (!transport) throw groupDown(); if (!transport.connected) await transport.waitForReconnect(); - if (!transport.connected) throw new Error(t('music.err_transport')); + if (!transport.connected) throw groupDown(); let downloadId = entry.id; let downloadSize = entry.size; @@ -319,9 +378,18 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { // afterward — see prefetchDepth() for how far ahead that runway goes. const prefetchNext = useCallback((fromPos) => { const ahead = prefetchDepth(); + const playingGroup = tracks[order[fromPos]] && tracks[order[fromPos]].groupId; for (let i = 1; i <= ahead; i++) { const nextEntry = tracks[order[fromPos + i]]; if (!nextEntry || blobCacheRef.current.has(nextEntry.id)) continue; + // Only what this queue is already connected to. For an album these five + // share one connection and nothing changes; for a shuffled cross-group + // playlist they may want five *different* node dials of up to ten + // seconds each, against a pool of twelve — to warm tracks the reader may + // never reach (docs/playlists.md §9.5). The rest warm when the queue + // gets to them and the dial has to happen anyway. + if (nextEntry.groupId !== playingGroup) continue; + if (downGroupsRef.current.has(nextEntry.groupId)) continue; fetchTrackBlob(nextEntry).catch(() => {}); } }, [tracks, order, fetchTrackBlob]); @@ -388,7 +456,9 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { if (loadTokenRef.current !== myToken) return; setError(err.message || String(err)); setPlaying(false); - advancePastFailure(); + // `fetchTrackBlob` throws `err_transport` when no node answered for + // this track's group; anything else is about the file itself. + advancePastFailure(err.isGroupDown ? currentTrack.groupId : null); } finally { if (loadTokenRef.current === myToken) setLoading(false); } @@ -547,7 +617,14 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { </div> ${showQueue && html` <${QueuePanel} tracks=${tracks} order=${order} pos=${pos} - onSelect=${skipTo} onClose=${() => setShowQueue(false)} /> + onSelect=${skipTo} onClose=${() => setShowQueue(false)} + ${/* Saved in **play order**, which is what this panel is showing: if + shuffle is on, that freezes the shuffle, and that is what "save + what I am listening to" means. */''} + onSaveAsPlaylist=${onSaveQueue && ((rows) => { + setShowQueue(false); + onSaveQueue(rows); + })} /> `} `; } |