diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:01:02 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:01:02 +0200 |
| commit | 20706fb9a4ec646816b44a10842aa8f58ea0fd75 (patch) | |
| tree | 126be97d794491196c6033ae76d4680f24188595 /packages/meshbay-hub/src/meshbay_hub/static/music-player.js | |
| parent | a79a38a22a6145c475f50eeadb79b451aee31c11 (diff) | |
| download | meshbay-20706fb9a4ec646816b44a10842aa8f58ea0fd75.tar.gz | |
music: play, play next, add to queue
The player's queue could only be replaced: every onPlayQueue reset
tracks/order/pos together. It becomes one reducer (queue-ops.js) with
an `op`, because two appends batched into one tick cannot both read the
track count out of separate useStates.
A shared pop-up menu (menu.js) carries the three verbs, on right-click
and on a dots button. A track row is now a div holding two buttons: a
button cannot contain a button.
Found by the browser probe: both music wrappers took two arguments and
forwarded two, so every "add to queue" arrived as a plain play.
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 | 92 |
1 files changed, 48 insertions, 44 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 a4a8c5c..fcf122f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -1,6 +1,7 @@ import { - html, useState, useEffect, useRef, useCallback, + html, useState, useEffect, useReducer, useRef, useCallback, } from './vendor/htm-preact.js'; +import { queueReducer, emptyQueue } from './queue-ops.js'; import { t } from './i18n.js'; import { Icon } from './icon.js'; import { useStickyBand } from './sticky.js'; @@ -52,21 +53,6 @@ function formatTime(seconds) { return `${m}:${String(s).padStart(2, '0')}`; } -function shuffledOrder(n, keepFirst) { - const order = Array.from({ length: n }, (_, i) => i); - // Fisher-Yates, then move keepFirst to the front so shuffling on doesn't - // interrupt whatever is already playing. - for (let i = order.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [order[i], order[j]] = [order[j], order[i]]; - } - if (keepFirst != null) { - const at = order.indexOf(keepFirst); - if (at > 0) { order.splice(at, 1); order.unshift(keepFirst); } - } - return order; -} - // Bounded: only the currently playing track plus the read-ahead window are // ever worth holding in memory. Older blob URLs are revoked, not merely // dropped — otherwise every track played in a session leaks its object URL. @@ -188,9 +174,12 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { const blobInsertRef = useRef(0); const loadTokenRef = useRef(0); - const [tracks, setTracks] = useState([]); - const [order, setOrder] = useState([]); - const [pos, setPos] = useState(0); // index into `order` + // The queue: every entry, the play order over it, and where in that order + // we are (docs/playlists.md §9.3). One reducer rather than three useStates + // because "add to queue" derives its new indices from the current track + // count, and two appends batched into one tick cannot both see it. + const [queueState, dispatch] = useReducer(queueReducer, null, emptyQueue); + const { tracks, order, pos } = queueState; const [playing, setPlaying] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -209,14 +198,15 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { // through the whole list in an instant. const consecutiveFailuresRef = useRef(0); const MAX_CONSECUTIVE_FAILURES = 5; + const prefetchAfterInsertRef = useRef(false); 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) setPos(pos + 1); - else if (repeat === 'all') setPos(0); + if (pos + 1 < order.length) dispatch({ type: 'skipTo', pos: pos + 1 }); + else if (repeat === 'all') dispatch({ type: 'skipTo', pos: 0 }); }, [order.length, pos, repeat]); // Stops playback the moment this bar goes away for any reason -- the @@ -342,17 +332,38 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { // effect always re-runs rather than bailing out on reference equality. useEffect(() => { if (!queue) return; - const n = queue.tracks.length; - const initialOrder = shuffle ? shuffledOrder(n, queue.startIndex) : Array.from({ length: n }, (_, i) => i); - const startPos = shuffle ? 0 : queue.startIndex; - setTracks(queue.tracks); - setOrder(initialOrder); - setPos(startPos); - setError(''); + const op = queue.op || 'replace'; + dispatch({ + type: op === 'next' ? 'insertNext' : op === 'append' ? 'append' : 'replace', + tracks: queue.tracks, + startIndex: queue.startIndex, + shuffle, + }); + // A deliberate queue change is new material to try, so the runaway bound + // starts over; the bound exists to stop an automatic advance burning + // through a bad queue, not to hold a user's own action against them. consecutiveFailuresRef.current = 0; + if (op === 'replace') { + setError(''); + } else { + // Nothing that is playing changed, so the play effect below will not + // run and will not warm anything — but "play next" just put a track at + // pos + 1 that nobody has fetched. Flagged here, acted on once `order` + // has actually been rebuilt. + prefetchAfterInsertRef.current = true; + } // Playback itself starts from the effect below, keyed on [tracks, order, pos]. }, [queue]); + useEffect(() => { + if (!prefetchAfterInsertRef.current) return; + prefetchAfterInsertRef.current = false; + if (order.length) prefetchNext(pos); + // Deliberately not keyed on `pos`: this runs for a queue edit, and the + // ordinary advance is already covered where the current track loads. + // eslint-disable-next-line + }, [order]); + // Loads and plays whatever `pos` now points to. Runs after the queue // effect above (pos/order/tracks all just changed together) and also // after skipNext/skipPrev/onEnded update `pos` alone. @@ -406,7 +417,7 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { saveVolume(volume); }, [volume]); - const skipTo = useCallback((newPos) => setPos(newPos), []); + const skipTo = useCallback((newPos) => dispatch({ type: 'skipTo', pos: newPos }), []); const skipNext = useCallback(() => { if (order.length === 0) return; @@ -441,22 +452,15 @@ function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { else { audio.play().then(() => setPlaying(true)).catch(() => {}); } }, [playing]); + // Reshuffling keeps the currently playing track in place — turning shuffle + // on mid-album must not interrupt what's already playing. Which track that + // is, is `order[pos]`; see queue-ops.js on why it is not looked up by id. const toggleShuffle = useCallback(() => { - setShuffle((prev) => { - const next = !prev; - saveShuffle(next); - // Reshuffling keeps the currently playing track in place — turning - // shuffle on mid-album must not interrupt what's already playing. - const currentId = tracks[order[pos]] && tracks[order[pos]].id; - const currentIdx = tracks.findIndex((tr) => tr.id === currentId); - const newOrder = next - ? shuffledOrder(tracks.length, currentIdx) - : Array.from({ length: tracks.length }, (_, i) => i); - setOrder(newOrder); - setPos(next ? 0 : currentIdx); - return next; - }); - }, [tracks, order, pos]); + const next = !shuffle; + setShuffle(next); + saveShuffle(next); + dispatch({ type: 'reshuffle', shuffle: next }); + }, [shuffle]); const cycleRepeat = useCallback(() => { setRepeat((prev) => { |