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