diff --git a/deno.jsonc b/deno.jsonc
index 526697cc..2d510095 100644
--- a/deno.jsonc
+++ b/deno.jsonc
@@ -8,6 +8,7 @@
"@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2",
"@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.9.4",
"@fry69/deep-diff": "jsr:@fry69/deep-diff@^0.1.10",
+ "@js-temporal/polyfill": "npm:@js-temporal/polyfill@^0.5.1",
"@kunkun/kkrpc": "jsr:@kunkun/kkrpc@^0.6.0",
"@mary/ds-queue": "jsr:@mary/ds-queue@^0.1.3",
"@mys/m-rpc": "jsr:@mys/m-rpc@^0.12.2",
@@ -17,6 +18,7 @@
"@vicary/debounce-microtask": "jsr:@vicary/debounce-microtask@^0.1.8",
"alien-signals": "npm:alien-signals@^3.0.0",
"esbuild-plugins-node-modules-polyfill": "npm:esbuild-plugins-node-modules-polyfill@^1.7.1",
+ "fast-average-color": "npm:fast-average-color@^9.5.0",
"idb-keyval": "npm:idb-keyval@^6.2.2",
"lit-html": "npm:lit-html@^3.3.1",
"morphdom": "npm:morphdom@^2.7.7/dist/morphdom.js",
@@ -40,6 +42,7 @@
"@common/": "./src/common/",
"@components/": "./src/components/",
"@definitions/": "./src/definitions/",
+ "@styles/": "./src/styles/",
// Build
"@std/fs": "jsr:@std/fs@^1.0.19",
diff --git a/deno.lock b/deno.lock
index a0b3bc3c..13ecad42 100644
--- a/deno.lock
+++ b/deno.lock
@@ -42,10 +42,12 @@
"npm:@atcute/lex-cli@*": "2.3.1",
"npm:@atcute/lex-cli@^2.3.1": "2.3.1",
"npm:@atcute/lexicons@^1.2.2": "1.2.2",
+ "npm:@js-temporal/polyfill@~0.5.1": "0.5.1",
"npm:@tauri-apps/plugin-shell@^2.2.0": "2.3.3",
"npm:alien-signals@3": "3.0.3",
"npm:autoprefixer@10.4.21": "10.4.21_postcss@8.5.6",
"npm:esbuild-plugins-node-modules-polyfill@^1.7.1": "1.7.1_esbuild@0.25.12",
+ "npm:fast-average-color@^9.5.0": "9.5.0",
"npm:idb-keyval@^6.2.2": "6.2.2",
"npm:lightningcss-wasm@1.30.1": "1.30.1",
"npm:lit-html@^3.3.1": "3.3.1",
@@ -361,6 +363,12 @@
"os": ["win32"],
"cpu": ["x64"]
},
+ "@js-temporal/polyfill@0.5.1": {
+ "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==",
+ "dependencies": [
+ "jsbi"
+ ]
+ },
"@jspm/core@2.1.0": {
"integrity": "sha512-3sRl+pkyFY/kLmHl0cgHiFp2xEqErA8N3ECjMs7serSUBmoJ70lBa0PG5t0IM6WJgdZNyyI0R8YFfi5wM8+mzg=="
},
@@ -753,6 +761,9 @@
"exsolve@1.0.8": {
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="
},
+ "fast-average-color@9.5.0": {
+ "integrity": "sha512-nC6x2YIlJ9xxgkMFMd1BNoM1ctMjNoRKfRliPmiEWW3S6rLTHiQcy9g3pt/xiKv/D0NAAkhb9VyV+WJFvTqMGg=="
+ },
"fflate@0.8.2": {
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="
},
@@ -877,6 +888,9 @@
"js-tokens@4.0.0": {
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
+ "jsbi@4.3.2": {
+ "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="
+ },
"jszip@3.10.1": {
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dependencies": [
@@ -1790,8 +1804,10 @@
"npm:98.css@~0.1.21",
"npm:@atcute/lex-cli@^2.3.1",
"npm:@atcute/lexicons@^1.2.2",
+ "npm:@js-temporal/polyfill@~0.5.1",
"npm:alien-signals@3",
"npm:esbuild-plugins-node-modules-polyfill@^1.7.1",
+ "npm:fast-average-color@^9.5.0",
"npm:idb-keyval@^6.2.2",
"npm:lit-html@^3.3.1",
"npm:morphdom@^2.7.7",
diff --git a/src/_includes/layouts/diffuse.vto b/src/_includes/layouts/diffuse.vto
index c7f790b0..df219717 100644
--- a/src/_includes/layouts/diffuse.vto
+++ b/src/_includes/layouts/diffuse.vto
@@ -1,5 +1,9 @@
---
title: "Diffuse"
+
+base: "./"
+scripts: []
+styles: []
---
@@ -13,20 +17,29 @@ title: "Diffuse"
{{title}}
-
-
-
-
+
+
+
+
+
+ {{ for url of styles }}
+
+ {{ /for }}
+
+
+ {{ for url of scripts }}
+
+ {{ /for }}
{{ content }}
diff --git a/src/common/element.js b/src/common/element.js
index c588e631..5fd4e9c6 100644
--- a/src/common/element.js
+++ b/src/common/element.js
@@ -46,7 +46,7 @@ export class DiffuseElement extends HTMLElement {
}
/**
- * Effect helper that automatically disposes it
+ * Effect helper that automatically is disposes
* when this element is removed from the DOM.
*
* @param {() => void} fn
@@ -265,6 +265,15 @@ export class BroadcastableDiffuseElement extends DiffuseElement {
return /** @type {ProxiedActions} */ (actions);
}
+ async isLeader() {
+ if (this.broadcasted) {
+ const status = await this.broadcastingStatus();
+ return status.leader;
+ } else {
+ return true;
+ }
+ }
+
// LIFECYCLE
/**
diff --git a/src/common/index.js b/src/common/index.js
index f6232f33..dfec745c 100644
--- a/src/common/index.js
+++ b/src/common/index.js
@@ -1,4 +1,4 @@
-// import * as Uint8 from "uint8arrays";
+import * as Uint8 from "uint8arrays";
import { xxh32r } from "xxh32/dist/raw.js";
/**
@@ -92,9 +92,7 @@ export function jsonEncode(a) {
* @returns {Promise}
*/
export async function trackArtworkCacheId(track) {
- // TODO:
- return "";
- // return await crypto.subtle
- // .digest("SHA-256", new TextEncoder().encode(track.uri))
- // .then((a) => Uint8.toString(new Uint8Array(a), "base64url"));
+ return await crypto.subtle
+ .digest("SHA-256", new TextEncoder().encode(track.uri))
+ .then((a) => Uint8.toString(new Uint8Array(a), "base64url"));
}
diff --git a/src/components/engine/audio/element.js b/src/components/engine/audio/element.js
index 60b0cac9..511e91bd 100644
--- a/src/components/engine/audio/element.js
+++ b/src/components/engine/audio/element.js
@@ -20,6 +20,8 @@ const _SILENT_MP3 =
* @implements {Actions}
*/
class AudioEngine extends BroadcastableDiffuseElement {
+ static NAME = "diffuse/engine/audio";
+
// SIGNALS
#items = signal(/** @type {Audio[]} */ ([]));
@@ -43,15 +45,18 @@ class AudioEngine extends BroadcastableDiffuseElement {
connectedCallback() {
// Setup leader election if shared
if (this.hasAttribute("group")) {
- const actions = this.broadcast(`diffuse/engine/audio/${this.group}`, {
- adjustVolume: { strategy: "leaderOnly", fn: this.adjustVolume },
- pause: { strategy: "leaderOnly", fn: this.pause },
- play: { strategy: "leaderOnly", fn: this.play },
- seek: { strategy: "leaderOnly", fn: this.seek },
- supply: { strategy: "replicate", fn: this.supply },
-
- setIsPlaying: { strategy: "replicate", fn: this.$isPlaying.set },
- });
+ const actions = this.broadcast(
+ `${this.constructor.prototype.constructor.NAME}/${this.group}`,
+ {
+ adjustVolume: { strategy: "leaderOnly", fn: this.adjustVolume },
+ pause: { strategy: "leaderOnly", fn: this.pause },
+ play: { strategy: "leaderOnly", fn: this.play },
+ seek: { strategy: "leaderOnly", fn: this.seek },
+ supply: { strategy: "replicate", fn: this.supply },
+
+ setIsPlaying: { strategy: "replicate", fn: this.$isPlaying.set },
+ },
+ );
if (actions) {
this.adjustVolume = actions.adjustVolume;
@@ -68,7 +73,8 @@ class AudioEngine extends BroadcastableDiffuseElement {
super.connectedCallback();
// Get volume from previous session if possible
- const VOLUME_KEY = `diffuse/engine/audio/${this.group}/volume`;
+ const VOLUME_KEY =
+ `${this.constructor.prototype.constructor.NAME}/${this.group}/volume`;
const volume = localStorage.getItem(VOLUME_KEY);
if (volume != undefined) {
diff --git a/src/components/orchestrator/queue-tracks/element.js b/src/components/orchestrator/queue-tracks/element.js
index 44d03221..3ea0b2bd 100644
--- a/src/components/orchestrator/queue-tracks/element.js
+++ b/src/components/orchestrator/queue-tracks/element.js
@@ -1,11 +1,10 @@
import {
+ BroadcastableDiffuseElement,
callWorkerWithProvisions,
- DiffuseElement,
query,
terminateProvisions,
whenElementsDefined,
workerProxy,
- workerTunnel,
} from "@common/element.js";
import { untracked } from "@common/signal.js";
@@ -28,7 +27,7 @@ import { untracked } from "@common/signal.js";
* tracks have been loaded,
* or the tracks collection changes.
*/
-class QueueTracksOrchestrator extends DiffuseElement {
+class QueueTracksOrchestrator extends BroadcastableDiffuseElement {
static NAME = "diffuse/orchestrator/queue-tracks";
static WORKER_URL = "components/orchestrator/queue-tracks/worker.js";
@@ -77,11 +76,15 @@ class QueueTracksOrchestrator extends DiffuseElement {
// Watch tracks collection
this.effect(() => {
- const tracks = output.tracks.collection().filter((t) =>
- t.kind !== "placeholder"
- );
+ const tracks = output.tracks.collection();
- untracked(() => this.poolAvailable(tracks));
+ this.isLeader().then((isLeader) => {
+ if (!isLeader) return;
+
+ untracked(() =>
+ this.poolAvailable(tracks.filter((t) => t.kind !== "placeholder"))
+ );
+ });
});
}
diff --git a/src/components/output/types.d.ts b/src/components/output/types.d.ts
index eda2ea3e..d46bb8ae 100644
--- a/src/components/output/types.d.ts
+++ b/src/components/output/types.d.ts
@@ -1,7 +1,8 @@
import type { SignalReader } from "@common/signal.d.ts";
import type { Track } from "@definitions/types.d.ts";
+import type { DiffuseElement } from "@common/element.js";
-export type OutputElement = HTMLElement & OutputManager;
+export type OutputElement = DiffuseElement & OutputManager;
export type OutputManager = {
tracks: {
diff --git a/src/components/processor/artwork/worker.js b/src/components/processor/artwork/worker.js
index 9ec9089c..29eff824 100644
--- a/src/components/processor/artwork/worker.js
+++ b/src/components/processor/artwork/worker.js
@@ -2,6 +2,7 @@ import * as IDB from "idb-keyval";
import { IDB_ARTWORK_PREFIX } from "./constants.js";
import { musicMetadataTags } from "../metadata/common.js";
+import { ostiary, rpc } from "@common/worker.js";
/**
* @import {IPicture} from "music-metadata"
@@ -35,6 +36,17 @@ export function supply(items) {
if (exe) shiftQueue();
}
+////////////////////////////////////////////
+// ⚡️
+////////////////////////////////////////////
+
+ostiary((context) => {
+ rpc(context, {
+ artwork,
+ supply,
+ });
+});
+
////////////////////////////////////////////
// 🛠️
////////////////////////////////////////////
diff --git a/src/index.css b/src/index.css
index 11ab6907..5c1377db 100644
--- a/src/index.css
+++ b/src/index.css
@@ -80,9 +80,10 @@ small {
}
.construct {
- color: oklch(1 0 0 / 0.75);
+ color: oklch(from currentColor l c h / 0.65);
font-size: var(--fs-2xl);
font-weight: 900;
+ letter-spacing: -0.0125em;
line-height: 0.775em;
line-height: 1.05cap;
margin-bottom: var(--space-md);
diff --git a/src/index.vto b/src/index.vto
index 1f5d5140..3f4975e8 100644
--- a/src/index.vto
+++ b/src/index.vto
@@ -1,6 +1,9 @@
---
layout: layouts/diffuse.vto
+styles:
+ - index.css
+
# THEMES
themes:
@@ -116,7 +119,6 @@ definitions:
url: "definitions/output/tracks.json"
---
-
diff --git a/src/styles/diffuse/colors.css b/src/styles/diffuse/colors.css
index 182d2fbb..0e298114 100644
--- a/src/styles/diffuse/colors.css
+++ b/src/styles/diffuse/colors.css
@@ -3,7 +3,8 @@
--color-1: oklch(4.1308% 0.25306 109.22);
--color-2: oklch(98.369% 0.01834 67.664);
--color-3: oklch(26.787% 0.00168 186.65);
- --accent: oklch(86.947% 0.25527 28.789);
+ /*--accent: oklch(86.947% 0.25527 28.789);*/
+ --accent: hsl(80, 60.5%, 34.7%);
--bg-color: var(--color-2);
--text-color: var(--color-1);
diff --git a/src/themes/blur/artwork-controller/_applet.astro b/src/themes/blur/artwork-controller/_applet.astro
deleted file mode 100644
index 9b1493d3..00000000
--- a/src/themes/blur/artwork-controller/_applet.astro
+++ /dev/null
@@ -1,791 +0,0 @@
----
-import "@styles/reset.css";
-import "@styles/variables.css";
-import "@styles/fonts.css";
-import "@styles/animations.css";
-import "@styles/icons/phosphor.css";
-
-import "@styles/diffuse/colors.css";
-import "@styles/diffuse/fonts.css";
----
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/themes/blur/artwork-controller/element.css b/src/themes/blur/artwork-controller/element.css
new file mode 100644
index 00000000..fbf90832
--- /dev/null
+++ b/src/themes/blur/artwork-controller/element.css
@@ -0,0 +1,299 @@
+@import "../../../styles/reset.css";
+@import "../../../styles/variables.css";
+@import "../../../styles/animations.css";
+/*@import "../../../styles/icons/phosphor.css";*/
+
+@import "../../../styles/diffuse/colors.css";
+@import "../../../styles/diffuse/fonts.css";
+
+:root {
+ --transition-durition: 500ms;
+}
+
+main {
+ background: var(--color-3);
+ color: white;
+ display: flex;
+ flex-direction: column;
+ font-size: var(--fs-sm);
+ height: 100dvh;
+ overflow: hidden;
+ position: relative;
+ transition:
+ background-color var(--transition-durition),
+ color var(--transition-durition);
+}
+
+/* Artwork */
+
+.artwork {
+ app-region: drag;
+ flex: 1;
+ position: relative;
+ user-select: none;
+}
+
+.artwork img {
+ height: 100%;
+ left: 0;
+ object-fit: cover;
+ opacity: 0;
+ position: absolute;
+ top: 0;
+ transition-duration: var(--transition-durition);
+ transition-property: opacity;
+ width: 100%;
+ z-index: 0;
+}
+
+.artwork label {
+ background: oklch(0 0 0);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--box-shadow-lg);
+ font-size: var(--fs-2xs);
+ font-weight: 600;
+ left: var(--space-xs);
+ letter-spacing: var(--tracking-wide);
+ line-height: 1;
+ padding: 7px 6px 6px;
+ position: absolute;
+ text-transform: uppercase;
+ top: var(--space-xs);
+ transition:
+ background-color var(--transition-durition),
+ color var(--transition-durition);
+ z-index: 10;
+}
+
+/* Progress bars */
+
+progress {
+ appearance: none;
+ border: 0;
+ display: block;
+ height: 4px;
+ width: 100%;
+}
+
+progress,
+progress::-webkit-progress-bar {
+ background-color: color-mix(in oklch, currentColor 40%, transparent);
+ overflow: hidden;
+ border-radius: 4px;
+}
+
+progress[value]::-webkit-progress-value {
+ border-radius: 4px;
+ background-color: color-mix(in oklch, currentColor 90%, transparent);
+}
+
+progress[value]::-moz-progress-bar {
+ border-radius: 4px;
+ background-color: color-mix(in oklch, currentColor 50%, transparent);
+}
+
+/* Controller */
+
+.controller {
+ flex-shrink: 0;
+ padding: 0 var(--space-md) var(--space-md);
+ position: relative;
+}
+
+.controller__background {
+ inset: 0;
+ opacity: 0.5;
+ position: absolute;
+ transition: background-color var(--transition-durition);
+ z-index: 1;
+}
+
+.controller__inner {
+ position: relative;
+ transition-duration: var(--transition-durition);
+ transition-property: color;
+ z-index: 10;
+}
+
+.controller__inner.controller__inner--light-mode {
+ color: rgba(0, 0, 0, 0.6);
+}
+
+/* Now playing */
+
+cite {
+ display: block;
+ font-style: normal;
+ text-shadow: var(--text-shadow-sm);
+}
+
+.controller__inner--light-mode cite {
+ text-shadow: none;
+}
+
+/* Progress */
+
+.progress {
+ cursor: pointer;
+ margin: var(--space-xs) 0;
+ padding-top: var(--space-2xs);
+}
+
+.timestamps {
+ display: flex;
+ font-size: var(--fs-2xs);
+ font-weight: 500;
+ justify-content: space-between;
+ margin-top: var(--space-3xs);
+ opacity: 0.4;
+ text-shadow: var(--text-shadow-xs);
+}
+
+.controller__inner--light-mode .timestamps {
+ text-shadow: none;
+}
+
+/* Controls */
+
+.controller menu {
+ align-items: center;
+ display: flex;
+ font-size: var(--fs-lg);
+ gap: var(--space-lg);
+ justify-content: center;
+ margin: var(--space-md) 0;
+ padding: 0;
+ text-align: center;
+}
+
+.controller .menu__loader {
+ line-height: 0;
+ transform-origin: center;
+}
+
+.controller command {
+ cursor: pointer;
+ line-height: 0;
+ transition-duration: var(--transition-durition);
+ transition-property: opacity;
+}
+
+.controller .ph-pause,
+.controller .ph-play,
+.controller .menu__loader {
+ font-size: var(--fs-xl);
+}
+
+/* Volume */
+
+footer {
+ align-items: center;
+ display: flex;
+ font-size: var(--fs-xs);
+ gap: var(--space-2xs);
+ justify-content: space-between;
+}
+
+footer .progress-bar {
+ cursor: pointer;
+ flex: 1;
+ padding: var(--space-2xs) 0;
+}
+
+footer i {
+ cursor: pointer;
+}
+
+/* Gradient blur */
+
+.gradient-blur {
+ bottom: 0;
+ height: 150%;
+ left: 0;
+ pointer-events: none;
+ position: absolute;
+ right: 0;
+ z-index: 2;
+}
+
+.gradient-blur > div {
+ position: absolute;
+ inset: 0;
+}
+
+.gradient-blur > div:nth-of-type(1) {
+ backdrop-filter: blur(1px);
+ mask: linear-gradient(
+ to bottom,
+ rgba(0, 0, 0, 0) 0%,
+ rgba(0, 0, 0, 1) 4.166666665%,
+ rgba(0, 0, 0, 1) 8.333333332%,
+ rgba(0, 0, 0, 0) 12.499999999%
+ );
+ z-index: 1;
+}
+
+.gradient-blur > div:nth-of-type(2) {
+ backdrop-filter: blur(2px);
+ mask: linear-gradient(
+ to bottom,
+ rgba(0, 0, 0, 0) 4.166666665%,
+ rgba(0, 0, 0, 1) 8.333333332%,
+ rgba(0, 0, 0, 1) 12.499999999%,
+ rgba(0, 0, 0, 0) 16.666666666%
+ );
+ z-index: 2;
+}
+
+.gradient-blur > div:nth-of-type(3) {
+ backdrop-filter: blur(4px);
+ mask: linear-gradient(
+ to bottom,
+ rgba(0, 0, 0, 0) 8.333333332%,
+ rgba(0, 0, 0, 1) 12.499999999%,
+ rgba(0, 0, 0, 1) 16.666666666%,
+ rgba(0, 0, 0, 0) 20.833333333%
+ );
+ z-index: 3;
+}
+
+.gradient-blur > div:nth-of-type(4) {
+ backdrop-filter: blur(8px);
+ mask: linear-gradient(
+ to bottom,
+ rgba(0, 0, 0, 0) 12.499999999%,
+ rgba(0, 0, 0, 1) 16.666666666%,
+ rgba(0, 0, 0, 1) 20.833333333%,
+ rgba(0, 0, 0, 0) 25%
+ );
+ z-index: 4;
+}
+
+.gradient-blur > div:nth-of-type(5) {
+ backdrop-filter: blur(16px);
+ mask: linear-gradient(
+ to bottom,
+ rgba(0, 0, 0, 0) 16.666666666%,
+ rgba(0, 0, 0, 1) 20.833333333%,
+ rgba(0, 0, 0, 1) 25%,
+ rgba(0, 0, 0, 0) 100%
+ );
+ z-index: 5;
+}
+
+.gradient-blur > div:nth-of-type(6) {
+ backdrop-filter: blur(32px);
+ mask: linear-gradient(
+ to bottom,
+ rgba(0, 0, 0, 0) 20.833333333%,
+ rgba(0, 0, 0, 1) 25%,
+ rgba(0, 0, 0, 1) 100%
+ );
+ z-index: 6;
+}
+
+.gradient-blur > div:nth-of-type(7) {
+ backdrop-filter: blur(64px);
+ mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 1) 100%);
+ z-index: 7;
+}
diff --git a/src/themes/blur/artwork-controller/element.js b/src/themes/blur/artwork-controller/element.js
new file mode 100644
index 00000000..62eb22a7
--- /dev/null
+++ b/src/themes/blur/artwork-controller/element.js
@@ -0,0 +1,266 @@
+import { FastAverageColor } from "fast-average-color";
+import { Temporal } from "@js-temporal/polyfill";
+import { xxh32r } from "xxh32/dist/raw.js";
+import { debounce } from "throttle-debounce";
+
+import { DiffuseElement, query, whenElementsDefined } from "@common/element.js";
+import { trackArtworkCacheId } from "@common/index.js";
+import { signal } from "@common/signal.js";
+
+/**
+ * @import {RenderArg} from "@common/element.d.ts"
+ * @import {Track} from "@definitions/types.d.ts"
+ * @import {InputElement} from "@components/input/types.d.ts"
+ * @import {OutputElement} from "@components/output/types.d.ts"
+ * @import {Artwork} from "@components/processor/artwork/types.d.ts"
+ */
+
+class ArtworkController extends DiffuseElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ }
+
+ // SIGNALS
+
+ // activeTrack = signal(/** @type {Track | undefined} */ (undefined));
+ #artwork = signal(/** @type {Artwork[]} */ ([]));
+ #artworkColor = signal(/** @type {string | undefined} */ (undefined));
+ #artworkLightMode = signal(false);
+ #duration = signal("0:00");
+ // isLoading = signal(true);
+ // isPlaying = signal(false);
+ // progress = signal(0);
+ #time = signal("0:00");
+ // volume = signal(0);
+
+ // LIFECYCLE
+
+ /**
+ * @override
+ */
+ connectedCallback() {
+ super.connectedCallback();
+
+ /** @type {import("@components/processor/artwork/element.js").CLASS} */
+ const artwork = query(this, "artwork-processor-selector");
+
+ /** @type {import("@components/engine/audio/element.js").CLASS} */
+ const audio = query(this, "audio-engine-selector");
+
+ /** @type {InputElement} */
+ const input = query(this, "input-selector");
+
+ /** @type {import("@components/engine/queue/element.js").CLASS} */
+ const queue = query(this, "queue-engine-selector");
+
+ this.artwork = artwork;
+ this.audio = audio;
+ this.input = input;
+ this.queue = queue;
+
+ whenElementsDefined({ audio, artwork, input, queue }).then(() => {
+ // Changed artwork based on active queue item.
+ const debouncedChangeArtwork = debounce(
+ 1000,
+ this.#changeArtwork.bind(this),
+ );
+
+ this.effect(() => {
+ debouncedChangeArtwork(queue.now());
+ });
+
+ this.effect(() => {
+ const trigger = queue.now();
+ const _other_trigger = queue.poolHash();
+
+ queue.fill({ amount: 10, shuffled: true });
+ if (!trigger) queue.shift();
+ });
+
+ // Force render when elements are defined
+
+ // this.effect(() => {
+ // this.forceRender();
+ // });
+ });
+
+ this.#artworkEffects();
+ }
+
+ // EFFECTS
+
+ /**
+ * @param {Track | null} track
+ */
+ async #changeArtwork(track) {
+ if (!track) {
+ this.#artwork.value = [];
+ return;
+ }
+
+ const cacheId = await trackArtworkCacheId(track);
+
+ const resGet = await this.input?.resolve({ method: "GET", uri: track.uri });
+ const resHead = await this.input?.resolve({
+ method: "HEAD",
+ uri: track.uri,
+ });
+
+ if (!resGet) return;
+
+ const request = "stream" in resGet
+ ? {
+ cacheId,
+ stream: resGet.stream,
+ tags: track.tags,
+ }
+ : {
+ cacheId,
+ tags: track.tags,
+ urls: {
+ get: resGet.url,
+ head: resHead && "url" in resHead ? resHead.url : resGet.url,
+ },
+ };
+
+ const art = await this.artwork?.artwork(request) ?? [];
+ const currCacheId = track ? await trackArtworkCacheId(track) : undefined;
+ if (cacheId === currCacheId) this.#artwork.set(art);
+ }
+
+ #artworkEffects() {
+ /** @type {Record>} */
+ const timeouts = {};
+
+ this.effect(() => {
+ const art = this.#artwork.value;
+
+ // No artwork, fade out existing.
+ if (art.length === 0) {
+ this.querySelectorAll(":scope .artwork img").forEach((el) => {
+ const element = /** @type {HTMLElement} */ (el);
+ element.style.opacity = "0";
+ const hash = element.getAttribute("data-hash");
+ if (hash) timeouts[hash] = setTimeout(() => element.remove(), 1000);
+ });
+ return;
+ }
+
+ // Determine if the current artwork needs to be replaced.
+ const hash = xxh32r(art[0].bytes).toString();
+
+ /** @type {HTMLImageElement | null} */
+ const existingArtwork = this.querySelector(
+ `:scope .artwork img[data-hash="${hash}"]`,
+ );
+
+ // If the artwork is the same, stop here.
+ if (existingArtwork) {
+ const timeoutId = timeouts[hash];
+ if (timeoutId) clearTimeout(timeoutId);
+ existingArtwork.style.opacity = "1";
+ return;
+ }
+
+ // Add new artwork
+ const blob = new Blob(
+ [/** @type {ArrayBuffer} */ (art[0].bytes.buffer)],
+ { type: art[0].mime },
+ );
+ const url = URL.createObjectURL(blob);
+
+ /** @type {HTMLImageElement} */
+ const img = document.createElement("img");
+ img.setAttribute("data-hash", hash);
+ img.src = url;
+
+ // Extract average color
+ img.onload = () => {
+ const fac = new FastAverageColor();
+ const color = fac.getColor(img);
+ const rgb = color.value;
+ const o = Math.round(
+ (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000,
+ );
+
+ this.#artworkColor.value = color.rgba;
+ this.#artworkLightMode.value = o > 165;
+
+ /** @type {HTMLElement | null} */
+ const bg = this.querySelector(":scope .controller__background");
+ if (bg) bg.style.backgroundColor = color.rgba;
+
+ /** @type {HTMLElement | null} */
+ const main = this.querySelector(":scope main");
+ if (main) main.style.backgroundColor = color.rgba;
+
+ img.style.opacity = "1";
+
+ this.querySelectorAll(":scope .artwork img").forEach((el) => {
+ if (el === img) return;
+
+ const element = /** @type {HTMLElement} */ (el);
+ element.style.opacity = "0";
+ timeouts[hash] = setTimeout(() => element.remove(), 1000);
+ });
+ };
+
+ // Insert new artwork
+ this.querySelector(":scope .artwork")?.appendChild(img);
+ });
+
+ this.effect(() => {
+ // if (artworkLightMode()) {
+ // controller.classList.add("controller__inner--light-mode");
+ // } else controller.classList.remove("controller__inner--light-mode");
+ });
+ }
+
+ // RENDER
+
+ /**
+ * @param {RenderArg} _
+ */
+ render({ html }) {
+ return html`
+
+
+
+
+
+
+
+ `;
+ }
+}
+
+export default ArtworkController;
+
+////////////////////////////////////////////
+// REGISTER
+////////////////////////////////////////////
+
+export const CLASS = ArtworkController;
+export const NAME = "db-artwork-controller";
+
+customElements.define(NAME, CLASS);
diff --git a/src/themes/blur/artwork-controller/index.vto b/src/themes/blur/artwork-controller/index.vto
new file mode 100644
index 00000000..a655bf8a
--- /dev/null
+++ b/src/themes/blur/artwork-controller/index.vto
@@ -0,0 +1,53 @@
+---
+layout: layouts/diffuse.vto
+
+base: "../../../"
+
+scripts:
+ - element.js
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/themes/blur/index.js b/src/themes/blur/index.js
index 30e36c78..23e0083e 100644
--- a/src/themes/blur/index.js
+++ b/src/themes/blur/index.js
@@ -57,10 +57,5 @@ effect(() => {
// 🛠️
async function isLeader() {
- if (audio.broadcasted) {
- const status = await audio.broadcastingStatus();
- return status.leader;
- } else {
- return true;
- }
+ return await audio.isLeader();
}
diff --git a/src/themes/webamp/browser/element.js b/src/themes/webamp/browser/element.js
index 82f0ba8d..4389d130 100644
--- a/src/themes/webamp/browser/element.js
+++ b/src/themes/webamp/browser/element.js
@@ -1,4 +1,4 @@
-import { DiffuseElement, query } from "@common/element.js";
+import { DiffuseElement, query, whenElementsDefined } from "@common/element.js";
/**
* @import {RenderArg} from "@common/element.d.ts"
@@ -10,18 +10,7 @@ import { DiffuseElement, query } from "@common/element.js";
class Browser extends DiffuseElement {
constructor() {
super();
-
- // Enable Shadow DOM
this.attachShadow({ mode: "open" });
-
- /** @type {InputElement} */
- this.input = query(this, "input-selector");
-
- /** @type {OutputElement