A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

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

feat: initial work for artwork-controller

+733 -851
+3
deno.jsonc
··· 8 8 "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2", 9 9 "@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.9.4", 10 10 "@fry69/deep-diff": "jsr:@fry69/deep-diff@^0.1.10", 11 + "@js-temporal/polyfill": "npm:@js-temporal/polyfill@^0.5.1", 11 12 "@kunkun/kkrpc": "jsr:@kunkun/kkrpc@^0.6.0", 12 13 "@mary/ds-queue": "jsr:@mary/ds-queue@^0.1.3", 13 14 "@mys/m-rpc": "jsr:@mys/m-rpc@^0.12.2", ··· 17 18 "@vicary/debounce-microtask": "jsr:@vicary/debounce-microtask@^0.1.8", 18 19 "alien-signals": "npm:alien-signals@^3.0.0", 19 20 "esbuild-plugins-node-modules-polyfill": "npm:esbuild-plugins-node-modules-polyfill@^1.7.1", 21 + "fast-average-color": "npm:fast-average-color@^9.5.0", 20 22 "idb-keyval": "npm:idb-keyval@^6.2.2", 21 23 "lit-html": "npm:lit-html@^3.3.1", 22 24 "morphdom": "npm:morphdom@^2.7.7/dist/morphdom.js", ··· 40 42 "@common/": "./src/common/", 41 43 "@components/": "./src/components/", 42 44 "@definitions/": "./src/definitions/", 45 + "@styles/": "./src/styles/", 43 46 44 47 // Build 45 48 "@std/fs": "jsr:@std/fs@^1.0.19",
+16
deno.lock
··· 42 42 "npm:@atcute/lex-cli@*": "2.3.1", 43 43 "npm:@atcute/lex-cli@^2.3.1": "2.3.1", 44 44 "npm:@atcute/lexicons@^1.2.2": "1.2.2", 45 + "npm:@js-temporal/polyfill@~0.5.1": "0.5.1", 45 46 "npm:@tauri-apps/plugin-shell@^2.2.0": "2.3.3", 46 47 "npm:alien-signals@3": "3.0.3", 47 48 "npm:autoprefixer@10.4.21": "10.4.21_postcss@8.5.6", 48 49 "npm:esbuild-plugins-node-modules-polyfill@^1.7.1": "1.7.1_esbuild@0.25.12", 50 + "npm:fast-average-color@^9.5.0": "9.5.0", 49 51 "npm:idb-keyval@^6.2.2": "6.2.2", 50 52 "npm:lightningcss-wasm@1.30.1": "1.30.1", 51 53 "npm:lit-html@^3.3.1": "3.3.1", ··· 361 363 "os": ["win32"], 362 364 "cpu": ["x64"] 363 365 }, 366 + "@js-temporal/polyfill@0.5.1": { 367 + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", 368 + "dependencies": [ 369 + "jsbi" 370 + ] 371 + }, 364 372 "@jspm/core@2.1.0": { 365 373 "integrity": "sha512-3sRl+pkyFY/kLmHl0cgHiFp2xEqErA8N3ECjMs7serSUBmoJ70lBa0PG5t0IM6WJgdZNyyI0R8YFfi5wM8+mzg==" 366 374 }, ··· 753 761 "exsolve@1.0.8": { 754 762 "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==" 755 763 }, 764 + "fast-average-color@9.5.0": { 765 + "integrity": "sha512-nC6x2YIlJ9xxgkMFMd1BNoM1ctMjNoRKfRliPmiEWW3S6rLTHiQcy9g3pt/xiKv/D0NAAkhb9VyV+WJFvTqMGg==" 766 + }, 756 767 "fflate@0.8.2": { 757 768 "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" 758 769 }, ··· 876 887 }, 877 888 "js-tokens@4.0.0": { 878 889 "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 890 + }, 891 + "jsbi@4.3.2": { 892 + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==" 879 893 }, 880 894 "jszip@3.10.1": { 881 895 "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", ··· 1790 1804 "npm:98.css@~0.1.21", 1791 1805 "npm:@atcute/lex-cli@^2.3.1", 1792 1806 "npm:@atcute/lexicons@^1.2.2", 1807 + "npm:@js-temporal/polyfill@~0.5.1", 1793 1808 "npm:alien-signals@3", 1794 1809 "npm:esbuild-plugins-node-modules-polyfill@^1.7.1", 1810 + "npm:fast-average-color@^9.5.0", 1795 1811 "npm:idb-keyval@^6.2.2", 1796 1812 "npm:lit-html@^3.3.1", 1797 1813 "npm:morphdom@^2.7.7",
+20 -7
src/_includes/layouts/diffuse.vto
··· 1 1 --- 2 2 title: "Diffuse" 3 + 4 + base: "./" 5 + scripts: [] 6 + styles: [] 3 7 --- 4 8 5 9 <html lang="en"> ··· 13 17 <title>{{title}}</title> 14 18 15 19 <!-- Favicons & Mobile --> 16 - <link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" /> 17 - <link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" /> 18 - <link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" /> 19 - <!-- TODO: <link rel="manifest" href="site.webmanifest" />--> 20 + <link rel="apple-touch-icon" sizes="180x180" href="{{base}}apple-touch-icon.png" /> 21 + <link rel="icon" type="image/png" sizes="32x32" href="{{base}}favicon-32x32.png" /> 22 + <link rel="icon" type="image/png" sizes="16x16" href="{{base}}favicon-16x16.png" /> 23 + <!-- TODO: <link rel="manifest" href="{{base}}site.webmanifest" />--> 20 24 <link rel="mask-icon" href="safari-pinned-tab.svg" color="#8a90a9" /> 21 25 <meta name="msapplication-TileColor" content="#8a90a9" /> 22 26 <meta name="theme-color" content="#8a90a9" /> 23 27 24 28 <!-- Styles --> 25 29 <style> 26 - @import "./styles/reset.css"; 27 - @import "./styles/fonts.css"; 28 - @import "./styles/variables.css"; 30 + @import "{{base}}styles/reset.css"; 31 + @import "{{base}}styles/fonts.css"; 32 + @import "{{base}}styles/variables.css"; 29 33 </style> 34 + 35 + {{ for url of styles }} 36 + <link rel="stylesheet" href="{{ url }}" /> 37 + {{ /for }} 38 + 39 + <!-- Scripts --> 40 + {{ for url of scripts }} 41 + <script defer src="{{ url }}" type="module"></script> 42 + {{ /for }} 30 43 </head> 31 44 <body> 32 45 {{ content }}
+10 -1
src/common/element.js
··· 46 46 } 47 47 48 48 /** 49 - * Effect helper that automatically disposes it 49 + * Effect helper that automatically is disposes 50 50 * when this element is removed from the DOM. 51 51 * 52 52 * @param {() => void} fn ··· 263 263 ); 264 264 265 265 return /** @type {ProxiedActions<Actions>} */ (actions); 266 + } 267 + 268 + async isLeader() { 269 + if (this.broadcasted) { 270 + const status = await this.broadcastingStatus(); 271 + return status.leader; 272 + } else { 273 + return true; 274 + } 266 275 } 267 276 268 277 // LIFECYCLE
+4 -6
src/common/index.js
··· 1 - // import * as Uint8 from "uint8arrays"; 1 + import * as Uint8 from "uint8arrays"; 2 2 import { xxh32r } from "xxh32/dist/raw.js"; 3 3 4 4 /** ··· 92 92 * @returns {Promise<string>} 93 93 */ 94 94 export async function trackArtworkCacheId(track) { 95 - // TODO: 96 - return ""; 97 - // return await crypto.subtle 98 - // .digest("SHA-256", new TextEncoder().encode(track.uri)) 99 - // .then((a) => Uint8.toString(new Uint8Array(a), "base64url")); 95 + return await crypto.subtle 96 + .digest("SHA-256", new TextEncoder().encode(track.uri)) 97 + .then((a) => Uint8.toString(new Uint8Array(a), "base64url")); 100 98 }
+15 -9
src/components/engine/audio/element.js
··· 20 20 * @implements {Actions} 21 21 */ 22 22 class AudioEngine extends BroadcastableDiffuseElement { 23 + static NAME = "diffuse/engine/audio"; 24 + 23 25 // SIGNALS 24 26 25 27 #items = signal(/** @type {Audio[]} */ ([])); ··· 43 45 connectedCallback() { 44 46 // Setup leader election if shared 45 47 if (this.hasAttribute("group")) { 46 - const actions = this.broadcast(`diffuse/engine/audio/${this.group}`, { 47 - adjustVolume: { strategy: "leaderOnly", fn: this.adjustVolume }, 48 - pause: { strategy: "leaderOnly", fn: this.pause }, 49 - play: { strategy: "leaderOnly", fn: this.play }, 50 - seek: { strategy: "leaderOnly", fn: this.seek }, 51 - supply: { strategy: "replicate", fn: this.supply }, 48 + const actions = this.broadcast( 49 + `${this.constructor.prototype.constructor.NAME}/${this.group}`, 50 + { 51 + adjustVolume: { strategy: "leaderOnly", fn: this.adjustVolume }, 52 + pause: { strategy: "leaderOnly", fn: this.pause }, 53 + play: { strategy: "leaderOnly", fn: this.play }, 54 + seek: { strategy: "leaderOnly", fn: this.seek }, 55 + supply: { strategy: "replicate", fn: this.supply }, 52 56 53 - setIsPlaying: { strategy: "replicate", fn: this.$isPlaying.set }, 54 - }); 57 + setIsPlaying: { strategy: "replicate", fn: this.$isPlaying.set }, 58 + }, 59 + ); 55 60 56 61 if (actions) { 57 62 this.adjustVolume = actions.adjustVolume; ··· 68 73 super.connectedCallback(); 69 74 70 75 // Get volume from previous session if possible 71 - const VOLUME_KEY = `diffuse/engine/audio/${this.group}/volume`; 76 + const VOLUME_KEY = 77 + `${this.constructor.prototype.constructor.NAME}/${this.group}/volume`; 72 78 const volume = localStorage.getItem(VOLUME_KEY); 73 79 74 80 if (volume != undefined) {
+10 -7
src/components/orchestrator/queue-tracks/element.js
··· 1 1 import { 2 + BroadcastableDiffuseElement, 2 3 callWorkerWithProvisions, 3 - DiffuseElement, 4 4 query, 5 5 terminateProvisions, 6 6 whenElementsDefined, 7 7 workerProxy, 8 - workerTunnel, 9 8 } from "@common/element.js"; 10 9 import { untracked } from "@common/signal.js"; 11 10 ··· 28 27 * tracks have been loaded, 29 28 * or the tracks collection changes. 30 29 */ 31 - class QueueTracksOrchestrator extends DiffuseElement { 30 + class QueueTracksOrchestrator extends BroadcastableDiffuseElement { 32 31 static NAME = "diffuse/orchestrator/queue-tracks"; 33 32 static WORKER_URL = "components/orchestrator/queue-tracks/worker.js"; 34 33 ··· 77 76 78 77 // Watch tracks collection 79 78 this.effect(() => { 80 - const tracks = output.tracks.collection().filter((t) => 81 - t.kind !== "placeholder" 82 - ); 79 + const tracks = output.tracks.collection(); 83 80 84 - untracked(() => this.poolAvailable(tracks)); 81 + this.isLeader().then((isLeader) => { 82 + if (!isLeader) return; 83 + 84 + untracked(() => 85 + this.poolAvailable(tracks.filter((t) => t.kind !== "placeholder")) 86 + ); 87 + }); 85 88 }); 86 89 } 87 90
+2 -1
src/components/output/types.d.ts
··· 1 1 import type { SignalReader } from "@common/signal.d.ts"; 2 2 import type { Track } from "@definitions/types.d.ts"; 3 + import type { DiffuseElement } from "@common/element.js"; 3 4 4 - export type OutputElement<Tracks> = HTMLElement & OutputManager<Tracks>; 5 + export type OutputElement<Tracks> = DiffuseElement & OutputManager<Tracks>; 5 6 6 7 export type OutputManager<Tracks> = { 7 8 tracks: {
+12
src/components/processor/artwork/worker.js
··· 2 2 3 3 import { IDB_ARTWORK_PREFIX } from "./constants.js"; 4 4 import { musicMetadataTags } from "../metadata/common.js"; 5 + import { ostiary, rpc } from "@common/worker.js"; 5 6 6 7 /** 7 8 * @import {IPicture} from "music-metadata" ··· 34 35 queue = [...queue, ...items]; 35 36 if (exe) shiftQueue(); 36 37 } 38 + 39 + //////////////////////////////////////////// 40 + // ⚡️ 41 + //////////////////////////////////////////// 42 + 43 + ostiary((context) => { 44 + rpc(context, { 45 + artwork, 46 + supply, 47 + }); 48 + }); 37 49 38 50 //////////////////////////////////////////// 39 51 // 🛠️
+2 -1
src/index.css
··· 80 80 } 81 81 82 82 .construct { 83 - color: oklch(1 0 0 / 0.75); 83 + color: oklch(from currentColor l c h / 0.65); 84 84 font-size: var(--fs-2xl); 85 85 font-weight: 900; 86 + letter-spacing: -0.0125em; 86 87 line-height: 0.775em; 87 88 line-height: 1.05cap; 88 89 margin-bottom: var(--space-md);
+3 -1
src/index.vto
··· 1 1 --- 2 2 layout: layouts/diffuse.vto 3 3 4 + styles: 5 + - index.css 6 + 4 7 # THEMES 5 8 6 9 themes: ··· 116 119 url: "definitions/output/tracks.json" 117 120 118 121 --- 119 - <link rel="stylesheet" href="index.css" /> 120 122 121 123 <header> 122 124 <h1>
+2 -1
src/styles/diffuse/colors.css
··· 3 3 --color-1: oklch(4.1308% 0.25306 109.22); 4 4 --color-2: oklch(98.369% 0.01834 67.664); 5 5 --color-3: oklch(26.787% 0.00168 186.65); 6 - --accent: oklch(86.947% 0.25527 28.789); 6 + /*--accent: oklch(86.947% 0.25527 28.789);*/ 7 + --accent: hsl(80, 60.5%, 34.7%); 7 8 8 9 --bg-color: var(--color-2); 9 10 --text-color: var(--color-1);
-791
src/themes/blur/artwork-controller/_applet.astro
··· 1 - --- 2 - import "@styles/reset.css"; 3 - import "@styles/variables.css"; 4 - import "@styles/fonts.css"; 5 - import "@styles/animations.css"; 6 - import "@styles/icons/phosphor.css"; 7 - 8 - import "@styles/diffuse/colors.css"; 9 - import "@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 - overflow: hidden; 47 - position: relative; 48 - transition: 49 - background-color var(--transition-durition), 50 - color var(--transition-durition); 51 - } 52 - 53 - /* Artwork */ 54 - 55 - .artwork { 56 - app-region: drag; 57 - flex: 1; 58 - position: relative; 59 - user-select: none; 60 - } 61 - 62 - .artwork img { 63 - height: 100%; 64 - left: 0; 65 - object-fit: cover; 66 - opacity: 0; 67 - position: absolute; 68 - top: 0; 69 - transition-duration: var(--transition-durition); 70 - transition-property: opacity; 71 - width: 100%; 72 - z-index: 0; 73 - } 74 - 75 - .artwork label { 76 - background: oklch(0 0 0); 77 - border-radius: var(--radius-sm); 78 - box-shadow: var(--box-shadow-lg); 79 - font-size: var(--fs-2xs); 80 - font-weight: 600; 81 - left: var(--space-xs); 82 - letter-spacing: var(--tracking-wide); 83 - line-height: 1; 84 - padding: 7px 6px 6px; 85 - position: absolute; 86 - text-transform: uppercase; 87 - top: var(--space-xs); 88 - transition: 89 - background-color var(--transition-durition), 90 - color var(--transition-durition); 91 - z-index: 10; 92 - } 93 - 94 - /* Progress bars */ 95 - 96 - progress { 97 - appearance: none; 98 - border: 0; 99 - display: block; 100 - height: 4px; 101 - width: 100%; 102 - } 103 - 104 - progress, 105 - progress::-webkit-progress-bar { 106 - background-color: color-mix(in oklch, currentColor 40%, transparent); 107 - overflow: hidden; 108 - border-radius: 4px; 109 - } 110 - 111 - progress[value]::-webkit-progress-value { 112 - border-radius: 4px; 113 - background-color: color-mix(in oklch, currentColor 90%, transparent); 114 - } 115 - 116 - progress[value]::-moz-progress-bar { 117 - border-radius: 4px; 118 - background-color: color-mix(in oklch, currentColor 50%, transparent); 119 - } 120 - 121 - /* Controller */ 122 - 123 - .controller { 124 - flex-shrink: 0; 125 - padding: 0 var(--space-md) var(--space-md); 126 - position: relative; 127 - } 128 - 129 - .controller__background { 130 - inset: 0; 131 - opacity: 0.5; 132 - position: absolute; 133 - transition: background-color var(--transition-durition); 134 - z-index: 1; 135 - } 136 - 137 - .controller__inner { 138 - position: relative; 139 - transition-duration: var(--transition-durition); 140 - transition-property: color; 141 - z-index: 10; 142 - } 143 - 144 - .controller__inner.controller__inner--light-mode { 145 - color: rgba(0, 0, 0, 0.6); 146 - } 147 - 148 - /* Now playing */ 149 - 150 - cite { 151 - display: block; 152 - font-style: normal; 153 - text-shadow: var(--text-shadow-sm); 154 - } 155 - 156 - .controller__inner--light-mode cite { 157 - text-shadow: none; 158 - } 159 - 160 - /* Progress */ 161 - 162 - .progress { 163 - cursor: pointer; 164 - margin: var(--space-xs) 0; 165 - padding-top: var(--space-2xs); 166 - } 167 - 168 - .timestamps { 169 - display: flex; 170 - font-size: var(--fs-2xs); 171 - font-weight: 500; 172 - justify-content: space-between; 173 - margin-top: var(--space-3xs); 174 - opacity: 0.4; 175 - text-shadow: var(--text-shadow-xs); 176 - } 177 - 178 - .controller__inner--light-mode .timestamps { 179 - text-shadow: none; 180 - } 181 - 182 - /* Controls */ 183 - 184 - .controller menu { 185 - align-items: center; 186 - display: flex; 187 - font-size: var(--fs-lg); 188 - gap: var(--space-lg); 189 - justify-content: center; 190 - margin: var(--space-md) 0; 191 - padding: 0; 192 - text-align: center; 193 - } 194 - 195 - .controller .menu__loader { 196 - line-height: 0; 197 - transform-origin: center; 198 - } 199 - 200 - .controller command { 201 - cursor: pointer; 202 - line-height: 0; 203 - transition-duration: var(--transition-durition); 204 - transition-property: opacity; 205 - } 206 - 207 - .controller .ph-pause, 208 - .controller .ph-play, 209 - .controller .menu__loader { 210 - font-size: var(--fs-xl); 211 - } 212 - 213 - /* Volume */ 214 - 215 - footer { 216 - align-items: center; 217 - display: flex; 218 - font-size: var(--fs-xs); 219 - gap: var(--space-2xs); 220 - justify-content: space-between; 221 - } 222 - 223 - footer .progress-bar { 224 - cursor: pointer; 225 - flex: 1; 226 - padding: var(--space-2xs) 0; 227 - } 228 - 229 - footer i { 230 - cursor: pointer; 231 - } 232 - 233 - /* Gradient blur */ 234 - 235 - .gradient-blur { 236 - bottom: 0; 237 - height: 150%; 238 - left: 0; 239 - pointer-events: none; 240 - position: absolute; 241 - right: 0; 242 - z-index: 2; 243 - } 244 - 245 - .gradient-blur > div { 246 - position: absolute; 247 - inset: 0; 248 - } 249 - 250 - .gradient-blur > div:nth-of-type(1) { 251 - backdrop-filter: blur(1px); 252 - mask: linear-gradient( 253 - to bottom, 254 - rgba(0, 0, 0, 0) 0%, 255 - rgba(0, 0, 0, 1) 4.166666665%, 256 - rgba(0, 0, 0, 1) 8.333333332%, 257 - rgba(0, 0, 0, 0) 12.499999999% 258 - ); 259 - z-index: 1; 260 - } 261 - 262 - .gradient-blur > div:nth-of-type(2) { 263 - backdrop-filter: blur(2px); 264 - mask: linear-gradient( 265 - to bottom, 266 - rgba(0, 0, 0, 0) 4.166666665%, 267 - rgba(0, 0, 0, 1) 8.333333332%, 268 - rgba(0, 0, 0, 1) 12.499999999%, 269 - rgba(0, 0, 0, 0) 16.666666666% 270 - ); 271 - z-index: 2; 272 - } 273 - 274 - .gradient-blur > div:nth-of-type(3) { 275 - backdrop-filter: blur(4px); 276 - mask: linear-gradient( 277 - to bottom, 278 - rgba(0, 0, 0, 0) 8.333333332%, 279 - rgba(0, 0, 0, 1) 12.499999999%, 280 - rgba(0, 0, 0, 1) 16.666666666%, 281 - rgba(0, 0, 0, 0) 20.833333333% 282 - ); 283 - z-index: 3; 284 - } 285 - 286 - .gradient-blur > div:nth-of-type(4) { 287 - backdrop-filter: blur(8px); 288 - mask: linear-gradient( 289 - to bottom, 290 - rgba(0, 0, 0, 0) 12.499999999%, 291 - rgba(0, 0, 0, 1) 16.666666666%, 292 - rgba(0, 0, 0, 1) 20.833333333%, 293 - rgba(0, 0, 0, 0) 25% 294 - ); 295 - z-index: 4; 296 - } 297 - 298 - .gradient-blur > div:nth-of-type(5) { 299 - backdrop-filter: blur(16px); 300 - mask: linear-gradient( 301 - to bottom, 302 - rgba(0, 0, 0, 0) 16.666666666%, 303 - rgba(0, 0, 0, 1) 20.833333333%, 304 - rgba(0, 0, 0, 1) 25%, 305 - rgba(0, 0, 0, 0) 100% 306 - ); 307 - z-index: 5; 308 - } 309 - 310 - .gradient-blur > div:nth-of-type(6) { 311 - backdrop-filter: blur(32px); 312 - mask: linear-gradient( 313 - to bottom, 314 - rgba(0, 0, 0, 0) 20.833333333%, 315 - rgba(0, 0, 0, 1) 25%, 316 - rgba(0, 0, 0, 1) 100% 317 - ); 318 - z-index: 6; 319 - } 320 - 321 - .gradient-blur > div:nth-of-type(7) { 322 - backdrop-filter: blur(64px); 323 - mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 1) 100%); 324 - z-index: 7; 325 - } 326 - </style> 327 - 328 - <style is:global> 329 - iframe { 330 - display: none; 331 - } 332 - </style> 333 - 334 - <script> 335 - import scope from "astro:scope"; 336 - import { FastAverageColor } from "fast-average-color"; 337 - import { Temporal } from "@js-temporal/polyfill"; 338 - import { xxh32r } from "xxh32/dist/raw.js"; 339 - import { debounce } from "throttle-debounce"; 340 - 341 - import { computed, effect, signal } from "@scripts/spellcaster"; 342 - import { tags, text, type ElementConfigurator } from "@scripts/spellcaster/hyperscript.js"; 343 - 344 - import type { Artwork } from "@applets/processor/artwork/types"; 345 - import type { Track } from "@applets/core/types"; 346 - import { applet, hs, inputUrl, reactive, register } from "@scripts/applet/common"; 347 - import { trackArtworkCacheId } from "@scripts/common"; 348 - 349 - //////////////////////////////////////////// 350 - // SETUP 351 - //////////////////////////////////////////// 352 - import type * as AudioEngine from "@applets/engine/audio/types.d.ts"; 353 - import type * as QueueEngine from "@applets/engine/queue/types.d.ts"; 354 - 355 - // Register 356 - const context = register(); 357 - 358 - // Signals 359 - const activeTrack = signal<Track | undefined>(undefined); 360 - const artwork = signal<Artwork[]>([]); 361 - const artworkColor = signal<string | undefined>(undefined); 362 - const artworkLightMode = signal<boolean>(false); 363 - const duration = signal<string>("0:00"); 364 - const isLoading = signal<boolean>(true); 365 - const isPlaying = signal<boolean>(false); 366 - const progress = signal<number>(0); 367 - const time = signal<string>("0:00"); 368 - const volume = 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 dur = curr?.stats?.duration ?? audio?.duration; 406 - 407 - if (audio && dur != undefined && !isNaN(dur)) { 408 - const p = Temporal.Duration.from({ 409 - milliseconds: Math.round(dur * prog * 1000), 410 - }).round({ 411 - largestUnit: "hours", 412 - }); 413 - 414 - const d = Temporal.Duration.from({ milliseconds: Math.round(dur * 1000) }).round({ 415 - largestUnit: "hours", 416 - }); 417 - 418 - time(formatTime(p)); 419 - duration(formatTime(d)); 420 - } else { 421 - time("0:00"); 422 - duration("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, isLoading); 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 - (i) => isPlaying(i), 454 - ); 455 - 456 - reactive( 457 - engine.audio, 458 - (data) => data.items[engine.queue.data.now?.id ?? Infinity]?.progress ?? 0, 459 - (p) => progress(p), 460 - ); 461 - 462 - reactive( 463 - engine.audio, 464 - (data) => data.volume.default, 465 - (v) => volume(v), 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 - activeTrack(track || undefined); 479 - progress(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 - artwork([]); 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) artwork(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 = hs(scope); 533 - 534 - //////////////////////////////////////////// 535 - // UI ░ ARTWORK 536 - //////////////////////////////////////////// 537 - 538 - const timeouts: Record<string, ReturnType<typeof setTimeout>> = {}; 539 - 540 - effect(() => { 541 - const art = artwork(); 542 - 543 - // No artwork, fade out existing. 544 - if (art.length === 0) { 545 - showcase.querySelectorAll("img").forEach((node) => { 546 - node.style.opacity = "0"; 547 - const hash = node.getAttribute("data-hash"); 548 - if (hash) timeouts[hash] = setTimeout(() => node.remove(), 1000); 549 - }); 550 - return; 551 - } 552 - 553 - // Determine if the current artwork needs to be replaced. 554 - const hash = xxh32r(art[0].bytes).toString(); 555 - const existingArtwork = showcase.querySelector<HTMLImageElement>(`img[data-hash="${hash}"]`); 556 - 557 - // If the artwork is the same, stop here. 558 - if (existingArtwork) { 559 - const timeoutId = timeouts[hash]; 560 - if (timeoutId) clearTimeout(timeoutId); 561 - existingArtwork.style.opacity = "1"; 562 - return; 563 - } 564 - 565 - // Add new artwork 566 - const blob = new Blob([art[0].bytes.buffer as ArrayBuffer], { type: art[0].mime }); 567 - const url = URL.createObjectURL(blob); 568 - 569 - // Create img for new artwork 570 - const img = h("img", { 571 - src: url, 572 - className: "artwork", 573 - attrs: { 574 - "data-hash": hash, 575 - }, 576 - }); 577 - 578 - // Extract average color 579 - img.onload = () => { 580 - const fac = new FastAverageColor(); 581 - const color = fac.getColor(img as HTMLImageElement); 582 - const rgb = color.value; 583 - const o = Math.round((rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000); 584 - 585 - artworkColor(color.rgba); 586 - artworkLightMode(o > 165); 587 - bg.style.backgroundColor = color.rgba; 588 - main.style.backgroundColor = color.rgba; 589 - img.style.opacity = "1"; 590 - 591 - showcase.querySelectorAll("img").forEach((node) => { 592 - if (node === img) return; 593 - node.style.opacity = "0"; 594 - timeouts[hash] = setTimeout(() => node.remove(), 1000); 595 - }); 596 - }; 597 - 598 - // Insert new artwork 599 - showcase.appendChild(img); 600 - }); 601 - 602 - effect(() => { 603 - if (artworkLightMode()) controller.classList.add("controller__inner--light-mode"); 604 - else controller.classList.remove("controller__inner--light-mode"); 605 - }); 606 - 607 - //////////////////////////////////////////// 608 - // UI ░ GROUP ID 609 - //////////////////////////////////////////// 610 - 611 - const GroupId = h( 612 - "label", 613 - computed(() => { 614 - const display = isMainGroup() ? "none" : "block"; 615 - 616 - return { 617 - attrs: { 618 - style: `background: ${artworkColor()}; display: ${display};`, 619 - }, 620 - }; 621 - }), 622 - text(context.groupId), 623 - ); 624 - 625 - showcase.appendChild(GroupId); 626 - 627 - //////////////////////////////////////////// 628 - // UI ░ NOW PLAYING 629 - //////////////////////////////////////////// 630 - 631 - const NowPlaying = h("cite", {}, [ 632 - h("strong", {}, text(computed(() => activeTrack()?.tags?.title || "Diffuse"))), 633 - tags.br(), 634 - h( 635 - "span", 636 - computed(() => { 637 - return { 638 - style: isMainGroup() && !activeTrack() ? "font-style: italic" : "", 639 - }; 640 - }), 641 - text( 642 - computed( 643 - () => 644 - activeTrack()?.tags?.artist || 645 - (isMainGroup() && !activeTrack() ? "Waiting on queue ..." : ""), 646 - ), 647 - ), 648 - ), 649 - ]); 650 - 651 - controller.appendChild(NowPlaying); 652 - 653 - //////////////////////////////////////////// 654 - // UI ░ PROGRESS 655 - //////////////////////////////////////////// 656 - 657 - const ProgressBar = h( 658 - "progress", 659 - computed(() => ({ max: "100", value: `${progress() * 100}` })), 660 - [], 661 - ); 662 - 663 - const Time = h("div", { className: "timestamps" }, [ 664 - h( 665 - "time", 666 - computed(() => { 667 - return { attrs: { datetime: time() } }; 668 - }), 669 - text(time), 670 - ), 671 - h( 672 - "time", 673 - computed(() => { 674 - return { attrs: { datetime: duration() } }; 675 - }), 676 - text(duration), 677 - ), 678 - ]); 679 - 680 - const Progress = h("div", { className: "progress", onclick: seek }, [ProgressBar, Time]); 681 - 682 - function seek(event: MouseEvent) { 683 - const percentage = event.offsetX / (event.target as HTMLProgressElement).clientWidth; 684 - engine.audio.sendAction("seek", { audioId: engine.queue.data.now?.id, percentage }); 685 - } 686 - 687 - controller.appendChild(Progress); 688 - 689 - //////////////////////////////////////////// 690 - // UI ░ CONTROLS 691 - //////////////////////////////////////////// 692 - 693 - const Control = ( 694 - label: string, 695 - icon: string, 696 - props: Record<string, any> | (() => Record<string, any>), 697 - ) => { 698 - return h("command", props, [h("i", { className: icon, title: label })]); 699 - }; 700 - 701 - const Controls = h("menu", {}, [ 702 - Control("Previous track", "ph-fill ph-rewind", { onclick: previous }), 703 - h( 704 - "div", 705 - computed(() => { 706 - const style = `display: ${activeTrack() && isLoading() ? "inherit" : "none"}`; 707 - return { className: "animate-bounce menu__loader", style }; 708 - }), 709 - [h("i", { className: "ph-fill ph-vinyl-record", title: "Loading ..." })], 710 - ), 711 - Control( 712 - "Play", 713 - "ph-fill ph-play", 714 - computed(() => { 715 - const style = `display: ${(!isPlaying() && !isLoading()) || !activeTrack() ? "inline" : "none"}`; 716 - return { onclick: playPause, style }; 717 - }), 718 - ), 719 - Control( 720 - "Pause", 721 - "ph-fill ph-pause", 722 - computed(() => { 723 - const style = `display: ${isPlaying() && !isLoading() ? "inline" : "none"}`; 724 - return { onclick: playPause, style }; 725 - }), 726 - ), 727 - Control("Next track", "ph-fill ph-fast-forward", { onclick: next }), 728 - ]); 729 - 730 - function playPause() { 731 - const audioId = engine.queue.data.now?.id; 732 - 733 - if (isPlaying() && audioId) { 734 - engine.audio.sendAction("pause", { audioId }); 735 - } else if (audioId) { 736 - engine.audio.sendAction("play", { audioId }); 737 - } 738 - } 739 - 740 - function previous() { 741 - engine.queue.sendAction("unshift", { groupId: context.groupId }, { worker: true }); 742 - } 743 - 744 - function next() { 745 - engine.queue.sendAction("shift", { groupId: context.groupId }, { worker: true }); 746 - } 747 - 748 - controller.appendChild(Controls); 749 - 750 - //////////////////////////////////////////// 751 - // UI ░ VOLUME 752 - //////////////////////////////////////////// 753 - 754 - const VolumeBar = h( 755 - "div", 756 - { 757 - className: "progress-bar", 758 - onclick: volumeClickHandler, 759 - }, 760 - [ 761 - h( 762 - "progress", 763 - computed(() => ({ 764 - max: "100", 765 - value: (volume() * 100).toString(), 766 - })), 767 - ), 768 - ], 769 - ); 770 - 771 - const Volume = h("footer", {}, [ 772 - h("i", { className: "ph-fill ph-speaker-none", onclick: mute }), 773 - VolumeBar, 774 - h("i", { className: "ph-fill ph-speaker-high", onclick: fullVolume }), 775 - ]); 776 - 777 - function volumeClickHandler(event: MouseEvent) { 778 - const percentage = event.offsetX / (event.target as HTMLProgressElement).clientWidth; 779 - engine.audio.sendAction("volume", { volume: percentage }); 780 - } 781 - 782 - function fullVolume() { 783 - engine.audio.sendAction("volume", { volume: 1 }); 784 - } 785 - 786 - function mute() { 787 - engine.audio.sendAction("volume", { volume: 0 }); 788 - } 789 - 790 - controller.appendChild(Volume); 791 - </script>
+299
src/themes/blur/artwork-controller/element.css
··· 1 + @import "../../../styles/reset.css"; 2 + @import "../../../styles/variables.css"; 3 + @import "../../../styles/animations.css"; 4 + /*@import "../../../styles/icons/phosphor.css";*/ 5 + 6 + @import "../../../styles/diffuse/colors.css"; 7 + @import "../../../styles/diffuse/fonts.css"; 8 + 9 + :root { 10 + --transition-durition: 500ms; 11 + } 12 + 13 + main { 14 + background: var(--color-3); 15 + color: white; 16 + display: flex; 17 + flex-direction: column; 18 + font-size: var(--fs-sm); 19 + height: 100dvh; 20 + overflow: hidden; 21 + position: relative; 22 + transition: 23 + background-color var(--transition-durition), 24 + color var(--transition-durition); 25 + } 26 + 27 + /* Artwork */ 28 + 29 + .artwork { 30 + app-region: drag; 31 + flex: 1; 32 + position: relative; 33 + user-select: none; 34 + } 35 + 36 + .artwork img { 37 + height: 100%; 38 + left: 0; 39 + object-fit: cover; 40 + opacity: 0; 41 + position: absolute; 42 + top: 0; 43 + transition-duration: var(--transition-durition); 44 + transition-property: opacity; 45 + width: 100%; 46 + z-index: 0; 47 + } 48 + 49 + .artwork label { 50 + background: oklch(0 0 0); 51 + border-radius: var(--radius-sm); 52 + box-shadow: var(--box-shadow-lg); 53 + font-size: var(--fs-2xs); 54 + font-weight: 600; 55 + left: var(--space-xs); 56 + letter-spacing: var(--tracking-wide); 57 + line-height: 1; 58 + padding: 7px 6px 6px; 59 + position: absolute; 60 + text-transform: uppercase; 61 + top: var(--space-xs); 62 + transition: 63 + background-color var(--transition-durition), 64 + color var(--transition-durition); 65 + z-index: 10; 66 + } 67 + 68 + /* Progress bars */ 69 + 70 + progress { 71 + appearance: none; 72 + border: 0; 73 + display: block; 74 + height: 4px; 75 + width: 100%; 76 + } 77 + 78 + progress, 79 + progress::-webkit-progress-bar { 80 + background-color: color-mix(in oklch, currentColor 40%, transparent); 81 + overflow: hidden; 82 + border-radius: 4px; 83 + } 84 + 85 + progress[value]::-webkit-progress-value { 86 + border-radius: 4px; 87 + background-color: color-mix(in oklch, currentColor 90%, transparent); 88 + } 89 + 90 + progress[value]::-moz-progress-bar { 91 + border-radius: 4px; 92 + background-color: color-mix(in oklch, currentColor 50%, transparent); 93 + } 94 + 95 + /* Controller */ 96 + 97 + .controller { 98 + flex-shrink: 0; 99 + padding: 0 var(--space-md) var(--space-md); 100 + position: relative; 101 + } 102 + 103 + .controller__background { 104 + inset: 0; 105 + opacity: 0.5; 106 + position: absolute; 107 + transition: background-color var(--transition-durition); 108 + z-index: 1; 109 + } 110 + 111 + .controller__inner { 112 + position: relative; 113 + transition-duration: var(--transition-durition); 114 + transition-property: color; 115 + z-index: 10; 116 + } 117 + 118 + .controller__inner.controller__inner--light-mode { 119 + color: rgba(0, 0, 0, 0.6); 120 + } 121 + 122 + /* Now playing */ 123 + 124 + cite { 125 + display: block; 126 + font-style: normal; 127 + text-shadow: var(--text-shadow-sm); 128 + } 129 + 130 + .controller__inner--light-mode cite { 131 + text-shadow: none; 132 + } 133 + 134 + /* Progress */ 135 + 136 + .progress { 137 + cursor: pointer; 138 + margin: var(--space-xs) 0; 139 + padding-top: var(--space-2xs); 140 + } 141 + 142 + .timestamps { 143 + display: flex; 144 + font-size: var(--fs-2xs); 145 + font-weight: 500; 146 + justify-content: space-between; 147 + margin-top: var(--space-3xs); 148 + opacity: 0.4; 149 + text-shadow: var(--text-shadow-xs); 150 + } 151 + 152 + .controller__inner--light-mode .timestamps { 153 + text-shadow: none; 154 + } 155 + 156 + /* Controls */ 157 + 158 + .controller menu { 159 + align-items: center; 160 + display: flex; 161 + font-size: var(--fs-lg); 162 + gap: var(--space-lg); 163 + justify-content: center; 164 + margin: var(--space-md) 0; 165 + padding: 0; 166 + text-align: center; 167 + } 168 + 169 + .controller .menu__loader { 170 + line-height: 0; 171 + transform-origin: center; 172 + } 173 + 174 + .controller command { 175 + cursor: pointer; 176 + line-height: 0; 177 + transition-duration: var(--transition-durition); 178 + transition-property: opacity; 179 + } 180 + 181 + .controller .ph-pause, 182 + .controller .ph-play, 183 + .controller .menu__loader { 184 + font-size: var(--fs-xl); 185 + } 186 + 187 + /* Volume */ 188 + 189 + footer { 190 + align-items: center; 191 + display: flex; 192 + font-size: var(--fs-xs); 193 + gap: var(--space-2xs); 194 + justify-content: space-between; 195 + } 196 + 197 + footer .progress-bar { 198 + cursor: pointer; 199 + flex: 1; 200 + padding: var(--space-2xs) 0; 201 + } 202 + 203 + footer i { 204 + cursor: pointer; 205 + } 206 + 207 + /* Gradient blur */ 208 + 209 + .gradient-blur { 210 + bottom: 0; 211 + height: 150%; 212 + left: 0; 213 + pointer-events: none; 214 + position: absolute; 215 + right: 0; 216 + z-index: 2; 217 + } 218 + 219 + .gradient-blur > div { 220 + position: absolute; 221 + inset: 0; 222 + } 223 + 224 + .gradient-blur > div:nth-of-type(1) { 225 + backdrop-filter: blur(1px); 226 + mask: linear-gradient( 227 + to bottom, 228 + rgba(0, 0, 0, 0) 0%, 229 + rgba(0, 0, 0, 1) 4.166666665%, 230 + rgba(0, 0, 0, 1) 8.333333332%, 231 + rgba(0, 0, 0, 0) 12.499999999% 232 + ); 233 + z-index: 1; 234 + } 235 + 236 + .gradient-blur > div:nth-of-type(2) { 237 + backdrop-filter: blur(2px); 238 + mask: linear-gradient( 239 + to bottom, 240 + rgba(0, 0, 0, 0) 4.166666665%, 241 + rgba(0, 0, 0, 1) 8.333333332%, 242 + rgba(0, 0, 0, 1) 12.499999999%, 243 + rgba(0, 0, 0, 0) 16.666666666% 244 + ); 245 + z-index: 2; 246 + } 247 + 248 + .gradient-blur > div:nth-of-type(3) { 249 + backdrop-filter: blur(4px); 250 + mask: linear-gradient( 251 + to bottom, 252 + rgba(0, 0, 0, 0) 8.333333332%, 253 + rgba(0, 0, 0, 1) 12.499999999%, 254 + rgba(0, 0, 0, 1) 16.666666666%, 255 + rgba(0, 0, 0, 0) 20.833333333% 256 + ); 257 + z-index: 3; 258 + } 259 + 260 + .gradient-blur > div:nth-of-type(4) { 261 + backdrop-filter: blur(8px); 262 + mask: linear-gradient( 263 + to bottom, 264 + rgba(0, 0, 0, 0) 12.499999999%, 265 + rgba(0, 0, 0, 1) 16.666666666%, 266 + rgba(0, 0, 0, 1) 20.833333333%, 267 + rgba(0, 0, 0, 0) 25% 268 + ); 269 + z-index: 4; 270 + } 271 + 272 + .gradient-blur > div:nth-of-type(5) { 273 + backdrop-filter: blur(16px); 274 + mask: linear-gradient( 275 + to bottom, 276 + rgba(0, 0, 0, 0) 16.666666666%, 277 + rgba(0, 0, 0, 1) 20.833333333%, 278 + rgba(0, 0, 0, 1) 25%, 279 + rgba(0, 0, 0, 0) 100% 280 + ); 281 + z-index: 5; 282 + } 283 + 284 + .gradient-blur > div:nth-of-type(6) { 285 + backdrop-filter: blur(32px); 286 + mask: linear-gradient( 287 + to bottom, 288 + rgba(0, 0, 0, 0) 20.833333333%, 289 + rgba(0, 0, 0, 1) 25%, 290 + rgba(0, 0, 0, 1) 100% 291 + ); 292 + z-index: 6; 293 + } 294 + 295 + .gradient-blur > div:nth-of-type(7) { 296 + backdrop-filter: blur(64px); 297 + mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 1) 100%); 298 + z-index: 7; 299 + }
+266
src/themes/blur/artwork-controller/element.js
··· 1 + import { FastAverageColor } from "fast-average-color"; 2 + import { Temporal } from "@js-temporal/polyfill"; 3 + import { xxh32r } from "xxh32/dist/raw.js"; 4 + import { debounce } from "throttle-debounce"; 5 + 6 + import { DiffuseElement, query, whenElementsDefined } from "@common/element.js"; 7 + import { trackArtworkCacheId } from "@common/index.js"; 8 + import { signal } from "@common/signal.js"; 9 + 10 + /** 11 + * @import {RenderArg} from "@common/element.d.ts" 12 + * @import {Track} from "@definitions/types.d.ts" 13 + * @import {InputElement} from "@components/input/types.d.ts" 14 + * @import {OutputElement} from "@components/output/types.d.ts" 15 + * @import {Artwork} from "@components/processor/artwork/types.d.ts" 16 + */ 17 + 18 + class ArtworkController extends DiffuseElement { 19 + constructor() { 20 + super(); 21 + this.attachShadow({ mode: "open" }); 22 + } 23 + 24 + // SIGNALS 25 + 26 + // activeTrack = signal(/** @type {Track | undefined} */ (undefined)); 27 + #artwork = signal(/** @type {Artwork[]} */ ([])); 28 + #artworkColor = signal(/** @type {string | undefined} */ (undefined)); 29 + #artworkLightMode = signal(false); 30 + #duration = signal("0:00"); 31 + // isLoading = signal(true); 32 + // isPlaying = signal(false); 33 + // progress = signal(0); 34 + #time = signal("0:00"); 35 + // volume = signal(0); 36 + 37 + // LIFECYCLE 38 + 39 + /** 40 + * @override 41 + */ 42 + connectedCallback() { 43 + super.connectedCallback(); 44 + 45 + /** @type {import("@components/processor/artwork/element.js").CLASS} */ 46 + const artwork = query(this, "artwork-processor-selector"); 47 + 48 + /** @type {import("@components/engine/audio/element.js").CLASS} */ 49 + const audio = query(this, "audio-engine-selector"); 50 + 51 + /** @type {InputElement} */ 52 + const input = query(this, "input-selector"); 53 + 54 + /** @type {import("@components/engine/queue/element.js").CLASS} */ 55 + const queue = query(this, "queue-engine-selector"); 56 + 57 + this.artwork = artwork; 58 + this.audio = audio; 59 + this.input = input; 60 + this.queue = queue; 61 + 62 + whenElementsDefined({ audio, artwork, input, queue }).then(() => { 63 + // Changed artwork based on active queue item. 64 + const debouncedChangeArtwork = debounce( 65 + 1000, 66 + this.#changeArtwork.bind(this), 67 + ); 68 + 69 + this.effect(() => { 70 + debouncedChangeArtwork(queue.now()); 71 + }); 72 + 73 + this.effect(() => { 74 + const trigger = queue.now(); 75 + const _other_trigger = queue.poolHash(); 76 + 77 + queue.fill({ amount: 10, shuffled: true }); 78 + if (!trigger) queue.shift(); 79 + }); 80 + 81 + // Force render when elements are defined 82 + 83 + // this.effect(() => { 84 + // this.forceRender(); 85 + // }); 86 + }); 87 + 88 + this.#artworkEffects(); 89 + } 90 + 91 + // EFFECTS 92 + 93 + /** 94 + * @param {Track | null} track 95 + */ 96 + async #changeArtwork(track) { 97 + if (!track) { 98 + this.#artwork.value = []; 99 + return; 100 + } 101 + 102 + const cacheId = await trackArtworkCacheId(track); 103 + 104 + const resGet = await this.input?.resolve({ method: "GET", uri: track.uri }); 105 + const resHead = await this.input?.resolve({ 106 + method: "HEAD", 107 + uri: track.uri, 108 + }); 109 + 110 + if (!resGet) return; 111 + 112 + const request = "stream" in resGet 113 + ? { 114 + cacheId, 115 + stream: resGet.stream, 116 + tags: track.tags, 117 + } 118 + : { 119 + cacheId, 120 + tags: track.tags, 121 + urls: { 122 + get: resGet.url, 123 + head: resHead && "url" in resHead ? resHead.url : resGet.url, 124 + }, 125 + }; 126 + 127 + const art = await this.artwork?.artwork(request) ?? []; 128 + const currCacheId = track ? await trackArtworkCacheId(track) : undefined; 129 + if (cacheId === currCacheId) this.#artwork.set(art); 130 + } 131 + 132 + #artworkEffects() { 133 + /** @type {Record<string, ReturnType<typeof setTimeout>>} */ 134 + const timeouts = {}; 135 + 136 + this.effect(() => { 137 + const art = this.#artwork.value; 138 + 139 + // No artwork, fade out existing. 140 + if (art.length === 0) { 141 + this.querySelectorAll(":scope .artwork img").forEach((el) => { 142 + const element = /** @type {HTMLElement} */ (el); 143 + element.style.opacity = "0"; 144 + const hash = element.getAttribute("data-hash"); 145 + if (hash) timeouts[hash] = setTimeout(() => element.remove(), 1000); 146 + }); 147 + return; 148 + } 149 + 150 + // Determine if the current artwork needs to be replaced. 151 + const hash = xxh32r(art[0].bytes).toString(); 152 + 153 + /** @type {HTMLImageElement | null} */ 154 + const existingArtwork = this.querySelector( 155 + `:scope .artwork img[data-hash="${hash}"]`, 156 + ); 157 + 158 + // If the artwork is the same, stop here. 159 + if (existingArtwork) { 160 + const timeoutId = timeouts[hash]; 161 + if (timeoutId) clearTimeout(timeoutId); 162 + existingArtwork.style.opacity = "1"; 163 + return; 164 + } 165 + 166 + // Add new artwork 167 + const blob = new Blob( 168 + [/** @type {ArrayBuffer} */ (art[0].bytes.buffer)], 169 + { type: art[0].mime }, 170 + ); 171 + const url = URL.createObjectURL(blob); 172 + 173 + /** @type {HTMLImageElement} */ 174 + const img = document.createElement("img"); 175 + img.setAttribute("data-hash", hash); 176 + img.src = url; 177 + 178 + // Extract average color 179 + img.onload = () => { 180 + const fac = new FastAverageColor(); 181 + const color = fac.getColor(img); 182 + const rgb = color.value; 183 + const o = Math.round( 184 + (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000, 185 + ); 186 + 187 + this.#artworkColor.value = color.rgba; 188 + this.#artworkLightMode.value = o > 165; 189 + 190 + /** @type {HTMLElement | null} */ 191 + const bg = this.querySelector(":scope .controller__background"); 192 + if (bg) bg.style.backgroundColor = color.rgba; 193 + 194 + /** @type {HTMLElement | null} */ 195 + const main = this.querySelector(":scope main"); 196 + if (main) main.style.backgroundColor = color.rgba; 197 + 198 + img.style.opacity = "1"; 199 + 200 + this.querySelectorAll(":scope .artwork img").forEach((el) => { 201 + if (el === img) return; 202 + 203 + const element = /** @type {HTMLElement} */ (el); 204 + element.style.opacity = "0"; 205 + timeouts[hash] = setTimeout(() => element.remove(), 1000); 206 + }); 207 + }; 208 + 209 + // Insert new artwork 210 + this.querySelector(":scope .artwork")?.appendChild(img); 211 + }); 212 + 213 + this.effect(() => { 214 + // if (artworkLightMode()) { 215 + // controller.classList.add("controller__inner--light-mode"); 216 + // } else controller.classList.remove("controller__inner--light-mode"); 217 + }); 218 + } 219 + 220 + // RENDER 221 + 222 + /** 223 + * @param {RenderArg} _ 224 + */ 225 + render({ html }) { 226 + return html` 227 + <style> 228 + @import "../../../styles/fonts.css"; 229 + @import "./element.css"; 230 + </style> 231 + 232 + <main> 233 + <section class="artwork"></section> 234 + 235 + <section class="controller"> 236 + <div class="gradient-blur"> 237 + <div></div> 238 + <div></div> 239 + <div></div> 240 + <div></div> 241 + <div></div> 242 + <div></div> 243 + <div></div> 244 + <div></div> 245 + </div> 246 + 247 + <div class="controller__background"></div> 248 + 249 + <!-- Content --> 250 + <section class="controller__inner"></section> 251 + </section> 252 + </main> 253 + `; 254 + } 255 + } 256 + 257 + export default ArtworkController; 258 + 259 + //////////////////////////////////////////// 260 + // REGISTER 261 + //////////////////////////////////////////// 262 + 263 + export const CLASS = ArtworkController; 264 + export const NAME = "db-artwork-controller"; 265 + 266 + customElements.define(NAME, CLASS);
+53
src/themes/blur/artwork-controller/index.vto
··· 1 + --- 2 + layout: layouts/diffuse.vto 3 + 4 + base: "../../../" 5 + 6 + scripts: 7 + - element.js 8 + --- 9 + 10 + <!-- ELEMENTS --> 11 + 12 + <de-audio group="constituents"></de-audio> 13 + <de-queue group="constituents"></de-queue> 14 + 15 + <do-queue-tracks 16 + input-selector="dc-input" 17 + output-selector="#output" 18 + queue-engine-selector="de-queue" 19 + ></do-queue-tracks> 20 + 21 + <dp-artwork></dp-artwork> 22 + 23 + <dtor-default id="output" output-selector="dtos-json"></dtor-default> 24 + <dtos-json output-selector="dop-indexed-db"></dtos-json> 25 + <dop-indexed-db></dop-indexed-db> 26 + 27 + <dc-input> 28 + <di-opensubsonic></di-opensubsonic> 29 + <di-s3></di-s3> 30 + </dc-input> 31 + 32 + <db-artwork-controller 33 + artwork-processor-selector="dp-artwork" 34 + audio-engine-selector="de-queue" 35 + input-selector="dc-input" 36 + queue-engine-selector="de-queue" 37 + > 38 + </db-artwork-controller> 39 + 40 + <!-- SCRIPTS --> 41 + 42 + <script type="module"> 43 + import "../../../components/configurator/input/element.js" 44 + import "../../../components/engine/audio/element.js" 45 + import "../../../components/engine/queue/element.js" 46 + import "../../../components/input/opensubsonic/element.js" 47 + import "../../../components/input/s3/element.js" 48 + import "../../../components/orchestrator/queue-tracks/element.js" 49 + import "../../../components/output/polymorphic/indexed-db/element.js" 50 + import "../../../components/processor/artwork/element.js" 51 + import "../../../components/transformer/output/refiner/default/element.js" 52 + import "../../../components/transformer/output/string/json/element.js" 53 + </script>
+1 -6
src/themes/blur/index.js
··· 57 57 // 🛠️ 58 58 59 59 async function isLeader() { 60 - if (audio.broadcasted) { 61 - const status = await audio.broadcastingStatus(); 62 - return status.leader; 63 - } else { 64 - return true; 65 - } 60 + return await audio.isLeader(); 66 61 }
+14 -19
src/themes/webamp/browser/element.js
··· 1 - import { DiffuseElement, query } from "@common/element.js"; 1 + import { DiffuseElement, query, whenElementsDefined } from "@common/element.js"; 2 2 3 3 /** 4 4 * @import {RenderArg} from "@common/element.d.ts" ··· 10 10 class Browser extends DiffuseElement { 11 11 constructor() { 12 12 super(); 13 - 14 - // Enable Shadow DOM 15 13 this.attachShadow({ mode: "open" }); 16 - 17 - /** @type {InputElement} */ 18 - this.input = query(this, "input-selector"); 19 - 20 - /** @type {OutputElement<Track[]>} */ 21 - this.output = query(this, "output-selector"); 22 - 23 - /** @type {import("@components/engine/queue/element.js").CLASS} */ 24 - this.queue = query(this, "queue-selector"); 25 14 } 26 15 27 16 // LIFECYCLE ··· 32 21 connectedCallback() { 33 22 super.connectedCallback(); 34 23 35 - // Wait for the above dependencies to be defined, then render again. 36 - (async () => { 37 - await customElements.whenDefined(this.input.localName); 38 - await customElements.whenDefined(this.output.localName); 24 + /** @type {InputElement} */ 25 + this.input = query(this, "input-selector"); 26 + 27 + /** @type {OutputElement<Track[]>} */ 28 + this.output = query(this, "output-selector"); 39 29 30 + /** @type {import("@components/engine/queue/element.js").CLASS} */ 31 + this.queue = query(this, "queue-engine-selector"); 32 + 33 + // Wait for the above dependencies to be defined, then render again. 34 + whenElementsDefined({ input: this.input, output: this.output }).then(() => { 40 35 this.effect(() => { 41 36 this.forceRender(); 42 37 }); 43 - })(); 38 + }); 44 39 } 45 40 46 41 // EVENTS ··· 66 61 * @param {Track} track 67 62 */ 68 63 playTrack(track) { 69 - this.queue.add({ 64 + this.queue?.add({ 70 65 inFront: true, 71 66 tracks: [track], 72 67 }); ··· 78 73 * @param {RenderArg} _ 79 74 */ 80 75 render({ html }) { 81 - const tracks = this.output.tracks?.collection() || []; 76 + const tracks = this.output?.tracks?.collection() ?? []; 82 77 83 78 return html` 84 79 <link rel="stylesheet" href="../../styles/vendor/98.css" />
+1 -1
src/themes/webamp/index.vto
··· 37 37 <dtw-browser 38 38 input-selector="#input" 39 39 output-selector="#output" 40 - queue-selector="de-queue" 40 + queue-engine-selector="de-queue" 41 41 ></dtw-browser> 42 42 </dtw-window> 43 43 </dtw-window-manager>