A music player that connects to your cloud/distributed storage.
1import { FastAverageColor } from "fast-average-color";
2import { Temporal } from "~/common/temporal.js";
3import { cache } from "lit-html/directives/cache.js";
4import { debounce } from "throttle-debounce";
5import { xxh32r } from "xxh32/dist/raw.js";
6
7import {
8 DEFAULT_GROUP,
9 DiffuseElement,
10 query,
11 whenElementsDefined,
12} from "~/common/element.js";
13
14import { computed, signal, untracked } from "~/common/signal.js";
15
16/**
17 * @import {RenderArg} from "~/common/element.d.ts"
18 *
19 * @import {InputElement} from "~/components/input/types.d.ts"
20 * @import {OutputElement} from "~/components/output/types.d.ts"
21 * @import {Artwork} from "~/components/processor/artwork/types.d.ts"
22 * @import AudioEngine from "~/components/engine/audio/element.js"
23 * @import QueueEngine from "~/components/engine/queue/element.js"
24 * @import ArtworkProcessor from "~/components/processor/artwork/element.js"
25 * @import FavouritesOrchestrator from "~/components/orchestrator/favourites/element.js"
26 */
27
28class ArtworkController extends DiffuseElement {
29 constructor() {
30 super();
31 this.attachShadow({ mode: "open" });
32 }
33
34 // VARIABLES
35
36 /** @type {number | undefined} */
37 #isLoadingTimeout = undefined;
38
39 // SIGNALS
40
41 #artwork = signal(
42 /** @type {{ current: (Artwork & { hash: string; index: number; loaded: boolean; url: string }) | null; previous: (Artwork & { hash: string; index: number; loaded: boolean; url: string }) | null }} */ ({
43 current: null,
44 previous: null,
45 }),
46 );
47
48 #artworkColor = signal(/** @type {string | undefined} */ (undefined));
49 #artworkLightMode = signal(false);
50 #duration = signal("0:00");
51 #isLoading = signal(true);
52 #time = signal("0:00");
53
54 // SIGNALS - DEPENDENCIES
55
56 $artwork = signal(/** @type {ArtworkProcessor | undefined} */ (undefined));
57 $audio = signal(/** @type {AudioEngine | undefined} */ (undefined));
58 $favourites = signal(
59 /** @type {FavouritesOrchestrator | undefined} */ (undefined),
60 );
61 $input = signal(/** @type {InputElement | undefined} */ (undefined));
62 $output = signal(/** @type {OutputElement | undefined} */ (undefined));
63 $queue = signal(/** @type {QueueEngine | undefined} */ (undefined));
64
65 // SIGNALS - COMPUTED
66
67 audio = computed(() => {
68 const curr = this.$queue.value?.now();
69 return curr ? this.$audio.value?.state(curr.id) : undefined;
70 });
71
72 currentTrack = computed(() => {
73 const item = this.$queue.value?.now();
74 if (!item) return undefined;
75 const col = this.$output.value?.tracks.collection();
76 if (!col || col.state !== "loaded") return undefined;
77 return col.data.find((t) => t.id === item.id);
78 });
79
80 isPlaying = computed(() => {
81 return this.$audio.value?.isPlaying();
82 });
83
84 // LIFECYCLE
85
86 /**
87 * @override
88 */
89 connectedCallback() {
90 super.connectedCallback();
91
92 /** @type {ArtworkProcessor} */
93 const artwork = query(this, "artwork-processor-selector");
94
95 /** @type {AudioEngine} */
96 const audio = query(this, "audio-engine-selector");
97
98 /** @type {InputElement} */
99 const input = query(this, "input-selector");
100
101 /** @type {OutputElement} */
102 const output = query(this, "output-selector");
103
104 /** @type {QueueEngine} */
105 const queue = query(this, "queue-engine-selector");
106
107 /** @type {FavouritesOrchestrator} */
108 const favourites = query(this, "favourites-orchestrator-selector");
109
110 whenElementsDefined({ audio, artwork, favourites, input, output, queue })
111 .then(
112 () => {
113 this.$artwork.value = artwork;
114 this.$audio.value = audio;
115 this.$input.value = input;
116 this.$output.value = output;
117 this.$queue.value = queue;
118 this.$favourites.value = favourites;
119
120 // Changed artwork based on active queue item.
121 const debouncedChangeArtwork = debounce(
122 1000,
123 this.#setArtwork.bind(this),
124 );
125
126 this.effect(() => {
127 const _trigger = this.currentTrack();
128 debouncedChangeArtwork();
129 });
130
131 this.effect(() => this.#formatTimestamps());
132 this.effect(() => this.#lightOrDark());
133
134 this.effect(() => {
135 const now = !!queue.now();
136 const aud = this.audio()?.loadingState();
137 const bool = now && aud !== "loaded";
138
139 if (this.#isLoadingTimeout) {
140 clearTimeout(this.#isLoadingTimeout);
141 }
142
143 if (bool) {
144 this.#isLoadingTimeout = setTimeout(
145 () => this.#isLoading.value = true,
146 2000,
147 );
148 } else {
149 this.#isLoading.value = false;
150 }
151 });
152 },
153 );
154 }
155
156 ////////////////////////////////////////////
157 // ✨ EFFECTS
158 // 🖼️ Artwork
159 ////////////////////////////////////////////
160
161 #lightOrDark() {
162 const controller = this.root().querySelector(".controller__inner");
163 if (!controller) return;
164
165 if (this.#artworkLightMode.value) {
166 controller.classList.add("controller__inner--light-mode");
167 } else controller.classList.remove("controller__inner--light-mode");
168 }
169
170 /** */
171 async #setArtwork() {
172 const track = this.currentTrack();
173 const currArtwork = untracked(this.#artwork.get);
174
175 if (!track) {
176 if (currArtwork.current) {
177 this.#artwork.value = { current: null, previous: currArtwork.current };
178 }
179 return;
180 }
181
182 const cacheId = track.id;
183
184 const resGet = await this.$input.value?.resolve({
185 method: "GET",
186 uri: track.uri,
187 });
188
189 const resHead = await this.$input.value?.resolve({
190 method: "HEAD",
191 uri: track.uri,
192 });
193
194 if (!resGet) return;
195
196 const request = "stream" in resGet
197 ? {
198 cacheId,
199 stream: resGet.stream,
200 tags: track.tags,
201 }
202 : {
203 cacheId,
204 tags: track.tags,
205 urls: {
206 get: resGet.url,
207 head: resHead && "url" in resHead ? resHead.url : resGet.url,
208 },
209 };
210
211 if (this.$queue.value?.now()?.id !== track?.id) {
212 return;
213 }
214
215 const allArt = await this.$artwork.value?.artwork(request) ?? [];
216
217 // Check if queue item has changed while fetching the artwork
218 const currTrack = this.currentTrack();
219 const currCacheId = currTrack ? currTrack.id : undefined;
220
221 if (cacheId === currCacheId) {
222 const art = allArt[0];
223
224 this.#artwork.set({
225 previous: currArtwork.current
226 ? { ...currArtwork.current, loaded: false }
227 : null,
228 current: art
229 ? {
230 ...art,
231 hash: xxh32r(art.bytes).toString(),
232 index: (currArtwork.current?.index ?? 0) + 1,
233 loaded: false,
234 url: URL.createObjectURL(
235 new Blob(
236 [/** @type {ArrayBuffer} */ (art.bytes.buffer)],
237 { type: art.mime },
238 ),
239 ),
240 }
241 : null,
242 });
243
244 if (!art) {
245 this.#artworkColor.value = undefined;
246 this.#artworkLightMode.value = false;
247 }
248 }
249 }
250
251 ////////////////////////////////////////////
252 // ✨ EFFECTS
253 // ⌚️ Time
254 ////////////////////////////////////////////
255 #formatTimestamps() {
256 const currTrack = this.currentTrack();
257 const audio = this.audio();
258 const curMs = (audio?.currentTime() ?? 0) * 1000;
259 const durMs = currTrack?.stats?.duration ??
260 (audio?.duration() != null ? audio.duration() * 1000 : undefined);
261
262 if (audio && durMs && !isNaN(durMs)) {
263 const p = Temporal.Duration.from({
264 milliseconds: Math.round(curMs),
265 }).round({
266 largestUnit: "hours",
267 smallestUnit: "seconds",
268 });
269
270 if (durMs === Infinity) {
271 this.#time.value = this.#formatTime(p);
272 this.#duration.value = "∞";
273 return;
274 }
275
276 const d = Temporal.Duration.from({ milliseconds: Math.round(durMs) })
277 .round({
278 largestUnit: "hours",
279 smallestUnit: "seconds",
280 });
281
282 this.#time.value = this.#formatTime(p);
283 this.#duration.value = this.#formatTime(d);
284 } else {
285 this.#time.value = "0:00";
286 this.#duration.value = "0:00";
287 }
288 }
289
290 /**
291 * @param {import("temporal-polyfill").Temporal.Duration} duration
292 */
293 #formatTime(duration) {
294 return `${duration.hours > 0 ? duration.hours.toFixed(0) + ":" : ""}${
295 duration.hours > 0
296 ? (duration.minutes > 9
297 ? duration.minutes.toFixed(0)
298 : "0" + duration.minutes.toFixed(0))
299 : duration.minutes.toFixed(0)
300 }:${
301 duration.seconds > 9
302 ? duration.seconds.toFixed(0)
303 : "0" + duration.seconds.toFixed(0)
304 }`;
305 }
306
307 // EVENTS
308
309 /**
310 * @param {Event} event
311 */
312 artworkLoaded = (event) => {
313 if (!(event.target instanceof HTMLImageElement)) return;
314
315 const hash = event.target.getAttribute("data-hash");
316 if (!hash) return;
317
318 if (hash !== this.#artwork.value.current?.hash) return;
319 if (this.#artwork.value.current?.loaded) return;
320
321 const fac = new FastAverageColor();
322 const color = fac.getColor(event.target);
323 const rgb = color.value;
324 const o = Math.round(
325 (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000,
326 );
327
328 this.#artworkColor.value = color.rgba;
329 this.#artworkLightMode.value = o > 165;
330 this.#artwork.value = {
331 previous: this.#artwork.value.previous,
332 current: { ...this.#artwork.value.current, loaded: true },
333 };
334 };
335
336 fullVolume = () => {
337 this.$audio.value?.adjustVolume({ volume: 1 });
338 };
339
340 mute = () => {
341 this.$audio.value?.adjustVolume({ volume: 0 });
342 };
343
344 next = () => {
345 this.$queue.value?.shift();
346 };
347
348 playPause = () => {
349 const audioId = this.$queue.value?.now()?.id;
350
351 if (this.isPlaying() && audioId) {
352 this.$audio.value?.pause({ audioId });
353 } else if (audioId) {
354 this.$audio.value?.play({ audioId });
355 }
356 };
357
358 previous = () => {
359 this.$queue.value?.unshift();
360 };
361
362 /**
363 * @param {MouseEvent} event
364 */
365 seek = (event) => {
366 const target = event.target
367 ? /** @type {HTMLProgressElement} */ (event.target)
368 : null;
369 const percentage = target ? event.offsetX / target.clientWidth : 0;
370 const audioId = this.$queue.value?.now()?.id;
371
372 if (audioId) this.$audio.value?.seek({ audioId, percentage });
373 };
374
375 /**
376 * @param {MouseEvent} event
377 */
378 setVolume = (event) => {
379 const target = event.target
380 ? /** @type {HTMLProgressElement} */ (event.target)
381 : null;
382
383 const percentage = target ? event.offsetX / target.clientWidth : 0;
384 this.$audio.value?.adjustVolume({ volume: percentage });
385 };
386
387 toggleFavourite = () => {
388 const track = this.currentTrack();
389 if (!track) return;
390
391 this.$favourites.value?.toggle(track);
392 };
393
394 // RENDER
395
396 /**
397 * @param {RenderArg} _
398 */
399 render({ html }) {
400 const activeQueueItem = this.currentTrack();
401 const isFav = activeQueueItem
402 ? this.$favourites.value?.isFavourite(activeQueueItem) ?? false
403 : false;
404
405 // Artwork
406 const artworkArr = [
407 this.#artwork.value.previous,
408 this.#artwork.value.current,
409 ].sort((a, b) => {
410 if (!a || !b) return 0;
411 return a.index % 2 ? 1 : -1;
412 });
413
414 const artwork = artworkArr.map((art) => {
415 if (art === null) {
416 return null;
417 }
418
419 return cache(html`
420 <img
421 @load="${this.artworkLoaded}"
422 data-hash="${art.hash}"
423 src="${art.url}"
424 style="opacity: ${art.loaded ? `1` : `0`}"
425 />
426 `);
427 });
428
429 return html`
430 <link rel="stylesheet" href="vendor/@phosphor-icons/web/bold/style.css" />
431 <link rel="stylesheet" href="vendor/@phosphor-icons/web/fill/style.css" />
432 <link rel="stylesheet" href="styles/animations.css" />
433 <link rel="stylesheet" href="themes/blur/artwork-controller/element.css" />
434
435 <main style="background-color: ${this.#artworkColor.value ??
436 `var(--color-3)`}; opacity: 0;">
437 <section class="artwork">
438 <label style="display: ${this.group === DEFAULT_GROUP
439 ? `none`
440 : `block`};">
441 ${this.group}
442 </label>
443
444 ${artwork}
445 </section>
446
447 <section class="controller">
448 <div class="gradient-blur">
449 <div></div>
450 <div></div>
451 <div></div>
452 <div></div>
453 <div></div>
454 <div></div>
455 <div></div>
456 <div></div>
457 </div>
458
459 <div
460 class="controller__background"
461 style="background-color: ${this.#artworkColor.value ??
462 `transparent`};"
463 >
464 </div>
465
466 <section class="controller__inner">
467 <!-- NOW PLAYING -->
468
469 <cite>
470 <strong>${activeQueueItem?.tags?.title ||
471 "Diffuse"}</strong>
472 <span style="font-style: ${activeQueueItem
473 ? `normal`
474 : `italic`}">
475 ${activeQueueItem?.tags?.artist ??
476 (activeQueueItem ? `` : `Waiting on queue ...`)}
477 </span>
478 </cite>
479
480 <!-- PROGRESS -->
481
482 <div class="progress" @click="${this.seek}">
483 <progress max="100" value="${(this.audio()?.loadingState() === "loaded" ? (this.audio()?.progress() ?? 0) : 0) * 100}"></progress>
484 <div class="timestamps">
485 <time datetime="${this.#time.value}">${this.#time.value}</time>
486 <time datetime="${this.#time.value}">${this.#duration
487 .value}</time>
488 </div>
489 </div>
490
491 <!-- CONTROLS -->
492
493 <menu>
494 <!-- previous -->
495 <li @click="${this.previous}">
496 <i class="ph-fill ph-rewind" title="Previous track"></i>
497 </li>
498
499 <!-- loading ... -->
500 <div
501 class="animate-bounce menu__loader"
502 style="display: ${this.#isLoading.value ? `inherit` : `none`};"
503 >
504 <i class="ph-fill ph-vinyl-record" title="Loading ..."></i>
505 </div>
506
507 <!-- play -->
508 <li
509 @click="${this.playPause}"
510 style="display: ${!this.#isLoading.value &&
511 !this.isPlaying()
512 ? `inline`
513 : `none`};"
514 >
515 <i class="ph-fill ph-play" title="Play"></i>
516 </li>
517
518 <!-- pause -->
519 <li
520 @click="${this.playPause}"
521 style="display: ${!this.#isLoading.value && this.isPlaying()
522 ? `inline`
523 : `none`};"
524 >
525 <i class="ph-fill ph-pause" title="Pause"></i>
526 </li>
527
528 <!-- next -->
529 <li @click="${this.next}">
530 <i class="ph-fill ph-fast-forward" title="Next track"></i>
531 </li>
532 </menu>
533
534 <!-- VOLUME -->
535
536 <div class="volume">
537 <i @click="${this.mute}" class="ph-fill ph-speaker-none"></i>
538 <div @click="${this.setVolume}" class="progress-bar">
539 <progress max="100" value="${(this.$audio.value?.volume() ??
540 0) * 100}"></progress>
541 </div>
542 <i @click="${this
543 .fullVolume}" class="ph-fill ph-speaker-high"></i>
544 </div>
545
546 <footer>
547 <div class="button-row">
548 <button
549 title="Toggle favourite"
550 data-enabled="${isFav ? `t` : `f`}"
551 @click="${this.toggleFavourite}"
552 >
553 <i class="ph-${isFav ? `fill` : `bold`} ph-star"></i>
554 </button>
555 </div>
556 </footer>
557 </section>
558 </section>
559 </main>
560 `;
561 }
562}
563
564export default ArtworkController;
565
566////////////////////////////////////////////
567// REGISTER
568////////////////////////////////////////////
569
570export const CLASS = ArtworkController;
571export const NAME = "db-artwork-controller";
572
573customElements.define(NAME, CLASS);