Experiment to rebuild Diffuse using web applets.
0

Configure Feed

Select the types of activity you want to include in your feed.

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>