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 e68f727ec6d8bde0a9a06f73af2ebae7f10ffc5f 907 lines 25 kB view raw
1import { keyed } from "lit-html/directives/keyed.js"; 2 3import { BroadcastableDiffuseElement, defineElement, nothing } from "~/common/element.js"; 4import { computed, signal, untracked } from "~/common/signal.js"; 5 6/** 7 * @import {Actions, AudioUrl, 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 {AudioUrl[]} */ ([])); 38 #volume = signal(0.75); 39 40 /** @type {Map<string, ReadableStream>} Streams pending MediaSource setup */ 41 #streams = new Map(); 42 43 /** @type {Map<string, string>} MediaSource object URLs created from streams, keyed by item ID */ 44 #mediaSourceUrls = new Map(); 45 46 // STATE 47 48 items = this.#items.get; 49 volume = this.#volume.get; 50 51 // LIFECYCLE 52 53 /** 54 * @override 55 */ 56 connectedCallback() { 57 // Setup broadcasting if part of group 58 if (this.hasAttribute("group")) { 59 const actions = this.broadcast( 60 this.identifier, 61 { 62 adjustVolume: { strategy: "replicate", fn: this.adjustVolume }, 63 pause: { strategy: "leaderOnly", fn: this.pause }, 64 play: { strategy: "leaderOnly", fn: this.play }, 65 seek: { strategy: "leaderOnly", fn: this.seek }, 66 supply: { strategy: "replicate", fn: this.supply }, 67 68 // State 69 items: { strategy: "leaderOnly", fn: this.items }, 70 }, 71 ); 72 73 if (!actions) return; 74 75 this.adjustVolume = actions.adjustVolume; 76 this.pause = actions.pause; 77 this.play = actions.play; 78 this.seek = actions.seek; 79 this.supply = actions.supply; 80 81 // Sync items with leader if needed 82 this.broadcastingStatus().then(async (status) => { 83 if (status.leader) return; 84 this.#items.value = await actions.items(); 85 }); 86 } 87 88 // Super 89 super.connectedCallback(); 90 91 // Get volume from previous session if possible 92 const VOLUME_KEY = 93 `${this.constructor.prototype.constructor.NAME}/${this.group}/volume`; 94 const volume = localStorage.getItem(VOLUME_KEY); 95 96 if (volume != undefined) { 97 this.#volume.set(parseFloat(volume)); 98 } 99 100 // Monitor volume signal 101 this.effect(() => { 102 Array.from(this.querySelectorAll("de-audio-item")).forEach( 103 (node) => { 104 const item = /** @type {AudioEngineItem} */ (node); 105 if (item.hasAttribute("preload")) return; 106 const audio = item.querySelector("audio"); 107 if (audio) audio.volume = this.#volume.value; 108 }, 109 ); 110 111 localStorage.setItem(VOLUME_KEY, this.#volume.value.toString()); 112 }); 113 114 // Only broadcasting stuff from here on out 115 if (!this.broadcasted) return; 116 117 // Manage playback across tabs if needed 118 this.effect(async () => { 119 const status = await this.broadcastingStatus(); 120 untracked(() => { 121 if (!(status.leader && status.initialLeader === false)) return; 122 123 console.log("🧙 Leadership acquired"); 124 this.items().forEach((item) => { 125 const el = this.#itemElement(item.id); 126 if (!el) return; 127 128 el.removeAttribute("initial-progress"); 129 130 if (!el.audio) return; 131 132 const currentTime = el.$state.currentTime.value; 133 const canPlay = () => { 134 this.seek({ 135 audioId: item.id, 136 currentTime: currentTime, 137 }); 138 139 if (el.$state.isPlaying.value) this.play({ audioId: item.id }); 140 }; 141 142 el.audio.addEventListener("canplay", canPlay, { once: true }); 143 144 if (el.audio.readyState === 0) el.audio.load(); 145 else canPlay(); 146 }); 147 }); 148 }); 149 } 150 151 // ACTIONS 152 153 /** 154 * @type {Actions["adjustVolume"]} 155 */ 156 adjustVolume(args) { 157 if (args.audioId) { 158 this.#withAudioNode(args.audioId, (audio) => { 159 audio.volume = args.volume; 160 }); 161 } else { 162 this.#volume.value = args.volume; 163 } 164 } 165 166 /** 167 * @type {Actions["pause"]} 168 */ 169 pause({ audioId }) { 170 this.#withAudioNode(audioId, (audio) => audio.pause()); 171 } 172 173 /** 174 * @type {Actions["play"]} 175 */ 176 play({ audioId, volume }) { 177 this.#withAudioNode(audioId, (audio, item) => { 178 audio.volume = volume ?? this.volume(); 179 audio.muted = false; 180 181 // TODO: Might need this for `data-initial-progress` 182 // Does seem to cause trouble when broadcasting 183 // (open multiple sessions and play the next audio) 184 // if (audio.readyState === 0) audio.load(); 185 if (!audio.isConnected) return; 186 187 const promise = audio.play() || Promise.resolve(); 188 item.$state.isPlaying.set(true); 189 190 promise.catch((e) => { 191 if (!audio.isConnected) { 192 return; /* The node was removed from the DOM, we can ignore this error */ 193 } 194 const err = 195 "Couldn't play audio automatically. Please resume playback manually."; 196 console.error(err, e); 197 item.$state.isPlaying.set(false); 198 }); 199 }); 200 } 201 202 /** 203 * @type {Actions["reload"]} 204 */ 205 reload(args) { 206 this.#withAudioNode(args.audioId, (audio, item) => { 207 if (audio.readyState === 0 || audio.error?.code === 2) { 208 audio.load(); 209 210 if (args.progress !== undefined) { 211 item.setAttribute( 212 "initial-progress", 213 JSON.stringify(args.progress), 214 ); 215 } 216 217 if (args.play) { 218 this.play({ audioId: args.audioId, volume: audio.volume }); 219 } 220 } 221 }); 222 } 223 224 /** 225 * @type {Actions["seek"]} 226 */ 227 seek({ audioId, currentTime, percentage }) { 228 this.#withAudioNode(audioId, (audio) => { 229 if (currentTime != undefined) { 230 audio.currentTime = currentTime; 231 } else if ( 232 percentage != undefined && !isNaN(audio.duration) && 233 audio.duration !== Infinity 234 ) { 235 audio.currentTime = percentage * audio.duration; 236 } 237 }); 238 } 239 240 /** 241 * @type {Actions["supply"]} 242 */ 243 supply(args) { 244 const existingMap = new Map(this.#items.value.map((a) => [a.id, a])); 245 246 // Start loading new streams 247 for (const item of args.audio) { 248 if ( 249 "stream" in item && 250 !existingMap.has(item.id) && 251 !this.#streams.has(item.id) 252 ) { 253 this.#streams.set(item.id, item.stream); 254 this.#resolveStream( 255 item.id, 256 item.stream, 257 item.mimeType ?? "", 258 item.seek, 259 item.duration, 260 ); 261 } 262 } 263 264 // Stop streams that are no longer needed 265 const newIds = new Set(args.audio.map((a) => a.id)); 266 267 for (const [id, objectUrl] of this.#mediaSourceUrls) { 268 if (!newIds.has(id)) { 269 URL.revokeObjectURL(objectUrl); 270 this.#mediaSourceUrls.delete(id); 271 } 272 } 273 274 for (const id of this.#streams.keys()) { 275 if (!newIds.has(id)) this.#streams.delete(id); 276 } 277 278 /** @type {AudioUrl[]} Remove `stream` field, replace it with `url` */ 279 const resolvedAudio = args.audio.map((a) => { 280 const url = "stream" in a ? this.#mediaSourceUrls.get(a.id) : a.url; 281 282 if (!url) { 283 throw new Error("Stream did not produce a media source url"); 284 } 285 286 return { 287 id: a.id, 288 isPreload: a.isPreload, 289 mimeType: a.mimeType, 290 progress: a.progress, 291 track: a.track, 292 url, 293 }; 294 }); 295 296 const hasNewIds = resolvedAudio.some((a) => !existingMap.has(a.id)); 297 const hasPreloadChanges = resolvedAudio.some( 298 (a) => existingMap.get(a.id)?.isPreload !== a.isPreload, 299 ); 300 301 const hasUrlChanges = resolvedAudio.some( 302 (a) => existingMap.get(a.id)?.url !== a.url, 303 ); 304 305 if (hasNewIds || hasPreloadChanges || hasUrlChanges) { 306 this.#items.value = resolvedAudio; 307 } 308 309 // When only the URL changed for an existing item (e.g. tab leadership handoff invalidated 310 // a blob URL), the same <de-audio-item> element is reused via `keyed`. lit-html will 311 // update <source src> but the browser won't reload on its own — call audio.load() if the 312 // element hasn't successfully loaded yet so it picks up the fresh URL. 313 if (hasUrlChanges && !hasNewIds) { 314 for (const a of resolvedAudio) { 315 if (existingMap.has(a.id) && existingMap.get(a.id)?.url !== a.url) { 316 this.#withAudioNode(a.id, (audio) => { 317 if (audio.readyState === 0 || audio.error) audio.load(); 318 }); 319 } 320 } 321 } 322 323 if (args.play) this.play(args.play); 324 } 325 326 // STREAMS 327 328 /** 329 * @param {string} id 330 * @param {ReadableStream} stream 331 * @param {string} mimeType 332 * @param {((timeSeconds: number) => Promise<ReadableStream>) | undefined} seekFn 333 * @param {number | undefined} duration 334 */ 335 async #resolveStream(id, stream, mimeType, seekFn, duration) { 336 const mediaSource = new MediaSource(); 337 const objectUrl = URL.createObjectURL(mediaSource); 338 339 this.#mediaSourceUrls.set(id, objectUrl); 340 this.#streams.delete(id); 341 342 // Yield so the render triggered by supply() can complete, ensuring the 343 // audio element is in the DOM before we set its src. 344 await Promise.resolve(); 345 346 if (!this.#mediaSourceUrls.has(id)) { 347 // Item was removed while waiting 348 URL.revokeObjectURL(objectUrl); 349 return; 350 } 351 352 const itemEl = this.#itemElement(id); 353 if (!itemEl) { 354 URL.revokeObjectURL(objectUrl); 355 this.#mediaSourceUrls.delete(id); 356 return; 357 } 358 359 // MediaSource must be attached via audio.src directly; 360 // <source> elements do not trigger sourceopen. 361 itemEl.audio.src = objectUrl; 362 363 await new Promise((resolve) => { 364 mediaSource.addEventListener("sourceopen", resolve, { once: true }); 365 }); 366 367 if (duration !== undefined) mediaSource.duration = duration; 368 369 const sourceBuffer = mediaSource.addSourceBuffer(mimeType); 370 371 // 'reader' is always the current active reader; the seeking handler 372 // closes over this variable so it always cancels the right one. 373 let reader = stream.getReader(); 374 let seekPending = false; 375 let seekTarget = 0; 376 377 const onSeeking = () => { 378 if (!seekFn) return; 379 const audio = itemEl.audio; 380 const target = audio.currentTime; 381 382 // Only intervene if the target is outside what's already buffered. 383 for (let i = 0; i < audio.buffered.length; i++) { 384 if ( 385 audio.buffered.start(i) <= target && target <= audio.buffered.end(i) 386 ) { 387 return; // Browser can handle it with buffered data. 388 } 389 } 390 391 seekPending = true; 392 seekTarget = target; 393 reader.cancel().catch(() => {}); 394 }; 395 396 itemEl.audio.addEventListener("seeking", onSeeking); 397 398 try { 399 while (true) { 400 if (!this.#mediaSourceUrls.has(id)) { 401 await reader.cancel(); 402 break; 403 } 404 405 let done, value; 406 407 try { 408 ({ done, value } = await reader.read()); 409 } catch { 410 done = true; 411 } 412 413 if (!this.#mediaSourceUrls.has(id)) break; 414 415 if (seekPending) { 416 seekPending = false; 417 418 // Clear all buffered data before feeding from the new position. 419 if (sourceBuffer.updating) { 420 await new Promise((r) => 421 sourceBuffer.addEventListener("updateend", r, { once: true }) 422 ); 423 } 424 await new Promise((r) => { 425 sourceBuffer.addEventListener("updateend", r, { once: true }); 426 sourceBuffer.remove(0, Infinity); 427 }); 428 429 if (!seekFn) throw new Error("seekFn is undefined"); 430 reader = (await seekFn(seekTarget)).getReader(); 431 432 continue; 433 } 434 435 if (done) { 436 if (mediaSource.readyState === "open") mediaSource.endOfStream(); 437 break; 438 } 439 440 if (sourceBuffer.updating) { 441 await new Promise((r) => 442 sourceBuffer.addEventListener("updateend", r, { once: true }) 443 ); 444 } 445 446 sourceBuffer.appendBuffer(value); 447 await new Promise((r) => 448 sourceBuffer.addEventListener("updateend", r, { once: true }) 449 ); 450 } 451 } catch (err) { 452 console.error("[audio engine] Stream error:", err); 453 if (mediaSource.readyState === "open") mediaSource.endOfStream("decode"); 454 } finally { 455 itemEl.audio.removeEventListener("seeking", onSeeking); 456 } 457 } 458 459 // RENDER 460 461 /** 462 * @param {RenderArg} _ 463 */ 464 render({ html }) { 465 const ids = this.items().map((i) => i.id); 466 467 this.querySelectorAll("de-audio-item").forEach((element) => { 468 if (ids.includes(element.id)) return; 469 470 const source = element.querySelector("source"); 471 if (source) source.src = SILENT_MP3; 472 }); 473 474 const group = this.group; 475 const nodes = this.items().map((audio) => { 476 const ip = audio.progress === undefined 477 ? "0" 478 : JSON.stringify(audio.progress); 479 480 return keyed( 481 audio.id, 482 html` 483 <de-audio-item 484 group="${this.broadcasted ? `${group}/${audio.id}` : nothing}" 485 id="${audio.id}" 486 initial-progress="${ip}" 487 mime-type="${audio.mimeType ? audio.mimeType : nothing}" 488 preload="${audio.isPreload ? `preload` : nothing}" 489 url="${audio.url ?? nothing}" 490 > 491 <audio 492 crossorigin="anonymous" 493 muted="true" 494 preload="auto" 495 > 496 ${audio.url 497 ? html` 498 <source 499 src="${audio.url}" 500 ${audio.mimeType ? 'type="' + audio.mimeType + '"' : ""} 501 /> 502 ` 503 : nothing} 504 </audio> 505 </de-audio-item> 506 `, 507 ); 508 }); 509 510 return html` 511 <section id="audio-nodes"> 512 ${nodes} 513 </section> 514 `; 515 } 516 517 // 🛠️ 518 519 /** 520 * Convenience signal to track if something is, or was, playing. 521 */ 522 _isPlaying() { 523 return computed(() => { 524 const item = this.items()?.[0]; 525 if (!item) return false; 526 527 const state = this.state(item.id); 528 if (!state) return false; 529 530 return state.isPlaying() || state.hasEnded() || 531 (state.duration() > 0 && state.currentTime() === state.duration()); 532 }); 533 } 534 535 /** 536 * Get the state of a single audio item. 537 * 538 * @param {string} audioId 539 * @returns {SignalReader<AudioStateReadOnly | undefined>} 540 */ 541 _state(audioId) { 542 return computed(() => { 543 const _trigger = this.#items.value; 544 545 const s = this.#itemElement(audioId)?.state; 546 return s ? { ...s } : undefined; 547 }); 548 } 549 550 /** 551 * Convenience signal to track if something is, or was, playing. 552 */ 553 isPlaying() { 554 return this._isPlaying()(); 555 } 556 557 /** 558 * Get the state of a single audio item. 559 * 560 * @param {string} audioId 561 * @returns {AudioStateReadOnly | undefined} 562 */ 563 state(audioId) { 564 return this._state(audioId)(); 565 } 566 567 /** 568 * @param {string} audioId 569 */ 570 #itemElement(audioId) { 571 const node = this.querySelector( 572 `de-audio-item[id="${audioId}"]:not([preload])`, 573 ); 574 575 if (node) { 576 const item = /** @type {AudioEngineItem} */ (node); 577 return item; 578 } 579 } 580 581 /** 582 * @param {string} audioId 583 * @param {(audio: HTMLAudioElement, item: AudioEngineItem) => void} fn 584 */ 585 #withAudioNode(audioId, fn) { 586 const item = this.#itemElement(audioId); 587 if (item) fn(item.audio, item); 588 } 589} 590 591export default AudioEngine; 592 593//////////////////////////////////////////// 594// ITEM ELEMENT 595//////////////////////////////////////////// 596 597class AudioEngineItem extends BroadcastableDiffuseElement { 598 static NAME = "diffuse/engine/audio/item"; 599 600 constructor() { 601 super(); 602 603 // TODO: 604 // const ip = this.getAttribute("initial-progress"); 605 606 /** 607 * @type {AudioState} 608 */ 609 this.$state = { 610 currentTime: signal(0), 611 duration: signal(0), 612 hasEnded: signal(false), 613 isPlaying: signal(false), 614 isPreload: signal(this.hasAttribute("preload")), 615 loadingState: signal(/** @type {LoadingState} */ ("loading")), 616 617 progress: computed(() => { 618 const currentTime = this.$state.currentTime.value; 619 const duration = this.$state.duration.value; 620 621 if (isNaN(duration)) return 0; 622 if (duration === Infinity) return 0; 623 624 return currentTime / duration; 625 }), 626 }; 627 } 628 629 // LIFECYCLE 630 631 /** 632 * @override 633 */ 634 async connectedCallback() { 635 const audio = this.audio; 636 637 audio.addEventListener("canplay", this.canplayEvent); 638 audio.addEventListener("durationchange", this.durationchangeEvent); 639 audio.addEventListener("ended", this.endedEvent); 640 audio.addEventListener("error", this.errorEvent); 641 audio.addEventListener("pause", this.pauseEvent); 642 audio.addEventListener("play", this.playEvent); 643 audio.addEventListener("suspend", this.suspendEvent); 644 audio.addEventListener("timeupdate", this.timeupdateEvent); 645 audio.addEventListener("waiting", this.waitingEvent); 646 647 // Setup broadcasting if part of group 648 if (this.hasAttribute("group")) { 649 const actions = this.broadcast( 650 this.identifier, 651 { 652 getCurrentTime: { 653 strategy: "leaderOnly", 654 fn: this.$state.currentTime.get, 655 }, 656 getDuration: { strategy: "leaderOnly", fn: this.$state.duration.get }, 657 getHasEnded: { strategy: "leaderOnly", fn: this.$state.hasEnded.get }, 658 getIsPlaying: { 659 strategy: "leaderOnly", 660 fn: this.$state.isPlaying.get, 661 }, 662 getIsPreload: { 663 strategy: "leaderOnly", 664 fn: this.$state.isPreload.get, 665 }, 666 getLoadingState: { 667 strategy: "leaderOnly", 668 fn: this.$state.loadingState.get, 669 }, 670 671 // SET 672 setCurrentTime: { 673 strategy: "replicate", 674 fn: this.$state.currentTime.set, 675 }, 676 setDuration: { strategy: "replicate", fn: this.$state.duration.set }, 677 setHasEnded: { strategy: "replicate", fn: this.$state.hasEnded.set }, 678 setIsPlaying: { 679 strategy: "replicate", 680 fn: this.$state.isPlaying.set, 681 }, 682 setIsPreload: { 683 strategy: "replicate", 684 fn: this.$state.isPreload.set, 685 }, 686 setLoadingState: { 687 strategy: "replicate", 688 fn: this.$state.loadingState.set, 689 }, 690 }, 691 { 692 // Sync leadership with engine's broadcasting channel 693 assumeLeadership: (await this.engine?.broadcastingStatus())?.leader, 694 }, 695 ); 696 697 if (actions) { 698 this.$state.currentTime.set = actions.setCurrentTime; 699 this.$state.duration.set = actions.setDuration; 700 this.$state.hasEnded.set = actions.setHasEnded; 701 this.$state.isPlaying.set = actions.setIsPlaying; 702 this.$state.isPreload.set = actions.setIsPreload; 703 this.$state.loadingState.set = actions.setLoadingState; 704 705 untracked(async () => { 706 this.$state.currentTime.value = await actions.getCurrentTime(); 707 this.$state.duration.value = await actions.getDuration(); 708 this.$state.hasEnded.value = await actions.getHasEnded(); 709 this.$state.isPlaying.value = await actions.getIsPlaying(); 710 this.$state.isPreload.value = await actions.getIsPreload(); 711 this.$state.loadingState.value = await actions.getLoadingState(); 712 }); 713 } 714 } 715 716 // Super 717 super.connectedCallback(); 718 } 719 720 // STATE 721 722 /** 723 * @type {AudioStateReadOnly} 724 */ 725 get state() { 726 return { 727 id: this.id, 728 mimeType: this.getAttribute("mime-type") ?? undefined, 729 url: this.getAttribute("url") ?? "", 730 731 currentTime: this.$state.currentTime.get, 732 duration: this.$state.duration.get, 733 hasEnded: this.$state.hasEnded.get, 734 isPlaying: this.$state.isPlaying.get, 735 isPreload: this.$state.isPreload.get, 736 loadingState: this.$state.loadingState.get, 737 738 progress: this.$state.progress, 739 }; 740 } 741 742 // RELATED ELEMENTS 743 744 get audio() { 745 const el = this.querySelector("audio"); 746 if (el) return /** @type {HTMLAudioElement} */ (el); 747 else throw new Error("Cannot find child audio element"); 748 } 749 750 get engine() { 751 const el = this.closest("de-audio"); 752 if (el) return /** @type {AudioEngine} */ (el); 753 else return null; 754 } 755 756 // EVENTS 757 758 /** 759 * @param {Event} event 760 */ 761 canplayEvent(event) { 762 const audio = /** @type {HTMLAudioElement} */ (event.target); 763 const item = engineItem(audio); 764 765 if ( 766 item?.hasAttribute("initial-progress") && 767 audio.duration && 768 !isNaN(audio.duration) 769 ) { 770 const progress = JSON.parse( 771 item.getAttribute("initial-progress") ?? "0", 772 ); 773 if ( 774 progress !== 0 && !isNaN(audio.duration) && audio.duration !== Infinity 775 ) { 776 audio.currentTime = audio.duration * progress; 777 } 778 779 item.removeAttribute("initial-progress"); 780 } 781 782 finishedLoading(event); 783 } 784 785 /** 786 * @param {Event} event 787 */ 788 durationchangeEvent(event) { 789 const audio = /** @type {HTMLAudioElement} */ (event.target); 790 791 if (!isNaN(audio.duration)) { 792 engineItem(audio)?.$state.duration.set(audio.duration); 793 } 794 } 795 796 /** 797 * @param {Event} event 798 */ 799 endedEvent(event) { 800 const audio = /** @type {HTMLAudioElement} */ (event.target); 801 audio.currentTime = 0; 802 803 engineItem(audio)?.$state.hasEnded.set(true); 804 } 805 806 /** 807 * @param {Event} event 808 */ 809 errorEvent(event) { 810 const audio = /** @type {HTMLAudioElement} */ (event.target); 811 const code = audio.error?.code || 0; 812 813 engineItem(audio)?.$state.loadingState.set({ error: { code } }); 814 } 815 816 /** 817 * @param {Event} event 818 */ 819 pauseEvent(event) { 820 const audio = /** @type {HTMLAudioElement} */ (event.target); 821 const item = engineItem(audio); 822 823 item?.$state.isPlaying.set(false); 824 } 825 826 /** 827 * @param {Event} event 828 */ 829 playEvent(event) { 830 const audio = /** @type {HTMLAudioElement} */ (event.target); 831 832 const item = engineItem(audio); 833 item?.$state.hasEnded.set(false); 834 item?.$state.isPlaying.set(true); 835 836 // In case audio was preloaded: 837 if (audio.readyState === 4) finishedLoading(event); 838 } 839 840 /** 841 * @param {Event} event 842 */ 843 suspendEvent(event) { 844 finishedLoading(event); 845 } 846 847 /** 848 * @param {Event} event 849 */ 850 timeupdateEvent(event) { 851 const audio = /** @type {HTMLAudioElement} */ (event.target); 852 if (isNaN(audio.duration) || audio.duration === 0) return; 853 854 engineItem(audio)?.$state.currentTime.set(audio.currentTime); 855 } 856 857 /** 858 * @param {Event} event 859 */ 860 waitingEvent(event) { 861 initiateLoading(event); 862 } 863} 864 865export { AudioEngineItem }; 866 867//////////////////////////////////////////// 868// 🛠️ 869//////////////////////////////////////////// 870 871/** 872 * @param {HTMLAudioElement} audio 873 */ 874function engineItem(audio) { 875 const c = audio.closest("de-audio-item"); 876 if (c) return /** @type {AudioEngineItem} */ (c); 877 else return null; 878} 879 880/** 881 * @param {Event} event 882 */ 883function finishedLoading(event) { 884 const audio = /** @type {HTMLAudioElement} */ (event.target); 885 engineItem(audio)?.$state.loadingState.set("loaded"); 886} 887 888/** 889 * @param {Event} event 890 */ 891function initiateLoading(event) { 892 const audio = /** @type {HTMLAudioElement} */ (event.target); 893 if (audio.readyState < 4) { 894 engineItem(audio)?.$state.loadingState.set("loading"); 895 } 896} 897 898//////////////////////////////////////////// 899// REGISTER 900//////////////////////////////////////////// 901 902export const CLASS = AudioEngine; 903export const NAME = "de-audio"; 904export const NAME_ITEM = "de-audio-item"; 905 906defineElement(NAME, AudioEngine); 907defineElement(NAME_ITEM, AudioEngineItem);