A music player that connects to your cloud/distributed storage.
at v4 665 lines 18 kB view raw
1import { keyed } from "lit-html/directives/keyed.js"; 2 3import { BroadcastableDiffuseElement, nothing } from "@common/element.js"; 4import { computed, signal, untracked } from "@common/signal.js"; 5 6/** 7 * @import {Actions, Audio, AudioState, AudioStateReadOnly, LoadingState} from "./types.d.ts" 8 * @import {RenderArg} from "@common/element.d.ts" 9 * @import {SignalReader} from "@common/signal.d.ts" 10 */ 11 12//////////////////////////////////////////// 13// CONSTANTS 14//////////////////////////////////////////// 15const SILENT_MP3 = 16 "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV"; 17 18//////////////////////////////////////////// 19// ELEMENT 20//////////////////////////////////////////// 21 22/** 23 * @implements {Actions} 24 */ 25class AudioEngine extends BroadcastableDiffuseElement { 26 static NAME = "diffuse/engine/audio"; 27 28 constructor() { 29 super(); 30 31 this.isPlaying = this.isPlaying.bind(this); 32 this.state = this.state.bind(this); 33 } 34 35 // SIGNALS 36 37 #items = signal(/** @type {Audio[]} */ ([])); 38 #volume = signal(0.5); 39 40 // STATE 41 42 items = this.#items.get; 43 volume = this.#volume.get; 44 45 // LIFECYCLE 46 47 /** 48 * @override 49 */ 50 connectedCallback() { 51 // Setup broadcasting if part of group 52 if (this.hasAttribute("group")) { 53 const actions = this.broadcast( 54 this.nameWithGroup, 55 { 56 adjustVolume: { strategy: "replicate", fn: this.adjustVolume }, 57 pause: { strategy: "leaderOnly", fn: this.pause }, 58 play: { strategy: "leaderOnly", fn: this.play }, 59 seek: { strategy: "leaderOnly", fn: this.seek }, 60 supply: { strategy: "replicate", fn: this.supply }, 61 62 // State 63 items: { strategy: "leaderOnly", fn: this.items }, 64 }, 65 ); 66 67 if (!actions) return; 68 69 this.adjustVolume = actions.adjustVolume; 70 this.pause = actions.pause; 71 this.play = actions.play; 72 this.seek = actions.seek; 73 this.supply = actions.supply; 74 75 // Sync items with leader if needed 76 this.broadcastingStatus().then(async (status) => { 77 if (status.leader) return; 78 this.#items.value = await actions.items(); 79 }); 80 } 81 82 // Super 83 super.connectedCallback(); 84 85 // Get volume from previous session if possible 86 const VOLUME_KEY = 87 `${this.constructor.prototype.constructor.NAME}/${this.group}/volume`; 88 const volume = localStorage.getItem(VOLUME_KEY); 89 90 if (volume != undefined) { 91 this.#volume.set(parseFloat(volume)); 92 } 93 94 // Monitor volume signal 95 this.effect(() => { 96 Array.from(this.querySelectorAll("de-audio-item")).forEach( 97 (node) => { 98 const item = /** @type {AudioEngineItem} */ (node); 99 if (item.hasAttribute("preload")) return; 100 const audio = item.querySelector("audio"); 101 if (audio) audio.volume = this.#volume.value; 102 }, 103 ); 104 105 localStorage.setItem(VOLUME_KEY, this.#volume.value.toString()); 106 }); 107 108 // Only broadcasting stuff from here on out 109 if (!this.broadcasted) return; 110 111 // Manage playback across tabs if needed 112 this.effect(async () => { 113 const status = await this.broadcastingStatus(); 114 untracked(() => { 115 if (!(status.leader && status.initialLeader === false)) return; 116 117 console.log("🧙 Leadership acquired"); 118 this.items().forEach((item) => { 119 const el = this.#itemElement(item.id); 120 if (!el) return; 121 122 el.removeAttribute("initial-progress"); 123 124 if (!el.audio) return; 125 126 const progress = el.$state.progress.value; 127 const canPlay = () => { 128 this.seek({ 129 audioId: item.id, 130 percentage: progress, 131 }); 132 133 if (el.$state.isPlaying.value) this.play({ audioId: item.id }); 134 }; 135 136 el.audio.addEventListener("canplay", canPlay, { once: true }); 137 138 if (el.audio.readyState === 0) el.audio.load(); 139 else canPlay(); 140 }); 141 }); 142 }); 143 } 144 145 // ACTIONS 146 147 /** 148 * @type {Actions["adjustVolume"]} 149 */ 150 adjustVolume(args) { 151 if (args.audioId) { 152 this.#withAudioNode(args.audioId, (audio) => { 153 audio.volume = args.volume; 154 }); 155 } else { 156 this.#volume.value = args.volume; 157 } 158 } 159 160 /** 161 * @type {Actions["pause"]} 162 */ 163 pause({ audioId }) { 164 this.#withAudioNode(audioId, (audio) => audio.pause()); 165 } 166 167 /** 168 * @type {Actions["play"]} 169 */ 170 play({ audioId, volume }) { 171 this.#withAudioNode(audioId, (audio, item) => { 172 audio.volume = volume ?? this.volume(); 173 audio.muted = false; 174 175 // TODO: Might need this for `data-initial-progress` 176 // Does seem to cause trouble when broadcasting 177 // (open multiple sessions and play the next audio) 178 // if (audio.readyState === 0) audio.load(); 179 if (!audio.isConnected) return; 180 181 const promise = audio.play() || Promise.resolve(); 182 item.$state.isPlaying.set(true); 183 184 promise.catch((e) => { 185 if (!audio.isConnected) { 186 return; /* The node was removed from the DOM, we can ignore this error */ 187 } 188 const err = 189 "Couldn't play audio automatically. Please resume playback manually."; 190 console.error(err, e); 191 item.$state.isPlaying.set(false); 192 }); 193 }); 194 } 195 196 /** 197 * @type {Actions["reload"]} 198 */ 199 reload(args) { 200 this.#withAudioNode(args.audioId, (audio, item) => { 201 if (audio.readyState === 0 || audio.error?.code === 2) { 202 audio.load(); 203 204 if (args.progress !== undefined) { 205 item.setAttribute( 206 "initial-progress", 207 JSON.stringify(args.progress), 208 ); 209 } 210 211 if (args.play) { 212 this.play({ audioId: args.audioId, volume: audio.volume }); 213 } 214 } 215 }); 216 } 217 218 /** 219 * @type {Actions["seek"]} 220 */ 221 seek({ audioId, percentage }) { 222 this.#withAudioNode(audioId, (audio) => { 223 if (!isNaN(audio.duration)) { 224 audio.currentTime = audio.duration * percentage; 225 } 226 }); 227 } 228 229 /** 230 * @type {Actions["supply"]} 231 */ 232 supply(args) { 233 const existingSet = new Set(this.#items.value.map((a) => a.id)); 234 const newSet = new Set(args.audio.map((a) => a.id)); 235 236 if (newSet.difference(existingSet).size !== 0) { 237 this.#items.value = args.audio; 238 } 239 240 if (args.play) this.play(args.play); 241 } 242 243 // RENDER 244 245 /** 246 * @param {RenderArg} _ 247 */ 248 render({ html }) { 249 const ids = this.items().map((i) => i.id); 250 251 this.querySelectorAll("de-audio-item").forEach((element) => { 252 if (ids.includes(element.id)) return; 253 254 const source = element.querySelector("source"); 255 if (source) source.src = SILENT_MP3; 256 }); 257 258 const group = this.group; 259 const nodes = this.items().map((audio) => { 260 const ip = audio.progress === undefined 261 ? "0" 262 : JSON.stringify(audio.progress); 263 264 return keyed( 265 audio.id, 266 html` 267 <de-audio-item 268 group="${this.broadcasted ? `${group}/${audio.id}` : nothing}" 269 id="${audio.id}" 270 initial-progress="${ip}" 271 mime-type="${audio.mimeType ? audio.mimeType : nothing}" 272 preload="${audio.isPreload ? `preload` : nothing}" 273 url="${audio.url}" 274 > 275 <audio 276 crossorigin="anonymous" 277 muted="true" 278 preload="auto" 279 > 280 <source 281 src="${audio.url}" 282 ${audio.mimeType ? 'type="' + audio.mimeType + '"' : ""} 283 /> 284 </audio> 285 </de-audio-item> 286 `, 287 ); 288 }); 289 290 return html` 291 <section id="audio-nodes"> 292 ${nodes} 293 </section> 294 `; 295 } 296 297 // 🛠️ 298 299 /** 300 * Convenience signal to track if something is, or was, playing. 301 */ 302 _isPlaying() { 303 return computed(() => { 304 const item = this.items()?.[0]; 305 if (!item) return false; 306 307 const state = this.state(item.id); 308 if (!state) return false; 309 310 return state.isPlaying() || state.hasEnded() || state.progress() === 1; 311 }); 312 } 313 314 /** 315 * Get the state of a single audio item. 316 * 317 * @param {string} audioId 318 * @returns {SignalReader<AudioStateReadOnly | undefined>} 319 */ 320 _state(audioId) { 321 return computed(() => { 322 const _trigger = this.#items.value; 323 324 const s = this.#itemElement(audioId)?.state; 325 return s ? { ...s } : undefined; 326 }); 327 } 328 329 /** 330 * Convenience signal to track if something is, or was, playing. 331 */ 332 isPlaying() { 333 return this._isPlaying()(); 334 } 335 336 /** 337 * Get the state of a single audio item. 338 * 339 * @param {string} audioId 340 * @returns {AudioStateReadOnly | undefined} 341 */ 342 state(audioId) { 343 return this._state(audioId)(); 344 } 345 346 /** 347 * @param {string} audioId 348 */ 349 #itemElement(audioId) { 350 const node = this.querySelector( 351 `de-audio-item[id="${audioId}"]:not([preload])`, 352 ); 353 354 if (node) { 355 const item = /** @type {AudioEngineItem} */ (node); 356 return item; 357 } 358 } 359 360 /** 361 * @param {string} audioId 362 * @param {(audio: HTMLAudioElement, item: AudioEngineItem) => void} fn 363 */ 364 #withAudioNode(audioId, fn) { 365 const item = this.#itemElement(audioId); 366 if (item) fn(item.audio, item); 367 } 368} 369 370export default AudioEngine; 371 372//////////////////////////////////////////// 373// ITEM ELEMENT 374//////////////////////////////////////////// 375 376class AudioEngineItem extends BroadcastableDiffuseElement { 377 static NAME = "diffuse/engine/audio/item"; 378 379 constructor() { 380 super(); 381 382 const ip = this.getAttribute("initial-progress"); 383 384 /** 385 * @type {AudioState} 386 */ 387 this.$state = { 388 duration: signal(0), 389 hasEnded: signal(false), 390 isPlaying: signal(false), 391 isPreload: signal(this.hasAttribute("preload")), 392 loadingState: signal(/** @type {LoadingState} */ ("loading")), 393 progress: signal(ip ? parseFloat(ip) : 0), 394 }; 395 } 396 397 // LIFECYCLE 398 399 /** 400 * @override 401 */ 402 async connectedCallback() { 403 const audio = this.audio; 404 405 audio.addEventListener("canplay", this.canplayEvent); 406 audio.addEventListener("durationchange", this.durationchangeEvent); 407 audio.addEventListener("ended", this.endedEvent); 408 audio.addEventListener("error", this.errorEvent); 409 audio.addEventListener("pause", this.pauseEvent); 410 audio.addEventListener("play", this.playEvent); 411 audio.addEventListener("suspend", this.suspendEvent); 412 audio.addEventListener("timeupdate", this.timeupdateEvent); 413 audio.addEventListener("waiting", this.waitingEvent); 414 415 // Setup broadcasting if part of group 416 if (this.hasAttribute("group")) { 417 const actions = this.broadcast( 418 this.nameWithGroup, 419 { 420 getDuration: { strategy: "leaderOnly", fn: this.$state.duration.get }, 421 getHasEnded: { strategy: "leaderOnly", fn: this.$state.hasEnded.get }, 422 getIsPlaying: { 423 strategy: "leaderOnly", 424 fn: this.$state.isPlaying.get, 425 }, 426 getIsPreload: { 427 strategy: "leaderOnly", 428 fn: this.$state.isPreload.get, 429 }, 430 getLoadingState: { 431 strategy: "leaderOnly", 432 fn: this.$state.loadingState.get, 433 }, 434 getProgress: { strategy: "leaderOnly", fn: this.$state.progress.get }, 435 436 // SET 437 setDuration: { strategy: "replicate", fn: this.$state.duration.set }, 438 setHasEnded: { strategy: "replicate", fn: this.$state.hasEnded.set }, 439 setIsPlaying: { 440 strategy: "replicate", 441 fn: this.$state.isPlaying.set, 442 }, 443 setIsPreload: { 444 strategy: "replicate", 445 fn: this.$state.isPreload.set, 446 }, 447 setLoadingState: { 448 strategy: "replicate", 449 fn: this.$state.loadingState.set, 450 }, 451 setProgress: { strategy: "replicate", fn: this.$state.progress.set }, 452 }, 453 { 454 // Sync leadership with engine's broadcasting channel 455 assumeLeadership: (await this.engine?.broadcastingStatus())?.leader, 456 }, 457 ); 458 459 if (actions) { 460 this.$state.duration.set = actions.setDuration; 461 this.$state.hasEnded.set = actions.setHasEnded; 462 this.$state.isPlaying.set = actions.setIsPlaying; 463 this.$state.isPreload.set = actions.setIsPreload; 464 this.$state.loadingState.set = actions.setLoadingState; 465 this.$state.progress.set = actions.setProgress; 466 467 untracked(async () => { 468 this.$state.duration.value = await actions.getDuration(); 469 this.$state.hasEnded.value = await actions.getHasEnded(); 470 this.$state.isPlaying.value = await actions.getIsPlaying(); 471 this.$state.isPreload.value = await actions.getIsPreload(); 472 this.$state.loadingState.value = await actions.getLoadingState(); 473 this.$state.progress.value = await actions.getProgress(); 474 }); 475 } 476 } 477 478 // Super 479 super.connectedCallback(); 480 } 481 482 // STATE 483 484 /** 485 * @type {AudioStateReadOnly} 486 */ 487 get state() { 488 return { 489 id: this.id, 490 mimeType: (this.getAttribute("mime-type") ?? undefined), 491 url: (this.getAttribute("url") ?? ""), 492 493 duration: this.$state.duration.get, 494 hasEnded: this.$state.hasEnded.get, 495 isPlaying: this.$state.isPlaying.get, 496 isPreload: this.$state.isPreload.get, 497 loadingState: this.$state.loadingState.get, 498 progress: this.$state.progress.get, 499 }; 500 } 501 502 // RELATED ELEMENTS 503 504 get audio() { 505 const el = this.querySelector("audio"); 506 if (el) return /** @type {HTMLAudioElement} */ (el); 507 else throw new Error("Cannot find child audio element"); 508 } 509 510 get engine() { 511 const el = this.closest("de-audio"); 512 if (el) return /** @type {AudioEngine} */ (el); 513 else return null; 514 } 515 516 // EVENTS 517 518 /** 519 * @param {Event} event 520 */ 521 canplayEvent(event) { 522 const audio = /** @type {HTMLAudioElement} */ (event.target); 523 const item = engineItem(audio); 524 525 if ( 526 item?.hasAttribute("initial-progress") && 527 audio.duration && 528 !isNaN(audio.duration) 529 ) { 530 const progress = JSON.parse( 531 item.getAttribute("initial-progress") ?? "0", 532 ); 533 audio.currentTime = audio.duration * progress; 534 item.removeAttribute("initial-progress"); 535 } 536 537 finishedLoading(event); 538 } 539 540 /** 541 * @param {Event} event 542 */ 543 durationchangeEvent(event) { 544 const audio = /** @type {HTMLAudioElement} */ (event.target); 545 546 if (!isNaN(audio.duration)) { 547 engineItem(audio)?.$state.duration.set(audio.duration); 548 } 549 } 550 551 /** 552 * @param {Event} event 553 */ 554 endedEvent(event) { 555 const audio = /** @type {HTMLAudioElement} */ (event.target); 556 audio.currentTime = 0; 557 558 engineItem(audio)?.$state.hasEnded.set(true); 559 } 560 561 /** 562 * @param {Event} event 563 */ 564 errorEvent(event) { 565 const audio = /** @type {HTMLAudioElement} */ (event.target); 566 const code = audio.error?.code || 0; 567 568 engineItem(audio)?.$state.loadingState.set({ error: { code } }); 569 } 570 571 /** 572 * @param {Event} event 573 */ 574 pauseEvent(event) { 575 const audio = /** @type {HTMLAudioElement} */ (event.target); 576 const item = engineItem(audio); 577 578 item?.$state.isPlaying.set(false); 579 } 580 581 /** 582 * @param {Event} event 583 */ 584 playEvent(event) { 585 const audio = /** @type {HTMLAudioElement} */ (event.target); 586 587 const item = engineItem(audio); 588 item?.$state.hasEnded.set(false); 589 item?.$state.isPlaying.set(true); 590 591 // In case audio was preloaded: 592 if (audio.readyState === 4) finishedLoading(event); 593 } 594 595 /** 596 * @param {Event} event 597 */ 598 suspendEvent(event) { 599 finishedLoading(event); 600 } 601 602 /** 603 * @param {Event} event 604 */ 605 timeupdateEvent(event) { 606 const audio = /** @type {HTMLAudioElement} */ (event.target); 607 if (isNaN(audio.duration) || audio.duration === 0) return; 608 609 const progress = audio.currentTime / audio.duration; 610 if (progress === 0) return; 611 612 engineItem(audio)?.$state.progress.set(progress); 613 } 614 615 /** 616 * @param {Event} event 617 */ 618 waitingEvent(event) { 619 initiateLoading(event); 620 } 621} 622 623export { AudioEngineItem }; 624 625//////////////////////////////////////////// 626// 🛠️ 627//////////////////////////////////////////// 628 629/** 630 * @param {HTMLAudioElement} audio 631 */ 632function engineItem(audio) { 633 const c = audio.closest("de-audio-item"); 634 if (c) return /** @type {AudioEngineItem} */ (c); 635 else return null; 636} 637 638/** 639 * @param {Event} event 640 */ 641function finishedLoading(event) { 642 const audio = /** @type {HTMLAudioElement} */ (event.target); 643 engineItem(audio)?.$state.loadingState.set("loaded"); 644} 645 646/** 647 * @param {Event} event 648 */ 649function initiateLoading(event) { 650 const audio = /** @type {HTMLAudioElement} */ (event.target); 651 if (audio.readyState < 4) { 652 engineItem(audio)?.$state.loadingState.set("loading"); 653 } 654} 655 656//////////////////////////////////////////// 657// REGISTER 658//////////////////////////////////////////// 659 660export const CLASS = AudioEngine; 661export const NAME = "de-audio"; 662export const NAME_ITEM = "de-audio-item"; 663 664customElements.define(NAME, AudioEngine); 665customElements.define(NAME_ITEM, AudioEngineItem);