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);