diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js | 147 |
1 files changed, 147 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js b/packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js new file mode 100644 index 0000000..bd48a30 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js @@ -0,0 +1,147 @@ +/** + * The music player's queue, as a reducer over `{ tracks, order, pos }`. + * + * `tracks` is every entry the queue holds, in the order they were added. + * `order` is indices into it — the *play* order, which is what shuffle + * rewrites without disturbing `tracks`. `pos` walks `order`. The track playing + * is `tracks[order[pos]]`, and `docs/playlists.md` §9 is why these three live + * here rather than as three pieces of component state. + * + * The queue used to be replaceable and nothing else: every call to + * `onPlayQueue` reset all three together, which is all an album needs. "Play + * next" and "add to queue" do not replace, and building them on three separate + * `useState`s ships a defect — `setOrder` needs the length `setTracks` is about + * to produce and cannot see it, so two enqueues batched into one tick both read + * the stale length and write indices past the end. A reducer sees one state. + * + * No imports, and there must be none: the whole module is executed standalone + * by `test_queue_ops.py`, the way `source-merge.js` is, and the queue is + * untested from the moment it cannot be. + */ + +/** + * A play order over `n` tracks, shuffled, with `keepFirst` moved to the front + * so turning shuffle on mid-album does not interrupt what is already playing. + * + * `rand` is injectable for the tests alone — callers pass nothing. + */ +function shuffledOrder(n, keepFirst, rand) { + const rnd = rand || Math.random; + const order = Array.from({ length: n }, (_, i) => i); + for (let i = order.length - 1; i > 0; i--) { + const j = Math.floor(rnd() * (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; +} + +function identityOrder(n) { + return Array.from({ length: n }, (_, i) => i); +} + +function emptyQueue() { + return { tracks: [], order: [], pos: 0 }; +} + +/** + * `{ tracks, order, pos }` after `action`. Pure, and never mutates `state`: + * the player holds this in `useReducer`, and a mutated array would not + * re-render the queue panel that is looking at it. + * + * replace { tracks, startIndex, shuffle } the whole queue, as before + * append { tracks } at the end of the play order + * insertNext { tracks } right after what is playing + * removeAt { at } `at` indexes `order`, not `tracks` + * skipTo { pos } + * reshuffle { shuffle } keeping the current track playing + */ +function queueReducer(state, action) { + switch (action.type) { + case 'replace': { + const incoming = action.tracks || []; + const n = incoming.length; + const start = action.startIndex || 0; + return { + tracks: incoming, + // Identical to what the player did inline before this module existed: + // shuffled with the requested track pulled to the front and played from + // position 0, or the plain order played from the requested index. + order: action.shuffle + ? shuffledOrder(n, start, action.rand) + : identityOrder(n), + pos: action.shuffle ? 0 : start, + }; + } + + case 'append': + case 'insertNext': { + const incoming = action.tracks || []; + if (!incoming.length) return state; + // The one line this module exists for: the new indices are derived from + // the length in `state`, which is the length after every action already + // applied — not from a closure captured when the click handler was built. + const added = incoming.map((_, i) => state.tracks.length + i); + const tracks = [...state.tracks, ...incoming]; + if (action.type === 'append') { + return { tracks, order: [...state.order, ...added], pos: state.pos }; + } + // Appended tracks are *not* reshuffled into the middle when shuffle is + // on: "add to queue" means at the end, and "play next" means next, in + // the order given. Reshuffling either would be the player deciding it + // knew better than the verb the user pressed. + const at = state.order.length ? state.pos + 1 : 0; + return { + tracks, + order: [...state.order.slice(0, at), ...added, ...state.order.slice(at)], + pos: state.pos, + }; + } + + case 'removeAt': { + const { at } = action; + if (at < 0 || at >= state.order.length) return state; + const order = [...state.order.slice(0, at), ...state.order.slice(at + 1)]; + // Dropped from the play order only. Reindexing `tracks` would mean + // rewriting every entry of `order` for one removal, and the orphan costs + // a pointer — `tracks` is bounded by the queue, not by the session. + // + // Removing what is playing leaves `pos` where it is, so the next track + // slides into that slot and the player's load effect starts it: that is + // what removing the current track is expected to do. + let pos = state.pos; + if (at < state.pos) pos -= 1; + if (pos > order.length - 1) pos = Math.max(0, order.length - 1); + return { tracks: state.tracks, order, pos }; + } + + case 'skipTo': + return { ...state, pos: action.pos }; + + case 'reshuffle': { + // The track playing is `order[pos]` — an index into `tracks` — so that + // *is* the index to keep. The first version of this recovered it by + // looking up the current entry's id and searching `tracks` for it, which + // agreed with `order[pos]` only while the queue held no duplicates. A + // queue can now hold duplicates (enqueue the same track twice, add an + // album that overlaps a playlist), and the id search then jumps playback + // to the first copy. + const currentIdx = state.order.length ? state.order[state.pos] : null; + return { + tracks: state.tracks, + order: action.shuffle + ? shuffledOrder(state.tracks.length, currentIdx, action.rand) + : identityOrder(state.tracks.length), + pos: action.shuffle ? 0 : (currentIdx == null ? 0 : currentIdx), + }; + } + + default: + return state; + } +} + +export { queueReducer, shuffledOrder, identityOrder, emptyQueue }; |