diff options
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); + })} /> `} `; } |