aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/queue-ops.js
blob: bd48a30041f4a701bde34b586c38e6a230b82476 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
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 };