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

feat: broadcasted audio

+119 -21
+8 -3
src/common/element.js
··· 7 7 import { BrowserPostMessageIo } from "./worker/rpc.js"; 8 8 9 9 /** 10 - * @import {BroadcastingStatus, ProvisionedWorker, ProvisionedWorkers, WorkerOpts} from "./element.d.ts" 10 + * @import {BroadcastingStatus, WorkerOpts} from "./element.d.ts" 11 11 * @import {ProxiedActions, Tunnel} from "./worker.d.ts"; 12 12 * @import {Signal} from "./signal.d.ts" 13 13 */ 14 14 15 + export { nothing } from "lit-html"; 15 16 export const DEFAULT_GROUP = "default"; 16 17 17 18 /** ··· 22 23 #connected = Promise.withResolvers(); 23 24 #disposables = /** @type {Array<() => void>} */ ([]); 24 25 26 + /** */ 25 27 constructor() { 26 28 super(); 27 - 28 - this.group = this.getAttribute("group") ?? DEFAULT_GROUP; 29 29 30 30 this.worker = this.worker.bind(this); 31 31 this.workerLink = this.workerLink.bind(this); ··· 53 53 /** */ 54 54 forceRender() { 55 55 return this.#render(); 56 + } 57 + 58 + /** */ 59 + get group() { 60 + return this.getAttribute("group") ?? DEFAULT_GROUP; 56 61 } 57 62 58 63 /** */
+111 -18
src/components/engine/audio/element.js
··· 1 1 import { keyed } from "lit-html/directives/keyed.js"; 2 2 3 - import { BroadcastableDiffuseElement } from "@common/element.js"; 4 - import { computed, signal } from "@common/signal.js"; 3 + import { BroadcastableDiffuseElement, nothing } from "@common/element.js"; 4 + import { computed, signal, untracked } from "@common/signal.js"; 5 5 6 6 /** 7 7 * @import {Actions, Audio, AudioState, AudioStateReadOnly, LoadingState} from "./types.d.ts" ··· 48 48 * @override 49 49 */ 50 50 connectedCallback() { 51 - // Setup leader election if shared 51 + // Setup broadcasting if part of group 52 52 if (this.hasAttribute("group")) { 53 53 const actions = this.broadcast( 54 54 this.nameWithGroup(), 55 55 { 56 - adjustVolume: { strategy: "leaderOnly", fn: this.adjustVolume }, 56 + adjustVolume: { strategy: "replicate", fn: this.adjustVolume }, 57 57 pause: { strategy: "leaderOnly", fn: this.pause }, 58 58 play: { strategy: "leaderOnly", fn: this.play }, 59 59 seek: { strategy: "leaderOnly", fn: this.seek }, ··· 86 86 if (this.broadcasted) { 87 87 this.effect(async () => { 88 88 const status = await this.broadcastingStatus(); 89 - if (status.leader && status.initialLeader === false) { 90 - console.log("🧙 Leadership acquired (no actions performed)"); 91 - } 89 + untracked(() => { 90 + if (!(status.leader && status.initialLeader === false)) return; 91 + 92 + console.log("🧙 Leadership acquired"); 93 + this.items().forEach((item) => { 94 + const el = this.#itemElement(item.id); 95 + if (!el) return; 96 + 97 + el.removeAttribute("initial-progress"); 98 + 99 + if (!el.audio) return; 100 + 101 + const progress = el.$state.progress.value; 102 + const canPlay = () => { 103 + this.seek({ 104 + audioId: item.id, 105 + percentage: progress, 106 + }); 107 + 108 + if (el.$state.isPlaying.value) this.play({ audioId: item.id }); 109 + }; 110 + 111 + el.audio.addEventListener("canplay", canPlay, { once: true }); 112 + 113 + if (el.audio.readyState === 0) el.audio.load(); 114 + else canPlay(); 115 + }); 116 + }); 92 117 }); 93 118 } 94 119 ··· 211 236 if (source) source.src = SILENT_MP3; 212 237 }); 213 238 239 + const group = this.group; 214 240 const nodes = this.items().map((audio) => { 215 241 const ip = audio.progress === undefined 216 242 ? "0" ··· 220 246 audio.id, 221 247 html` 222 248 <de-audio-item 249 + group="${this.broadcasted ? `${group}/${audio.id}` : nothing}" 223 250 id="${audio.id}" 224 251 initial-progress="${ip}" 252 + mime-type="${audio.mimeType ? audio.mimeType : nothing}" 253 + preload="${audio.isPreload ? `preload` : nothing}" 225 254 url="${audio.url}" 226 - ${audio.isPreload ? "preload" : ""} 227 - ${audio.mimeType ? 'mime-type="' + audio.mimeType + '"' : ""} 228 255 > 229 256 <audio 230 257 crossorigin="anonymous" ··· 327 354 // ITEM ELEMENT 328 355 //////////////////////////////////////////// 329 356 330 - class AudioEngineItem extends HTMLElement { 357 + class AudioEngineItem extends BroadcastableDiffuseElement { 358 + static NAME = "diffuse/engine/audio/item"; 359 + 331 360 constructor() { 332 361 super(); 333 362 ··· 344 373 loadingState: signal(/** @type {LoadingState} */ ("loading")), 345 374 progress: signal(ip ? parseFloat(ip) : 0), 346 375 }; 376 + } 347 377 378 + // LIFECYCLE 379 + 380 + /** 381 + * @override 382 + */ 383 + connectedCallback() { 348 384 const audio = this.audio; 349 385 350 386 audio.addEventListener("canplay", this.canplayEvent); ··· 356 392 audio.addEventListener("suspend", this.suspendEvent); 357 393 audio.addEventListener("timeupdate", this.timeupdateEvent); 358 394 audio.addEventListener("waiting", this.waitingEvent); 359 - } 395 + 396 + // Setup broadcasting if part of group 397 + if (this.hasAttribute("group")) { 398 + const actions = this.broadcast( 399 + this.nameWithGroup(), 400 + { 401 + getDuration: { strategy: "leaderOnly", fn: this.$state.duration.get }, 402 + getHasEnded: { strategy: "leaderOnly", fn: this.$state.hasEnded.get }, 403 + getIsPlaying: { 404 + strategy: "leaderOnly", 405 + fn: this.$state.isPlaying.get, 406 + }, 407 + getIsPreload: { 408 + strategy: "leaderOnly", 409 + fn: this.$state.isPreload.get, 410 + }, 411 + getLoadingState: { 412 + strategy: "leaderOnly", 413 + fn: this.$state.loadingState.get, 414 + }, 415 + getProgress: { strategy: "leaderOnly", fn: this.$state.progress.get }, 360 416 361 - // LIFECYCLE 417 + // SET 418 + setDuration: { strategy: "replicate", fn: this.$state.duration.set }, 419 + setHasEnded: { strategy: "replicate", fn: this.$state.hasEnded.set }, 420 + setIsPlaying: { 421 + strategy: "replicate", 422 + fn: this.$state.isPlaying.set, 423 + }, 424 + setIsPreload: { 425 + strategy: "replicate", 426 + fn: this.$state.isPreload.set, 427 + }, 428 + setLoadingState: { 429 + strategy: "replicate", 430 + fn: this.$state.loadingState.set, 431 + }, 432 + setProgress: { strategy: "replicate", fn: this.$state.progress.set }, 433 + }, 434 + ); 362 435 363 - connectedCallback() { 436 + if (actions) { 437 + this.$state.duration.set = actions.setDuration; 438 + this.$state.hasEnded.set = actions.setHasEnded; 439 + this.$state.isPlaying.set = actions.setIsPlaying; 440 + this.$state.isPreload.set = actions.setIsPreload; 441 + this.$state.loadingState.set = actions.setLoadingState; 442 + this.$state.progress.set = actions.setProgress; 443 + 444 + untracked(async () => { 445 + this.$state.duration.value = await actions.getDuration(); 446 + this.$state.hasEnded.value = await actions.getHasEnded(); 447 + this.$state.isPlaying.value = await actions.getIsPlaying(); 448 + this.$state.isPreload.value = await actions.getIsPreload(); 449 + this.$state.loadingState.value = await actions.getLoadingState(); 450 + this.$state.progress.value = await actions.getProgress(); 451 + }); 452 + } 453 + } 454 + 455 + // Super 456 + super.connectedCallback(); 364 457 } 365 458 366 459 // STATE ··· 488 581 */ 489 582 timeupdateEvent(event) { 490 583 const audio = /** @type {HTMLAudioElement} */ (event.target); 584 + if (isNaN(audio.duration) || audio.duration === 0) return; 491 585 492 - engineItem(audio)?.$state.progress.set( 493 - isNaN(audio.duration) || audio.duration === 0 494 - ? 0 495 - : audio.currentTime / audio.duration, 496 - ); 586 + const progress = audio.currentTime / audio.duration; 587 + if (progress === 0) return; 588 + 589 + engineItem(audio)?.$state.progress.set(progress); 497 590 } 498 591 499 592 /**