Experiment to rebuild Diffuse using web applets.
1---
2import "@styles/reset.css";
3import "@styles/variables.css";
4import "@styles/fonts.css";
5import "@styles/animations.css";
6import "@styles/icons/phosphor.css";
7
8import "@styles/diffuse/colors.css";
9import "@styles/diffuse/fonts.css";
10---
11
12<main>
13 <section class="artwork"></section>
14
15 <section class="controller">
16 <div class="gradient-blur">
17 <div></div>
18 <div></div>
19 <div></div>
20 <div></div>
21 <div></div>
22 <div></div>
23 <div></div>
24 <div></div>
25 </div>
26
27 <div class="controller__background"></div>
28
29 <!-- Content -->
30 <section class="controller__inner"></section>
31 </section>
32</main>
33
34<style>
35 :root {
36 --transition-durition: 500ms;
37 }
38
39 main {
40 background: var(--color-3);
41 color: white;
42 display: flex;
43 flex-direction: column;
44 font-size: var(--fs-sm);
45 height: 100dvh;
46 /* max-width: var(--container-xs); */
47 overflow: hidden;
48 position: relative;
49 transition:
50 background-color var(--transition-durition),
51 color var(--transition-durition);
52 }
53
54 /* Artwork */
55
56 .artwork {
57 flex: 1;
58 position: relative;
59 }
60
61 .artwork img {
62 height: 100%;
63 left: 0;
64 object-fit: cover;
65 opacity: 0;
66 position: absolute;
67 top: 0;
68 transition-duration: var(--transition-durition);
69 transition-property: opacity;
70 width: 100%;
71 z-index: 0;
72 }
73
74 .artwork label {
75 background: oklch(0 0 0);
76 border-radius: var(--radius-sm);
77 box-shadow: var(--box-shadow-lg);
78 font-size: var(--fs-2xs);
79 font-weight: 600;
80 left: var(--space-xs);
81 letter-spacing: var(--tracking-wide);
82 line-height: 1;
83 padding: 7px 6px 6px;
84 position: absolute;
85 text-transform: uppercase;
86 top: var(--space-xs);
87 transition:
88 background-color var(--transition-durition),
89 color var(--transition-durition);
90 z-index: 10;
91 }
92
93 /* Progress bars */
94
95 progress {
96 appearance: none;
97 border: 0;
98 display: block;
99 height: 4px;
100 width: 100%;
101 }
102
103 progress,
104 progress::-webkit-progress-bar {
105 background-color: color-mix(in oklch, currentColor 40%, transparent);
106 overflow: hidden;
107 border-radius: 4px;
108 }
109
110 progress[value]::-webkit-progress-value {
111 border-radius: 4px;
112 background-color: color-mix(in oklch, currentColor 90%, transparent);
113 }
114
115 progress[value]::-moz-progress-bar {
116 border-radius: 4px;
117 background-color: color-mix(in oklch, currentColor 50%, transparent);
118 }
119
120 /* Controller */
121
122 .controller {
123 flex-shrink: 0;
124 padding: 0 var(--space-md) var(--space-md);
125 position: relative;
126 }
127
128 .controller__background {
129 inset: 0;
130 opacity: 0.5;
131 position: absolute;
132 transition: background-color var(--transition-durition);
133 z-index: 1;
134 }
135
136 .controller__inner {
137 position: relative;
138 transition-duration: var(--transition-durition);
139 transition-property: color;
140 z-index: 10;
141 }
142
143 .controller__inner.controller__inner--light-mode {
144 color: rgba(0, 0, 0, 0.6);
145 }
146
147 /* Now playing */
148
149 cite {
150 display: block;
151 font-style: normal;
152 text-shadow: var(--text-shadow-sm);
153 }
154
155 .controller__inner--light-mode cite {
156 text-shadow: none;
157 }
158
159 /* Progress */
160
161 .progress {
162 cursor: pointer;
163 margin: var(--space-xs) 0;
164 padding-top: var(--space-2xs);
165 }
166
167 .timestamps {
168 display: flex;
169 font-size: var(--fs-2xs);
170 font-weight: 500;
171 justify-content: space-between;
172 margin-top: var(--space-3xs);
173 opacity: 0.4;
174 text-shadow: var(--text-shadow-xs);
175 }
176
177 .controller__inner--light-mode .timestamps {
178 text-shadow: none;
179 }
180
181 /* Controls */
182
183 .controller menu {
184 align-items: center;
185 display: flex;
186 font-size: var(--fs-lg);
187 gap: var(--space-lg);
188 justify-content: center;
189 margin: var(--space-md) 0;
190 padding: 0;
191 text-align: center;
192 }
193
194 .controller .menu__loader {
195 line-height: 0;
196 transform-origin: center;
197 }
198
199 .controller command {
200 cursor: pointer;
201 line-height: 0;
202 transition-duration: var(--transition-durition);
203 transition-property: opacity;
204 }
205
206 .controller .ph-pause,
207 .controller .ph-play,
208 .controller .menu__loader {
209 font-size: var(--fs-xl);
210 }
211
212 /* Volume */
213
214 footer {
215 align-items: center;
216 display: flex;
217 font-size: var(--fs-xs);
218 gap: var(--space-2xs);
219 justify-content: space-between;
220 }
221
222 footer .progress-bar {
223 cursor: pointer;
224 flex: 1;
225 padding: var(--space-2xs) 0;
226 }
227
228 footer i {
229 cursor: pointer;
230 }
231
232 /* Gradient blur */
233
234 .gradient-blur {
235 bottom: 0;
236 height: 150%;
237 left: 0;
238 pointer-events: none;
239 position: absolute;
240 right: 0;
241 z-index: 2;
242 }
243
244 .gradient-blur > div {
245 position: absolute;
246 inset: 0;
247 }
248
249 .gradient-blur > div:nth-of-type(1) {
250 backdrop-filter: blur(1px);
251 mask: linear-gradient(
252 to bottom,
253 rgba(0, 0, 0, 0) 0%,
254 rgba(0, 0, 0, 1) 4.166666665%,
255 rgba(0, 0, 0, 1) 8.333333332%,
256 rgba(0, 0, 0, 0) 12.499999999%
257 );
258 z-index: 1;
259 }
260
261 .gradient-blur > div:nth-of-type(2) {
262 backdrop-filter: blur(2px);
263 mask: linear-gradient(
264 to bottom,
265 rgba(0, 0, 0, 0) 4.166666665%,
266 rgba(0, 0, 0, 1) 8.333333332%,
267 rgba(0, 0, 0, 1) 12.499999999%,
268 rgba(0, 0, 0, 0) 16.666666666%
269 );
270 z-index: 2;
271 }
272
273 .gradient-blur > div:nth-of-type(3) {
274 backdrop-filter: blur(4px);
275 mask: linear-gradient(
276 to bottom,
277 rgba(0, 0, 0, 0) 8.333333332%,
278 rgba(0, 0, 0, 1) 12.499999999%,
279 rgba(0, 0, 0, 1) 16.666666666%,
280 rgba(0, 0, 0, 0) 20.833333333%
281 );
282 z-index: 3;
283 }
284
285 .gradient-blur > div:nth-of-type(4) {
286 backdrop-filter: blur(8px);
287 mask: linear-gradient(
288 to bottom,
289 rgba(0, 0, 0, 0) 12.499999999%,
290 rgba(0, 0, 0, 1) 16.666666666%,
291 rgba(0, 0, 0, 1) 20.833333333%,
292 rgba(0, 0, 0, 0) 25%
293 );
294 z-index: 4;
295 }
296
297 .gradient-blur > div:nth-of-type(5) {
298 backdrop-filter: blur(16px);
299 mask: linear-gradient(
300 to bottom,
301 rgba(0, 0, 0, 0) 16.666666666%,
302 rgba(0, 0, 0, 1) 20.833333333%,
303 rgba(0, 0, 0, 1) 25%,
304 rgba(0, 0, 0, 0) 100%
305 );
306 z-index: 5;
307 }
308
309 .gradient-blur > div:nth-of-type(6) {
310 backdrop-filter: blur(32px);
311 mask: linear-gradient(
312 to bottom,
313 rgba(0, 0, 0, 0) 20.833333333%,
314 rgba(0, 0, 0, 1) 25%,
315 rgba(0, 0, 0, 1) 100%
316 );
317 z-index: 6;
318 }
319
320 .gradient-blur > div:nth-of-type(7) {
321 backdrop-filter: blur(64px);
322 mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 1) 100%);
323 z-index: 7;
324 }
325</style>
326
327<style is:global>
328 iframe {
329 display: none;
330 }
331</style>
332
333<script>
334 import scope from "astro:scope";
335 import { FastAverageColor } from "fast-average-color";
336 import { Temporal } from "@js-temporal/polyfill";
337 import { xxh32r } from "xxh32/dist/raw.js";
338
339 import { computed, effect, type Signal, signal } from "spellcaster";
340 import { tags, text, type ElementConfigurator } from "spellcaster/hyperscript.js";
341
342 import type { Track } from "@applets/core/types";
343 import { applet, hs, inputUrl, reactive, register } from "@scripts/applet/common";
344 import { trackArtworkCacheId } from "@scripts/common";
345
346 ////////////////////////////////////////////
347 // SETUP
348 ////////////////////////////////////////////
349 import type * as AudioEngine from "@applets/engine/audio/types.d.ts";
350 import type * as QueueEngine from "@applets/engine/queue/types.d.ts";
351
352 import type { Artwork } from "@applets/processor/artwork/types";
353 import { debounce } from "throttle-debounce";
354
355 // Register
356 const context = register();
357
358 // Signals
359 const [activeTrack, setActiveTrack] = signal<Track | undefined>(undefined);
360 const [artwork, setArtwork] = signal<Artwork[]>([]);
361 const [artworkColor, setArtworkColor] = signal<string | undefined>(undefined);
362 const [artworkLightMode, setArtworkLightMode] = signal<boolean>(false);
363 const [duration, setDuration] = signal<string>("0:00");
364 const [isLoading, setIsLoading] = signal<boolean>(true);
365 const [isPlaying, setIsPlaying] = signal<boolean>(false);
366 const [progress, setProgress] = signal<number>(0);
367 const [time, setTime] = signal<string>("0:00");
368 const [volume, setVolume] = signal<number>(0);
369
370 // Is main group
371 function isMainGroup() {
372 return context.groupId === undefined || context.groupId.toLowerCase() === "main";
373 }
374
375 // Applet connections
376 const configurator = {
377 input: applet("/configurator/input"),
378 };
379
380 const engine = {
381 audio: await applet<AudioEngine.State>("/engine/audio", { groupId: context.groupId }),
382 queue: await applet<QueueEngine.State>("/engine/queue", { groupId: context.groupId }),
383 };
384
385 const orchestrator = {
386 queueAudio: applet("/orchestrator/queue-audio", { groupId: context.groupId }),
387 queueTracks: isMainGroup()
388 ? applet("/orchestrator/queue-tracks", { groupId: context.groupId })
389 : undefined,
390 processTracks: isMainGroup() ? applet("/orchestrator/process-tracks") : undefined,
391 };
392
393 const processor = {
394 artwork: applet("/processor/artwork"),
395 };
396
397 ////////////////////////////////////////////
398 // ✨ EFFECTS
399 // ⌚️ Time
400 ////////////////////////////////////////////
401 const formatTimestamps = () => {
402 const prog = progress();
403 const curr = engine.queue.data.now;
404 const audio = curr ? engine.audio.data.items[curr.id] : undefined;
405 const duration = curr?.stats?.duration ?? audio?.duration;
406
407 if (audio && duration != undefined && !isNaN(duration)) {
408 const p = Temporal.Duration.from({
409 milliseconds: Math.round(duration * prog * 1000),
410 }).round({
411 largestUnit: "hours",
412 });
413
414 const d = Temporal.Duration.from({ milliseconds: Math.round(duration * 1000) }).round({
415 largestUnit: "hours",
416 });
417
418 setTime(formatTime(p));
419 setDuration(formatTime(d));
420 } else {
421 setTime("0:00");
422 setDuration("0:00");
423 }
424 };
425
426 effect(formatTimestamps);
427
428 function formatTime(duration: Temporal.Duration) {
429 return `${duration.hours > 0 ? duration.hours.toFixed(0) + ":" : ""}${duration.hours > 0 ? (duration.minutes > 9 ? duration.minutes.toFixed(0) : "0" + duration.minutes.toFixed(0)) : duration.minutes.toFixed(0)}:${duration.seconds > 9 ? duration.seconds.toFixed(0) : "0" + duration.seconds.toFixed(0)}`;
430 }
431
432 ////////////////////////////////////////////
433 // ✨ EFFECTS
434 // ⏳️ Loading
435 ////////////////////////////////////////////
436
437 const debouncedSetIsLoading = debounce(2000, setIsLoading);
438
439 ////////////////////////////////////////////
440 // 🔊 AUDIO
441 ////////////////////////////////////////////
442
443 reactive(
444 engine.audio,
445 (data) => data.items[engine.queue.data.now?.id ?? Infinity]?.loadingState === "loaded",
446 (isLoaded) => debouncedSetIsLoading(!isLoaded),
447 );
448
449 reactive(
450 engine.audio,
451 (data) =>
452 data.isPlaying && (data.items[engine.queue.data.now?.id ?? Infinity]?.isPlaying ?? false),
453 (isPlaying) => setIsPlaying(isPlaying),
454 );
455
456 reactive(
457 engine.audio,
458 (data) => data.items[engine.queue.data.now?.id ?? Infinity]?.progress ?? 0,
459 (progress) => setProgress(progress),
460 );
461
462 reactive(
463 engine.audio,
464 (data) => data.volume.default,
465 (volume) => setVolume(volume),
466 );
467
468 ////////////////////////////////////////////
469 // 🎢 QUEUE
470 ////////////////////////////////////////////
471
472 // Set active track based on active queue item.
473
474 reactive(
475 engine.queue,
476 (data) => data.now,
477 (track) => {
478 setActiveTrack(track || undefined);
479 setProgress(0);
480 },
481 );
482
483 // Changed artwork based on active queue item.
484 // (debounced)
485
486 reactive(engine.queue, (data) => data.now, debounce(1000, changeArtwork));
487
488 async function changeArtwork() {
489 const track = engine.queue.data.now;
490
491 if (!track) {
492 setArtwork([]);
493 return;
494 }
495
496 const input = await configurator.input;
497 const cacheId = await trackArtworkCacheId(track);
498 const urls = {
499 get: await inputUrl(input, track.uri, "GET").then((a) => a?.url),
500 head: await inputUrl(input, track.uri, "HEAD").then((a) => a?.url),
501 };
502
503 const proc = await processor.artwork;
504 const art = await proc.sendAction(
505 "artwork",
506 {
507 cacheId,
508 tags: track.tags,
509 urls,
510 },
511 {
512 timeoutDuration: 60000 * 5,
513 worker: true,
514 },
515 );
516
517 const currTrack = activeTrack();
518 const currCacheId = currTrack ? await trackArtworkCacheId(currTrack) : undefined;
519 if (cacheId === currCacheId) setArtwork(art);
520 }
521
522 ////////////////////////////////////////////
523 // UI
524 ////////////////////////////////////////////
525 const bg = document.body.querySelector<HTMLElement>(".controller__background");
526 const controller = document.body.querySelector<HTMLElement>(".controller__inner");
527 const main = document.body.querySelector("main");
528 const showcase = document.body.querySelector<HTMLElement>(".artwork");
529
530 if (!bg || !controller || !main || !showcase) throw new Error("Missing DOM elements");
531
532 const h = (
533 tag: string,
534 props?: Record<string, any> | Signal<Record<string, any>>,
535 configure?: ElementConfigurator,
536 ) => hs(tag, scope, props, configure);
537
538 ////////////////////////////////////////////
539 // UI ░ ARTWORK
540 ////////////////////////////////////////////
541
542 const timeouts: Record<string, ReturnType<typeof setTimeout>> = {};
543
544 effect(() => {
545 const art = artwork();
546
547 // No artwork, fade out existing.
548 if (art.length === 0) {
549 showcase.querySelectorAll("img").forEach((node) => {
550 node.style.opacity = "0";
551 const hash = node.getAttribute("data-hash");
552 if (hash) timeouts[hash] = setTimeout(() => node.remove(), 1000);
553 });
554 return;
555 }
556
557 // Determine if the current artwork needs to be replaced.
558 const hash = xxh32r(art[0].bytes).toString();
559 const existingArtwork = showcase.querySelector<HTMLImageElement>(`img[data-hash="${hash}"]`);
560
561 // If the artwork is the same, stop here.
562 if (existingArtwork) {
563 const timeoutId = timeouts[hash];
564 if (timeoutId) clearTimeout(timeoutId);
565 existingArtwork.style.opacity = "1";
566 return;
567 }
568
569 // Add new artwork
570 const blob = new Blob([art[0].bytes], { type: art[0].mime });
571 const url = URL.createObjectURL(blob);
572
573 // Create img for new artwork
574 const img = h("img", {
575 src: url,
576 className: "artwork",
577 attrs: {
578 "data-hash": hash,
579 },
580 });
581
582 // Extract average color
583 img.onload = () => {
584 const fac = new FastAverageColor();
585 const color = fac.getColor(img as HTMLImageElement);
586 const rgb = color.value;
587 const o = Math.round((rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000);
588
589 setArtworkColor(color.rgba);
590 setArtworkLightMode(o > 165);
591 bg.style.backgroundColor = color.rgba;
592 main.style.backgroundColor = color.rgba;
593 img.style.opacity = "1";
594
595 showcase.querySelectorAll("img").forEach((node) => {
596 if (node === img) return;
597 node.style.opacity = "0";
598 timeouts[hash] = setTimeout(() => node.remove(), 1000);
599 });
600 };
601
602 // Insert new artwork
603 showcase.appendChild(img);
604 });
605
606 effect(() => {
607 if (artworkLightMode()) controller.classList.add("controller__inner--light-mode");
608 else controller.classList.remove("controller__inner--light-mode");
609 });
610
611 ////////////////////////////////////////////
612 // UI ░ GROUP ID
613 ////////////////////////////////////////////
614
615 const GroupId = h(
616 "label",
617 computed(() => {
618 const display = isMainGroup() ? "none" : "block";
619
620 return {
621 attrs: {
622 style: `background: ${artworkColor()}; display: ${display};`,
623 },
624 };
625 }),
626 text(context.groupId),
627 );
628
629 showcase.appendChild(GroupId);
630
631 ////////////////////////////////////////////
632 // UI ░ NOW PLAYING
633 ////////////////////////////////////////////
634
635 const NowPlaying = h("cite", {}, [
636 h("strong", {}, text(computed(() => activeTrack()?.tags?.title || "Diffuse"))),
637 tags.br(),
638 h(
639 "span",
640 computed(() => {
641 return {
642 style: isMainGroup() && !activeTrack() ? "font-style: italic" : "",
643 };
644 }),
645 text(
646 computed(
647 () =>
648 activeTrack()?.tags?.artist ||
649 (isMainGroup() && !activeTrack() ? "Waiting on queue ..." : ""),
650 ),
651 ),
652 ),
653 ]);
654
655 controller.appendChild(NowPlaying);
656
657 ////////////////////////////////////////////
658 // UI ░ PROGRESS
659 ////////////////////////////////////////////
660
661 const ProgressBar = h(
662 "progress",
663 computed(() => ({ max: "100", value: `${progress() * 100}` })),
664 [],
665 );
666
667 const Time = h("div", { className: "timestamps" }, [
668 h(
669 "time",
670 computed(() => {
671 return { attrs: { datetime: time() } };
672 }),
673 text(time),
674 ),
675 h(
676 "time",
677 computed(() => {
678 return { attrs: { datetime: duration() } };
679 }),
680 text(duration),
681 ),
682 ]);
683
684 const Progress = h("div", { className: "progress", onclick: seek }, [ProgressBar, Time]);
685
686 function seek(event: MouseEvent) {
687 const percentage = event.offsetX / (event.target as HTMLProgressElement).clientWidth;
688 engine.audio.sendAction("seek", { audioId: engine.queue.data.now?.id, percentage });
689 }
690
691 controller.appendChild(Progress);
692
693 ////////////////////////////////////////////
694 // UI ░ CONTROLS
695 ////////////////////////////////////////////
696
697 const Control = (
698 label: string,
699 icon: string,
700 props: Record<string, any> | Signal<Record<string, any>>,
701 ) => {
702 return h("command", props, [h("i", { className: icon, title: label })]);
703 };
704
705 const Controls = h("menu", {}, [
706 Control("Previous track", "ph-fill ph-rewind", { onclick: previous }),
707 h(
708 "div",
709 computed(() => {
710 const style = `display: ${activeTrack() && isLoading() ? "inherit" : "none"}`;
711 return { className: "animate-bounce menu__loader", style };
712 }),
713 [h("i", { className: "ph-fill ph-vinyl-record", title: "Loading ..." })],
714 ),
715 Control(
716 "Play",
717 "ph-fill ph-play",
718 computed(() => {
719 const style = `display: ${(!isPlaying() && !isLoading()) || !activeTrack() ? "inline" : "none"}`;
720 return { onclick: playPause, style };
721 }),
722 ),
723 Control(
724 "Pause",
725 "ph-fill ph-pause",
726 computed(() => {
727 const style = `display: ${isPlaying() && !isLoading() ? "inline" : "none"}`;
728 return { onclick: playPause, style };
729 }),
730 ),
731 Control("Next track", "ph-fill ph-fast-forward", { onclick: next }),
732 ]);
733
734 function playPause() {
735 const audioId = engine.queue.data.now?.id;
736
737 if (isPlaying() && audioId) {
738 engine.audio.sendAction("pause", { audioId });
739 } else if (audioId) {
740 engine.audio.sendAction("play", { audioId });
741 }
742 }
743
744 function previous() {
745 engine.queue.sendAction("unshift", { groupId: context.groupId }, { worker: true });
746 }
747
748 function next() {
749 engine.queue.sendAction("shift", { groupId: context.groupId }, { worker: true });
750 }
751
752 controller.appendChild(Controls);
753
754 ////////////////////////////////////////////
755 // UI ░ VOLUME
756 ////////////////////////////////////////////
757
758 const VolumeBar = h(
759 "div",
760 {
761 className: "progress-bar",
762 onclick: volumeClickHandler,
763 },
764 [
765 h(
766 "progress",
767 computed(() => ({
768 max: "100",
769 value: (volume() * 100).toString(),
770 })),
771 ),
772 ],
773 );
774
775 const Volume = h("footer", {}, [
776 h("i", { className: "ph-fill ph-speaker-none", onclick: mute }),
777 VolumeBar,
778 h("i", { className: "ph-fill ph-speaker-high", onclick: fullVolume }),
779 ]);
780
781 function volumeClickHandler(event: MouseEvent) {
782 const percentage = event.offsetX / (event.target as HTMLProgressElement).clientWidth;
783 engine.audio.sendAction("volume", { volume: percentage });
784 }
785
786 function fullVolume() {
787 engine.audio.sendAction("volume", { volume: 1 });
788 }
789
790 function mute() {
791 engine.audio.sendAction("volume", { volume: 0 });
792 }
793
794 controller.appendChild(Volume);
795</script>