A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 71250e3e9ffd1e14fcc2db839b625bb50cffcd6c 611 lines 18 kB view raw
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}