forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import { BroadcastableDiffuseElement, defineElement } from "~/common/element.js";
2import { batch, computed, signal } from "~/common/signal.js";
3
4/**
5 * @import {DiffuseElement} from "~/common/element.js"
6 * @import {Facet, PlaylistItem, Setting, Track} from "~/definitions/types.d.ts"
7 * @import {OutputManagerDeputy, OutputElement} from "~/components/output/types.d.ts"
8 *
9 * @import {OutputConfiguratorElement} from "./types.d.ts"
10 */
11
12/**
13 * @typedef {OutputElement} Output
14 */
15
16const STORAGE_PREFIX = "diffuse/configurator/output";
17
18////////////////////////////////////////////
19// ELEMENT
20////////////////////////////////////////////
21
22/**
23 * @implements {OutputConfiguratorElement}
24 */
25class OutputConfigurator extends BroadcastableDiffuseElement {
26 static NAME = "diffuse/configurator/output";
27
28 constructor() {
29 super();
30
31 /** @type {OutputManagerDeputy} */
32 const manager = {
33 facets: {
34 collection: computed(() => {
35 const out = this.#selected.value;
36 if (out) return out.facets.collection();
37
38 const def = this.#defaultOutput.value;
39 if (def) return def.facets.collection();
40 if (this.hasDefault()) return { state: "loading" };
41
42 return this.#setupFinished.value
43 ? { state: "loaded", data: this.#memory.facets.value }
44 : { state: "loading" };
45 }),
46 reload: () => {
47 const def = this.#defaultOutput.value;
48 if (def) def.facets.reload();
49
50 const out = this.#selected.value;
51 if (out) return out.facets.reload();
52
53 return Promise.resolve();
54 },
55 save: async (newFacets) => {
56 const out = this.#selected.value;
57 if (out) return await out.facets.save(newFacets);
58
59 const def = this.#defaultOutput.value;
60 if (def) return await def.facets.save(newFacets);
61
62 this.#memory.facets.value = newFacets;
63 },
64 },
65 playlistItems: {
66 collection: computed(() => {
67 const out = this.#selected.value;
68 if (out) return out.playlistItems.collection();
69
70 const def = this.#defaultOutput.value;
71 if (def) return def.playlistItems.collection();
72 if (this.hasDefault()) return { state: "loading" };
73
74 return this.#setupFinished.value
75 ? { state: "loaded", data: this.#memory.playlistItems.value }
76 : { state: "loading" };
77 }),
78 reload: () => {
79 const def = this.#defaultOutput.value;
80 if (def) def.playlistItems.reload();
81
82 const out = this.#selected.value;
83 if (out) return out.playlistItems.reload();
84
85 return Promise.resolve();
86 },
87 save: async (newPlaylistItems) => {
88 const out = this.#selected.value;
89 if (out) return await out.playlistItems.save(newPlaylistItems);
90
91 const def = this.#defaultOutput.value;
92 if (def) return await def.playlistItems.save(newPlaylistItems);
93
94 this.#memory.playlistItems.value = newPlaylistItems;
95 },
96 },
97 settings: {
98 collection: computed(() => {
99 const out = this.#selected.value;
100 if (out) return out.settings.collection();
101
102 const def = this.#defaultOutput.value;
103 if (def) return def.settings.collection();
104 if (this.hasDefault()) return { state: "loading" };
105
106 return this.#setupFinished.value
107 ? { state: "loaded", data: this.#memory.settings.value }
108 : { state: "loading" };
109 }),
110 reload: () => {
111 const def = this.#defaultOutput.value;
112 if (def) def.settings.reload();
113
114 const out = this.#selected.value;
115 if (out) return out.settings.reload();
116
117 return Promise.resolve();
118 },
119 save: async (newSettings) => {
120 const out = this.#selected.value;
121 if (out) return await out.settings.save(newSettings);
122
123 const def = this.#defaultOutput.value;
124 if (def) return await def.settings.save(newSettings);
125
126 this.#memory.settings.value = newSettings;
127 },
128 },
129 tracks: {
130 collection: computed(() => {
131 const out = this.#selected.value;
132 if (out) return out.tracks.collection();
133
134 const def = this.#defaultOutput.value;
135 if (def) return def.tracks.collection();
136 if (this.hasDefault()) return { state: "loading" };
137
138 return this.#setupFinished.value
139 ? { state: "loaded", data: this.#memory.tracks.value }
140 : { state: "loading" };
141 }),
142 reload: () => {
143 const def = this.#defaultOutput.value;
144 if (def) def.tracks.reload();
145
146 const out = this.#selected.value;
147 if (out) return out.tracks.reload();
148
149 return Promise.resolve();
150 },
151 save: async (newTracks) => {
152 const out = this.#selected.value;
153 if (out) return await out.tracks.save(newTracks);
154
155 const def = this.#defaultOutput.value;
156 if (def) return await def.tracks.save(newTracks);
157
158 this.#memory.tracks.value = newTracks;
159 },
160 },
161
162 // Other
163 ready: computed(() => {
164 const out = this.#selected.value;
165 if (out) return out.ready();
166
167 const def = this.#defaultOutput.value;
168 if (def) return def.ready();
169
170 return this.#setupFinished.value;
171 }),
172 };
173
174 // Assign manager properties to class
175 this.facets = manager.facets;
176 this.playlistItems = manager.playlistItems;
177 this.settings = manager.settings;
178 this.tracks = manager.tracks;
179 this.ready = manager.ready;
180
181 // Effects
182
183 /**
184 * When there's a selected output and its collection changes,
185 * save it to the default output or memory.
186 */
187 this.effect(() => {
188 const out = this.#selected.value;
189 if (!out) return;
190
191 const col = out.facets.collection();
192 if (col.state !== "loaded") return;
193
194 const def = this.#defaultOutput.value;
195 if (def) def.facets.save(col.data);
196 else this.#memory.facets.set(col.data);
197 });
198
199 this.effect(() => {
200 const out = this.#selected.value;
201 if (!out) return;
202
203 const col = out.playlistItems.collection();
204 if (col.state !== "loaded") return;
205
206 const def = this.#defaultOutput.value;
207 if (def) def.playlistItems.save(col.data);
208 else this.#memory.playlistItems.set(col.data);
209 });
210
211 this.effect(() => {
212 const out = this.#selected.value;
213 if (!out) return;
214
215 const col = out.settings.collection();
216 if (col.state !== "loaded") return;
217
218 const def = this.#defaultOutput.value;
219 if (def) def.settings.save(col.data);
220 else this.#memory.settings.set(col.data);
221 });
222
223 this.effect(() => {
224 const out = this.#selected.value;
225 if (!out) return;
226
227 const col = out.tracks.collection();
228 if (col.state !== "loaded") return;
229
230 const def = this.#defaultOutput.value;
231 if (def) def.tracks.save(col.data);
232 else this.#memory.tracks.set(col.data);
233 });
234 }
235
236 // SIGNALS
237
238 #activated = signal(/** @type {Set<string>} */ (new Set()));
239
240 #defaultOutput = signal(
241 /** @type {Output | null | undefined} */ (undefined),
242 );
243
244 #memory = {
245 facets: signal(/** @type {Facet[]} */ ([])),
246 playlistItems: signal(/** @type {PlaylistItem[]} */ ([])),
247 settings: signal(/** @type {Setting[]} */ ([])),
248 tracks: signal(/** @type {Track[]} */ ([])),
249 };
250
251 #selected = signal(
252 /** @type {Output | null | undefined} */ (undefined),
253 );
254
255 #setupFinished = signal(false);
256
257 // STATE
258
259 activated = this.#activated.get;
260 selected = computed(() => this.#selected.value ?? null);
261
262 // LIFECYCLE
263
264 /**
265 * @override
266 */
267 async connectedCallback() {
268 // Broadcast if needed
269 if (this.hasAttribute("group")) {
270 const actions = this.broadcast(this.identifier, {
271 selectOutput: {
272 strategy: "replicate",
273 fn: this.#selectOutput,
274 },
275 });
276
277 if (actions) {
278 this.#selectOutput = actions.selectOutput;
279 }
280 }
281
282 // Super
283 super.connectedCallback();
284
285 // Outputs
286 const def_ault = this.getAttribute("default");
287 const selectedOutputId = this.#selectedOutputId();
288
289 batch(() => {
290 /** @type {Set<string>} */
291 const activated = new Set();
292
293 if (def_ault) {
294 activated.add(def_ault);
295 }
296
297 if (selectedOutputId) {
298 activated.add(selectedOutputId);
299 }
300
301 this.#activated.value = activated;
302 });
303
304 /** @type {Output | null} */
305 const defaultOutput = def_ault ? await this.#findOutput(def_ault) : null;
306 const selectedOutput = await this.#findOutput(selectedOutputId);
307
308 batch(() => {
309 this.#selected.value = selectedOutput;
310 this.#defaultOutput.value = defaultOutput;
311 this.#setupFinished.value = true;
312 });
313 }
314
315 // MISC
316
317 /**
318 * @param {string | null} id
319 */
320 async #findOutput(id) {
321 const el = id ? this.root().querySelector(`#${id}`) : null;
322 if (!el) return null;
323
324 await customElements.whenDefined(el.localName);
325
326 if (
327 "identifier" in el === false ||
328 "tracks" in el === false
329 ) {
330 return null;
331 }
332
333 return /** @type {Output} */ (/** @type {unknown} */ (el));
334 }
335
336 /**
337 * @param {string | null} id
338 */
339 #selectOutput = async (id) => {
340 if (id) {
341 this.#activated.value = new Set([...this.#activated.value.values(), id]);
342 }
343
344 this.#selected.value = await this.#findOutput(id);
345 };
346
347 #selectedOutputId() {
348 return localStorage.getItem(`${STORAGE_PREFIX}/selected/id`);
349 }
350
351 /**
352 * @override
353 */
354 dependencies = () => {
355 return Object.fromEntries(
356 Array.from(this.root().children).flatMap((element) => {
357 if (element.hasAttribute("id") === false) {
358 console.warn(
359 "Missing `id` for output configurator child element with `localName` '" +
360 element.localName + "'",
361 );
362 return [];
363 }
364
365 const d = /** @type {DiffuseElement} */ (element);
366 return [[d.id, d]];
367 }),
368 );
369 };
370
371 // ADDITIONAL ACTIONS
372
373 deselect = async () => {
374 localStorage.removeItem(`${STORAGE_PREFIX}/selected/id`);
375 await this.#selectOutput(null);
376 };
377
378 hasDefault() {
379 return this.hasAttribute("default");
380 }
381
382 hasSelected() {
383 return this.#selectedOutputId() !== null;
384 }
385
386 loadSelected = async () => {
387 const selectedOutput = await this.#findOutput(this.#selectedOutputId());
388 this.#selected.value = selectedOutput;
389 };
390
391 options = async () => {
392 const deps = this.dependencies();
393 const entries = Object.entries(deps);
394
395 return entries.map(([k, v]) => {
396 return {
397 id: k,
398 label: v.label ?? v.getAttribute("label"),
399 element: /** @type {OutputElement} */ (v),
400 };
401 });
402 };
403
404 /**
405 * @param {string} id
406 */
407 select = async (id) => {
408 localStorage.setItem(`${STORAGE_PREFIX}/selected/id`, id);
409 await this.#selectOutput(id);
410 };
411
412 /**
413 * @param {string} label
414 * @returns {Promise<{ id: string, label: string, element: OutputElement }>}
415 */
416 waitForOption = (label) => {
417 return new Promise((resolve) => {
418 const check = async () => {
419 const opt = (await this.options()).find((o) => o.label === label);
420 if (opt) {
421 observer.disconnect();
422 resolve(opt);
423 }
424 };
425
426 const observer = new MutationObserver(check);
427 observer.observe(this, { childList: true });
428 check();
429 });
430 };
431}
432
433export default OutputConfigurator;
434
435////////////////////////////////////////////
436// REGISTER
437////////////////////////////////////////////
438
439export const CLASS = OutputConfigurator;
440export const NAME = "dc-output";
441
442defineElement(NAME, CLASS);