A music player that connects to your cloud/distributed storage.
1import { announce, ostiary, rpc } from "~/common/worker.js";
2import { effect, signal } from "~/common/signal.js";
3import { arrayShuffle } from "~/common/utils.js";
4import { xxh32 } from "xxh32";
5
6/**
7 * @import {Actions, Item} from "./types.d.ts"
8 */
9
10////////////////////////////////////////////
11// STATE
12////////////////////////////////////////////
13
14let _key = 0;
15const nextKey = () => String(++_key);
16
17/** Ordered list of available track IDs. */
18export const $lake = signal(/** @type {string[]} */ ([]));
19
20// Communicated state
21export const $future = signal(/** @type {Item[]} */ ([]));
22export const $now = signal(/** @type {Item | null} */ (null));
23export const $past = signal(/** @type {Item[]} */ ([]));
24export const $supplyFingerprint = signal(
25 /** @type {string | undefined} */ (undefined),
26);
27
28////////////////////////////////////////////
29// ACTIONS
30////////////////////////////////////////////
31
32/**
33 * @type {Actions['add']}
34 *
35 * @example Adds tracks after the last manual entry, before any auto-filled items
36 * ```js
37 * import { add, $future } from "~/components/engine/queue/worker.js";
38 *
39 * add({ trackIds: ["a", "b"] });
40 *
41 * if ($future.value.length !== 2) throw new Error("expected 2 items");
42 * if ($future.value[0].id !== "a") throw new Error("wrong first item");
43 * if ($future.value[1].id !== "b") throw new Error("wrong second item");
44 * if (!$future.value[0].manualEntry) throw new Error("items should be manualEntry: true");
45 * ```
46 *
47 * @example Inserts before auto-filled items when they are present
48 * ```js
49 * import { add, $future } from "~/components/engine/queue/worker.js";
50 *
51 * $future.value = [
52 * { id: "manual", manualEntry: true },
53 * { id: "auto", manualEntry: false },
54 * ];
55 *
56 * add({ trackIds: ["new"] });
57 *
58 * if ($future.value[0].id !== "manual") throw new Error("expected 'manual' first");
59 * if ($future.value[1].id !== "new") throw new Error("expected 'new' second");
60 * if ($future.value[2].id !== "auto") throw new Error("expected 'auto' last");
61 * ```
62 *
63 * @example Prepends tracks to the front with inFront: true
64 * ```js
65 * import { add, $future } from "~/components/engine/queue/worker.js";
66 *
67 * add({ trackIds: ["c"] });
68 * add({ inFront: true, trackIds: ["a", "b"] });
69 *
70 * if ($future.value[0].id !== "a") throw new Error("expected 'a' first");
71 * if ($future.value[1].id !== "b") throw new Error("expected 'b' second");
72 * if ($future.value[2].id !== "c") throw new Error("expected 'c' last");
73 * ```
74 */
75export function add({ inFront, trackIds }) {
76 const items = trackIds.map((id) => {
77 return { id, key: nextKey(), manualEntry: true };
78 });
79
80 if (inFront) {
81 $future.value = [...items, ...$future.value];
82 } else {
83 let lastManualIdx = -1;
84 for (let i = 0; i < $future.value.length; i++) {
85 if ($future.value[i].manualEntry) lastManualIdx = i;
86 }
87 $future.value = [
88 ...$future.value.slice(0, lastManualIdx + 1),
89 ...items,
90 ...$future.value.slice(lastManualIdx + 1),
91 ];
92 }
93}
94
95/**
96 * @type {Actions['clear']}
97 *
98 * @example Keeps manual entries when keepManual is true
99 * ```js
100 * import { clear, $future } from "~/components/engine/queue/worker.js";
101 *
102 * $future.value = [
103 * { id: "manual", manualEntry: true },
104 * { id: "auto", manualEntry: false },
105 * ];
106 * clear({ keepManual: true });
107 *
108 * if ($future.value.length !== 1) throw new Error("expected 1 item remaining");
109 * if ($future.value[0].id !== "manual") throw new Error("manual entry should remain");
110 * ```
111 *
112 * @example Clears all items when keepManual is false
113 * ```js
114 * import { clear, $future } from "~/components/engine/queue/worker.js";
115 *
116 * $future.value = [
117 * { id: "manual", manualEntry: true },
118 * { id: "auto", manualEntry: false },
119 * ];
120 * clear({ keepManual: false });
121 *
122 * if ($future.value.length !== 0) throw new Error("expected empty queue");
123 * ```
124 */
125export function clear({ keepManual }) {
126 $future.value = keepManual
127 ? $future.value.filter((i) => i.manualEntry === true)
128 : [];
129}
130
131/**
132 * @type {Actions['expel']}
133 *
134 * @example Removes an item from the future by key
135 * ```js
136 * import { expel, $future } from "~/components/engine/queue/worker.js";
137 *
138 * $future.value = [
139 * { id: "a", key: "1", manualEntry: true },
140 * { id: "b", key: "2", manualEntry: true },
141 * ];
142 *
143 * expel({ key: "1" });
144 *
145 * if ($future.value.length !== 1) throw new Error("expected 1 item remaining");
146 * if ($future.value[0].id !== "b") throw new Error("expected 'b' to remain");
147 * ```
148 *
149 * @example Removes the now-playing item by key, setting now to null
150 * ```js
151 * import { expel, $now } from "~/components/engine/queue/worker.js";
152 *
153 * $now.value = { id: "a", key: "1", manualEntry: false };
154 *
155 * expel({ key: "1" });
156 *
157 * if ($now.value !== null) throw new Error("expected now to be null");
158 * ```
159 *
160 * @example Removes an item from the past by key
161 * ```js
162 * import { expel, $past } from "~/components/engine/queue/worker.js";
163 *
164 * $past.value = [
165 * { id: "a", key: "1", manualEntry: false },
166 * { id: "b", key: "2", manualEntry: false },
167 * ];
168 *
169 * expel({ key: "1" });
170 *
171 * if ($past.value.length !== 1) throw new Error("expected 1 item remaining");
172 * if ($past.value[0].id !== "b") throw new Error("expected 'b' to remain");
173 * ```
174 *
175 * @example Does nothing for an unknown key
176 * ```js
177 * import { expel, $past, $now, $future } from "~/components/engine/queue/worker.js";
178 *
179 * $past.value = [{ id: "a", key: "1", manualEntry: false }];
180 * $now.value = { id: "b", key: "2", manualEntry: false };
181 * $future.value = [{ id: "c", key: "3", manualEntry: false }];
182 *
183 * expel({ key: "z" });
184 *
185 * if ($past.value.length !== 1) throw new Error("past should be unchanged");
186 * if ($now.value?.id !== "b") throw new Error("now should be unchanged");
187 * if ($future.value.length !== 1) throw new Error("future should be unchanged");
188 * ```
189 */
190export function expel({ key }) {
191 const pastIdx = $past.value.findIndex((i) => i.key === key);
192 if (pastIdx !== -1) {
193 const p = [...$past.value];
194 p.splice(pastIdx, 1);
195 $past.value = p;
196 return;
197 }
198 if ($now.value?.key === key) {
199 $now.value = null;
200 return;
201 }
202 const futureIdx = $future.value.findIndex((i) => i.key === key);
203 if (futureIdx !== -1) {
204 const f = [...$future.value];
205 f.splice(futureIdx, 1);
206 $future.value = f;
207 }
208}
209
210/**
211 * @type {Actions['fill']}
212 */
213export function fill({ augment, amount, shuffled }) {
214 $future.value = fillQueue(
215 shuffled,
216 amount +
217 (augment
218 ? $future.value.filter((i) => i.manualEntry === false).length
219 : 0),
220 $future.value,
221 );
222}
223
224/**
225 * @type {Actions['move']}
226 *
227 * @example Moves an item forward in the flat list
228 * ```js
229 * import { move, $future } from "~/components/engine/queue/worker.js";
230 *
231 * $future.value = [
232 * { id: "a", key: "1", manualEntry: true },
233 * { id: "b", key: "2", manualEntry: true },
234 * { id: "c", key: "3", manualEntry: true },
235 * ];
236 *
237 * move({ key: "1", to: 2 });
238 *
239 * if ($future.value[0].id !== "b") throw new Error("expected 'b' first");
240 * if ($future.value[1].id !== "c") throw new Error("expected 'c' second");
241 * if ($future.value[2].id !== "a") throw new Error("expected 'a' last");
242 * ```
243 *
244 * @example Moves an item backward in the flat list
245 * ```js
246 * import { move, $future } from "~/components/engine/queue/worker.js";
247 *
248 * $future.value = [
249 * { id: "a", key: "1", manualEntry: true },
250 * { id: "b", key: "2", manualEntry: true },
251 * { id: "c", key: "3", manualEntry: true },
252 * ];
253 *
254 * move({ key: "3", to: 0 });
255 *
256 * if ($future.value[0].id !== "c") throw new Error("expected 'c' first");
257 * if ($future.value[1].id !== "a") throw new Error("expected 'a' second");
258 * if ($future.value[2].id !== "b") throw new Error("expected 'b' last");
259 * ```
260 *
261 * @example Preserves now identity when reordering across past/future
262 * ```js
263 * import { move, $past, $now, $future } from "~/components/engine/queue/worker.js";
264 *
265 * $past.value = [{ id: "a", key: "1", manualEntry: false }];
266 * $now.value = { id: "b", key: "2", manualEntry: false };
267 * $future.value = [{ id: "c", key: "3", manualEntry: false }];
268 *
269 * // flat list is [a(0), b(1), c(2)]; moving c to front → [c, a, b]
270 * move({ key: "3", to: 0 });
271 *
272 * if ($now.value?.id !== "b") throw new Error("now should still be 'b'");
273 * if ($past.value[0]?.id !== "c") throw new Error("expected 'c' first in past");
274 * if ($past.value[1]?.id !== "a") throw new Error("expected 'a' second in past");
275 * if ($future.value.length !== 0) throw new Error("future should be empty");
276 * ```
277 *
278 * @example Does nothing when the item is already at the target position
279 * ```js
280 * import { move, $future } from "~/components/engine/queue/worker.js";
281 *
282 * $future.value = [{ id: "a", key: "1", manualEntry: true }, { id: "b", key: "2", manualEntry: true }];
283 *
284 * move({ key: "2", to: 1 });
285 *
286 * if ($future.value[0].id !== "a") throw new Error("order should be unchanged");
287 * if ($future.value[1].id !== "b") throw new Error("order should be unchanged");
288 * ```
289 *
290 * @example Does nothing for out-of-bounds target or unknown key
291 * ```js
292 * import { move, $future } from "~/components/engine/queue/worker.js";
293 *
294 * $future.value = [{ id: "a", key: "1", manualEntry: true }, { id: "b", key: "2", manualEntry: true }];
295 *
296 * move({ key: "1", to: 99 });
297 * move({ key: "z", to: 0 });
298 *
299 * if ($future.value[0].id !== "a") throw new Error("order should be unchanged");
300 * if ($future.value[1].id !== "b") throw new Error("order should be unchanged");
301 * ```
302 */
303export function move({ key, to }) {
304 const past = $past.value;
305 const now = $now.value;
306 const future = $future.value;
307 const pLen = past.length;
308 const nLen = now ? 1 : 0;
309 const futureStart = pLen + nLen;
310 const total = futureStart + future.length;
311
312 let from = past.findIndex((i) => i.key === key);
313 if (from === -1 && now?.key === key) from = pLen;
314 if (from === -1) {
315 const fi = future.findIndex((i) => i.key === key);
316 if (fi !== -1) from = futureStart + fi;
317 }
318
319 if (from === -1 || from === to || to < 0 || to >= total) return;
320
321 // Compute now's new flat index after the move
322 let nowIdx = pLen;
323 if (nLen) {
324 if (from === pLen) nowIdx = to;
325 else if (from < to && pLen > from && pLen <= to) nowIdx = pLen - 1;
326 else if (from > to && pLen >= to && pLen < from) nowIdx = pLen + 1;
327 }
328
329 // Map a post-move flat index back to the original flat index
330 const origIdx = (/** @type {number} */ i) => {
331 if (from < to) {
332 if (i < from) return i;
333 if (i < to) return i + 1;
334 if (i === to) return from;
335 } else {
336 if (i < to) return i;
337 if (i === to) return from;
338 if (i <= from) return i - 1;
339 }
340 return i;
341 };
342
343 const flatGet = (/** @type {number} */ i) => {
344 const j = origIdx(i);
345 return j < pLen ? past[j] : j < futureStart ? now : future[j - futureStart];
346 };
347
348 $past.value =
349 /** @type {Item[]} */ (Array.from(
350 { length: nowIdx },
351 (_, i) => flatGet(i),
352 ));
353 $future.value =
354 /** @type {Item[]} */ (Array.from(
355 { length: total - nowIdx - nLen },
356 (_, i) => flatGet(nowIdx + nLen + i),
357 ));
358}
359
360/**
361 * @type {Actions['shift']}
362 */
363export function shift() {
364 return _shift();
365}
366
367/**
368 * @type {Actions['supply']}
369 *
370 * @example Sets the track pool and computes a fingerprint
371 * ```js
372 * import { supply, $lake, $supplyFingerprint } from "~/components/engine/queue/worker.js";
373 *
374 * supply({ trackIds: ["a", "b", "c"] });
375 *
376 * if ($lake.value.join(",") !== "a,b,c") throw new Error("lake not set correctly");
377 * if (typeof $supplyFingerprint.value !== "string") throw new Error("fingerprint should be a string");
378 * ```
379 *
380 * @example Returns undefined fingerprint for an empty supply
381 * ```js
382 * import { supply, $supplyFingerprint } from "~/components/engine/queue/worker.js";
383 *
384 * supply({ trackIds: [] });
385 *
386 * if ($supplyFingerprint.value !== undefined) throw new Error("fingerprint should be undefined for empty supply");
387 * ```
388 *
389 * @example Same track IDs produce the same fingerprint
390 * ```js
391 * import { supply, $supplyFingerprint } from "~/components/engine/queue/worker.js";
392 *
393 * supply({ trackIds: ["x", "y"] });
394 * const first = $supplyFingerprint.value;
395 *
396 * supply({ trackIds: ["x", "y"] });
397 * const second = $supplyFingerprint.value;
398 *
399 * if (first !== second) throw new Error("same tracks should produce the same fingerprint");
400 * ```
401 */
402export function supply({ trackIds }) {
403 $lake.value = trackIds;
404 $supplyFingerprint.value = trackIds.length
405 ? xxh32(trackIds.join("\0")).toString()
406 : undefined;
407}
408
409/**
410 * @type {Actions['unshift']}
411 *
412 * @example Moves the last past item back to now, pushing now to the front of future
413 * ```js
414 * import { unshift, $future, $now, $past } from "~/components/engine/queue/worker.js";
415 *
416 * $past.value = [{ id: "prev", manualEntry: false }];
417 * $now.value = { id: "current", manualEntry: false };
418 * $future.value = [];
419 *
420 * unshift();
421 *
422 * if ($now.value?.id !== "prev") throw new Error("expected 'prev' as now");
423 * if ($past.value.length !== 0) throw new Error("expected empty past");
424 * if ($future.value[0]?.id !== "current") throw new Error("expected 'current' back at front of future");
425 * ```
426 *
427 * @example Does nothing when past is empty
428 * ```js
429 * import { unshift, $now, $past } from "~/components/engine/queue/worker.js";
430 *
431 * $past.value = [];
432 * $now.value = { id: "current", manualEntry: false };
433 *
434 * unshift();
435 *
436 * if ($now.value?.id !== "current") throw new Error("now should remain unchanged");
437 * ```
438 */
439export function unshift() {
440 const p = $past.value;
441 if (p.length === 0) return;
442
443 const n = $now.value;
444 const last = p[p.length - 1];
445
446 $past.value = p.slice(0, p.length - 1);
447 $now.value = last ?? null;
448 if (n) $future.value = [n, ...$future.value];
449}
450
451////////////////////////////////////////////
452// ⚡️
453////////////////////////////////////////////
454
455ostiary((context, _firstConnection, _connectionId) => {
456 // Setup RPC
457
458 rpc(context, {
459 add,
460 clear,
461 expel,
462 fill,
463 move,
464 shift,
465 supply,
466 unshift,
467
468 // State
469 future: $future.get,
470 now: $now.get,
471 past: $past.get,
472 supplyFingerprint: $supplyFingerprint.get,
473 });
474
475 // Effects
476
477 // Communicate state
478 effect(() => announce("future", $future.value, context));
479 effect(() => announce("now", $now.value, context));
480 effect(() => announce("past", $past.value, context));
481 effect(() =>
482 announce("supplyFingerprint", $supplyFingerprint.value, context)
483 );
484});
485
486////////////////////////////////////////////
487// ⛔️
488////////////////////////////////////////////
489
490/**
491 * Add non-manual items to the queue.
492 *
493 * @param {boolean} shuffled
494 * @param {number | undefined | null} fillAmount
495 * @param {Item[]} future
496 * @returns {Item[]}
497 */
498function fillQueue(shuffled, fillAmount, future) {
499 if (!fillAmount) return future;
500
501 // Count
502 let autoFutureCount = 0;
503
504 future.forEach((item) => {
505 if (item.manualEntry) {}
506 else autoFutureCount++;
507 });
508
509 // Fill
510 if (shuffled) {
511 if (autoFutureCount >= fillAmount) return future;
512 return fillShuffle(fillAmount, future, autoFutureCount);
513 } else {
514 return fillSequentially(fillAmount, future);
515 }
516}
517
518/**
519 * @param {number} fillAmount
520 * @param {Item[]} future
521 * @returns {Item[]}
522 *
523 * @example Fills sequentially from the start of the lake
524 * ```js
525 * import { fillSequentially, $lake, $now } from "~/components/engine/queue/worker.js";
526 *
527 * $lake.value = ["a", "b", "c", "d"];
528 * $now.value = null;
529 *
530 * const result = fillSequentially(3, []);
531 *
532 * if (result.length !== 3) throw new Error("expected 3 items");
533 * if (result[0].id !== "a") throw new Error("expected to start from 'a'");
534 * if (result[1].id !== "b") throw new Error("expected 'b' second");
535 * if (result[2].id !== "c") throw new Error("expected 'c' third");
536 * if (result[0].manualEntry !== false) throw new Error("auto items should have manualEntry: false");
537 * ```
538 *
539 * @example Continues from after the current now item
540 * ```js
541 * import { fillSequentially, $lake, $now } from "~/components/engine/queue/worker.js";
542 *
543 * $lake.value = ["a", "b", "c", "d"];
544 * $now.value = { id: "b", manualEntry: false };
545 *
546 * const result = fillSequentially(2, []);
547 *
548 * if (result[0].id !== "c") throw new Error("expected to start after now ('c')");
549 * if (result[1].id !== "d") throw new Error("expected 'd' second");
550 * ```
551 *
552 * @example Wraps around to the beginning when reaching the end of the lake
553 * ```js
554 * import { fillSequentially, $lake, $now } from "~/components/engine/queue/worker.js";
555 *
556 * $lake.value = ["a", "b", "c"];
557 * $now.value = { id: "b", manualEntry: false };
558 *
559 * const result = fillSequentially(3, []);
560 *
561 * if (result[0].id !== "c") throw new Error("expected 'c'");
562 * if (result[1].id !== "a") throw new Error("expected wrap around to 'a'");
563 * if (result[2].id !== "b") throw new Error("expected 'b'");
564 * ```
565 *
566 * @example Preserves existing manual entries
567 * ```js
568 * import { fillSequentially, $lake, $now } from "~/components/engine/queue/worker.js";
569 *
570 * $lake.value = ["a", "b", "c"];
571 * $now.value = null;
572 *
573 * const future = [{ id: "manual", manualEntry: true }];
574 * const result = fillSequentially(2, future);
575 *
576 * if (result[0].id !== "manual") throw new Error("manual entry should be preserved");
577 * if (result.length !== 3) throw new Error("expected manual + 2 auto items");
578 * ```
579 */
580export function fillSequentially(fillAmount, future) {
581 const onlyManual = future.filter((i) => i.manualEntry);
582 const lastManual = onlyManual.slice(-1)[0];
583 const startIndex = lastManual
584 ? $lake.value.indexOf(lastManual.id) + 1
585 : $now.value
586 ? $lake.value.indexOf($now.value.id) + 1
587 : 0;
588
589 const maxIndex = $lake.value.length - 1;
590 let currIndex = startIndex;
591
592 /** @type {Item[]} */
593 const autoItems = [];
594
595 for (let i = 0; i < fillAmount; i++) {
596 if (currIndex > maxIndex) currIndex = 0;
597 const id = $lake.value[currIndex];
598 if (id) {
599 autoItems.push({ id, key: nextKey(), manualEntry: false });
600 }
601 currIndex++;
602 }
603
604 return [...onlyManual, ...autoItems];
605}
606
607/**
608 * @param {number} fillAmount
609 * @param {Item[]} future
610 * @param {number} autoFutureCount
611 * @returns {Item[]}
612 *
613 * @example Adds shuffled items to reach the fill amount
614 * ```js
615 * import { fillShuffle, $lake, $past } from "~/components/engine/queue/worker.js";
616 *
617 * $lake.value = ["a", "b", "c", "d", "e"];
618 * $past.value = [];
619 *
620 * const result = fillShuffle(3, [], 0);
621 *
622 * if (result.length !== 3) throw new Error("expected 3 items");
623 * if (!result.every((i) => i.manualEntry === false)) throw new Error("all items should be auto");
624 * ```
625 *
626 * @example Only adds enough to reach the fill amount given existing auto items
627 * ```js
628 * import { fillShuffle, $lake, $past } from "~/components/engine/queue/worker.js";
629 *
630 * $lake.value = ["a", "b", "c", "d"];
631 * $past.value = [];
632 *
633 * const existing = [
634 * { id: "x", manualEntry: false },
635 * { id: "y", manualEntry: false },
636 * ];
637 *
638 * const result = fillShuffle(4, existing, 2);
639 *
640 * if (result.length !== 4) throw new Error("expected 4 total items (2 existing + 2 new)");
641 * ```
642 *
643 * @example Does not add tracks that have already played or are now playing
644 * ```js
645 * import { fillShuffle, $lake, $past, $now } from "~/components/engine/queue/worker.js";
646 *
647 * $lake.value = ["a", "b", "c", "d"];
648 * $past.value = [{ id: "a", manualEntry: false }];
649 * $now.value = { id: "b", manualEntry: false };
650 *
651 * const result = fillShuffle(4, [], 0);
652 *
653 * if (result.some((i) => i.id === "a" || i.id === "b")) throw new Error("past and now tracks should be excluded");
654 * ```
655 *
656 * @example Falls back to full lake when everything has been played
657 * ```js
658 * import { fillShuffle, $lake, $past } from "~/components/engine/queue/worker.js";
659 *
660 * $lake.value = ["a", "b"];
661 * $past.value = [{ id: "a", manualEntry: false }, { id: "b", manualEntry: false }];
662 *
663 * const result = fillShuffle(2, [], 0);
664 *
665 * if (result.length !== 2) throw new Error("expected 2 items from full lake fallback");
666 * ```
667 */
668export function fillShuffle(fillAmount, future, autoFutureCount) {
669 const excludeIds = new Set($past.value.map((i) => i.id));
670 if ($now.value) excludeIds.add($now.value.id);
671 future.forEach((i) => excludeIds.add(i.id));
672
673 let pool = $lake.value.filter((id) => !excludeIds.has(id));
674
675 // Fallback: if everything has been played/is playing/is queued, use tracks not in past or now
676 if (pool.length === 0) {
677 const pastAndNowIds = new Set($past.value.map((i) => i.id));
678 if ($now.value) pastAndNowIds.add($now.value.id);
679 pool = $lake.value.filter((id) => !pastAndNowIds.has(id));
680 }
681
682 // Final fallback: everything has been played, use the full lake
683 if (pool.length === 0) {
684 pool = [...$lake.value];
685 }
686
687 const selected = arrayShuffle(pool).slice(
688 0,
689 Math.max(0, fillAmount - autoFutureCount),
690 );
691
692 return [
693 ...future,
694 ...selected.map((id) => ({ id, key: nextKey(), manualEntry: false })),
695 ];
696}
697
698/**
699 * @param {Item[]} [future]
700 *
701 * @example Moves the first future item to now
702 * ```ts
703 * import { _shift, $future, $now } from "~/components/engine/queue/worker.js";
704 * import type { Item } from "./types.d.ts"
705 *
706 * $now.value = null as null | Item;
707 * $future.value = [{ id: "a", manualEntry: false }, { id: "b", manualEntry: false }];
708 *
709 * _shift();
710 *
711 * if ($now.value?.id !== "a") throw new Error("expected 'a' as now");
712 * if ($future.value.length !== 1) throw new Error("expected 1 item remaining in future");
713 * if ($future.value[0].id !== "b") throw new Error("expected 'b' remaining in future");
714 * ```
715 *
716 * @example Moves previous now to past
717 * ```js
718 * import { _shift, $future, $now, $past } from "~/components/engine/queue/worker.js";
719 *
720 * $past.value = [];
721 * $now.value = { id: "prev", manualEntry: false };
722 * $future.value = [{ id: "next", manualEntry: false }];
723 *
724 * _shift();
725 *
726 * if ($now.value?.id !== "next") throw new Error("expected 'next' as now");
727 * if ($past.value.length !== 1) throw new Error("expected 1 past item");
728 * if ($past.value[0].id !== "prev") throw new Error("expected 'prev' in past");
729 * ```
730 *
731 * @example Does nothing when future is empty
732 * ```js
733 * import { _shift, $future, $now } from "~/components/engine/queue/worker.js";
734 *
735 * $future.value = [];
736 * $now.value = null;
737 *
738 * _shift();
739 *
740 * if ($now.value !== null) throw new Error("now should remain null");
741 * ```
742 */
743export function _shift(future) {
744 const n = $now.value;
745 const f = future ?? $future.value;
746 const v = f[0];
747
748 if (!v) return;
749 $now.value = v;
750 if (n) $past.value = [...$past.value, n];
751 $future.value = f.slice(1);
752}