1import { BroadcastableDiffuseElement, query } from "@common/element.js";
2import { signal, untracked } from "@common/signal.js";
3
4/**
5 * @import {InputElement} from "@components/input/types.d.ts"
6 */
7
8////////////////////////////////////////////
9// ELEMENT
10////////////////////////////////////////////
11
12/**
13 * When the active queue item changes,
14 * coordinate the audio engine accordingly.
15 *
16 * Vice versa, when the audio ends,
17 * shift the queue if needed.
18 */
19class QueueAudioOrchestrator extends BroadcastableDiffuseElement {
20 static NAME = "diffuse/orchestrator/queue-audio";
21 static observedAttributes = ["repeat"];
22
23 // SIGNALS
24
25 #repeat = signal(false);
26
27 // LIFE CYCLE
28
29 /**
30 * @override
31 */
32 async connectedCallback() {
33 this.#repeat.value = this.hasAttribute("repeat");
34
35 // Broadcast if needed
36 if (this.hasAttribute("group")) {
37 this.broadcast(this.nameWithGroup, {});
38 }
39
40 // Super
41 super.connectedCallback();
42
43 /** @type {InputElement} */
44 this.input = query(this, "input-selector");
45
46 /** @type {import("@components/engine/audio/element.js").CLASS} */
47 this.audio = query(this, "audio-engine-selector");
48
49 /** @type {import("@components/engine/queue/element.js").CLASS} */
50 this.queue = query(this, "queue-engine-selector");
51
52 // Wait until defined
53 await customElements.whenDefined(this.audio.localName);
54 await customElements.whenDefined(this.input.localName);
55 await customElements.whenDefined(this.queue.localName);
56
57 // Effects
58 this.effect(() => this.monitorActiveQueueItem());
59 this.effect(() => this.monitorAudioEnd());
60 }
61
62 /**
63 * @override
64 * @param {string} name
65 * @param {string} oldValue
66 * @param {string} newValue
67 */
68 attributeChangedCallback(name, oldValue, newValue) {
69 super.attributeChangedCallback(name, oldValue, newValue);
70
71 if (name === "repeat") {
72 this.#repeat.value = newValue != null;
73 }
74 }
75
76 // 🛠️
77
78 async monitorActiveQueueItem() {
79 if (!this.audio) return;
80 if (!this.input) return;
81 if (!this.queue) return;
82
83 const activeTrack = this.queue.now();
84 if ((await this.isLeader()) === false) return;
85
86 const isPlaying = untracked(this.audio.isPlaying);
87
88 // Resolve URIs
89 const resolvedUri = activeTrack
90 ? await this.input.resolve({ method: "GET", uri: activeTrack.uri })
91 : undefined;
92
93 if (resolvedUri && "stream" in resolvedUri) {
94 throw new Error("Streams are not supported yet.");
95 }
96
97 const url = resolvedUri?.url;
98
99 // Check if we still need to render
100 if (this.queue.now?.()?.id !== activeTrack?.id) return;
101
102 // Play new active queue item
103 // TODO: Take URL expiration timestamp into account
104 // TODO: Preload next queue item
105 this.audio.supply({
106 audio: activeTrack && url
107 ? [{
108 id: activeTrack.id,
109 isPreload: false,
110 url,
111 }]
112 // TODO: Keep preloads
113 : [],
114 play: activeTrack && isPlaying ? { audioId: activeTrack.id } : undefined,
115 });
116 }
117
118 async monitorAudioEnd() {
119 if (!this.audio) return;
120 if (!this.queue) return;
121
122 const now = this.queue.now();
123 const aud = now ? this.audio.state(now.id) : undefined;
124
125 if (aud?.hasEnded() && (await this.isLeader())) {
126 // TODO: Not sure yet if this is the best way to approach this.
127 // The idea is that scrobblers would more easily pick this up,
128 // as opposed to just resetting the audio.
129 if (this.#repeat.value) {
130 const now = this.queue.now();
131 if (now) {
132 await this.queue.add({
133 inFront: true,
134 tracks: [now],
135 });
136 }
137 }
138
139 await this.queue.shift();
140 }
141 }
142}
143
144export default QueueAudioOrchestrator;
145
146////////////////////////////////////////////
147// REGISTER
148////////////////////////////////////////////
149
150export const CLASS = QueueAudioOrchestrator;
151export const NAME = "do-queue-audio";
152
153customElements.define(NAME, QueueAudioOrchestrator);