forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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);