/** * 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 };