aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
diff options
context:
space:
mode:
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.js103
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);
+ })} />
`}
`;
}