aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 11:01:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 11:01:02 +0200
commit20706fb9a4ec646816b44a10842aa8f58ea0fd75 (patch)
tree126be97d794491196c6033ae76d4680f24188595 /packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js
parenta79a38a22a6145c475f50eeadb79b451aee31c11 (diff)
downloadmeshbay-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/queue-ops.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js147
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 };