Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

refactor: only setup output configurator ui when needed

+442 -386
+8 -1
astro.config.js
··· 1 1 import { defineConfig } from "astro/config"; 2 2 import scope from "astro-scope"; 3 3 import wasm from "vite-plugin-wasm"; 4 + import worker from "@astropub/worker"; 4 5 5 6 import purgecss from "astro-purgecss"; 6 7 7 8 export default defineConfig({ 8 - integrations: [scope(), purgecss()], 9 + integrations: [scope(), purgecss(), worker()], 9 10 build: { 10 11 inlineStylesheets: "never", 11 12 }, ··· 13 14 plugins: [wasm()], 14 15 server: { 15 16 hmr: false, 17 + }, 18 + build: { 19 + target: "esnext", 20 + }, 21 + worker: { 22 + format: "es", 16 23 }, 17 24 }, 18 25 });
+1
deno.lock
··· 22 22 "packageJson": { 23 23 "dependencies": [ 24 24 "npm:98.css@~0.1.21", 25 + "npm:@astropub/worker@0.2", 25 26 "npm:@automerge/automerge@^3.0.0-beta.0", 26 27 "npm:@js-temporal/polyfill@~0.5.1", 27 28 "npm:@jsr/bradenmacdonald__s3-lite-client@0.9",
+7
package-lock.json
··· 5 5 "packages": { 6 6 "": { 7 7 "dependencies": { 8 + "@astropub/worker": "^0.2.0", 8 9 "@automerge/automerge": "^3.0.0-beta.0", 9 10 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 10 11 "@js-temporal/polyfill": "^0.5.1", ··· 125 126 "engines": { 126 127 "node": "18.20.8 || ^20.3.0 || >=22.0.0" 127 128 } 129 + }, 130 + "node_modules/@astropub/worker": { 131 + "version": "0.2.0", 132 + "resolved": "https://registry.npmjs.org/@astropub/worker/-/worker-0.2.0.tgz", 133 + "integrity": "sha512-DkvD+H3N3n9XqRV66Jh3nYq0fPkfPwlbuOcKmLgSINms0z5VcHALVUmneceMJ1ni/MexkqffezWtGgKAiglYbw==", 134 + "license": "(MIT-0 AND Apache-2.0)" 128 135 }, 129 136 "node_modules/@automerge/automerge": { 130 137 "version": "3.0.0-preview.13",
+1
package.json
··· 1 1 { 2 2 "dependencies": { 3 + "@astropub/worker": "^0.2.0", 3 4 "@automerge/automerge": "^3.0.0-beta.0", 4 5 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 5 6 "@js-temporal/polyfill": "^0.5.1",
+13 -294
src/pages/configurator/output/_applet.astro
··· 41 41 </style> 42 42 43 43 <script> 44 - // @ts-ignore 45 - import scope from "astro:scope"; 46 - import type { Applet, AppletEvent } from "@web-applets/sdk"; 47 - import { type Signal, computed, effect, signal } from "spellcaster/spellcaster.js"; 48 - import { type ElementConfigurator, repeat, text } from "spellcaster/hyperscript.js"; 49 - 50 - import type { ManagedOutput } from "@applets/core/types"; 51 - import { applet, hs, register } from "@scripts/applet/common"; 52 44 import { INITIAL_MANAGED_OUTPUT } from "@scripts/output/common"; 45 + import { inIframe } from "@scripts/common"; 46 + import { effect } from "spellcaster"; 53 47 54 - const METHODS = ["browser", "custom", "device"] as const; 55 - 56 - const CONNECTIONS = { 57 - browser: "../../../output/indexed-db/", 58 - custom: undefined, 59 - device: "../../../output/native-fs/", 60 - }; 61 - 62 - type Method = (typeof METHODS)[number]; 63 - type List<M extends Method = Method> = Map<string, ListItem<M>>; 64 - type ListItem<M> = { activated: boolean; icon: string; method: M; title: string }; 65 - 66 - const DEFAULT_METHOD: Method = "browser"; 67 - const LOCALSTORAGE_KEY = "applets/configurator/output/active-output"; 68 - const CUSTOM_KEY = "applets/configurator/output/custom-applet"; 69 - 70 - const h = ( 71 - tag: string, 72 - props?: Record<string, any> | Signal<Record<string, any>>, 73 - configure?: ElementConfigurator, 74 - ) => hs(tag, scope, props, configure); 48 + import { connection } from "@scripts/configurator/output/connections"; 49 + import { active } from "@scripts/configurator/output/signals"; 50 + import { context } from "@scripts/configurator/output/context"; 51 + import { setContextData } from "@scripts/configurator/output/events"; 75 52 76 53 //////////////////////////////////////////// 77 54 // SETUP 78 55 //////////////////////////////////////////// 79 - const context = register<ManagedOutput>(); 80 - 81 - // Applet connections 82 - const connections: Record<string, Applet<ManagedOutput>> = {}; 83 - 84 - // Initial state 85 56 context.data = INITIAL_MANAGED_OUTPUT; 86 57 87 - // Applet connections 88 - async function connection(method: Method) { 89 - if (connections[method]) return connections[method]; 90 - 91 - let href; 92 - 93 - if (method === "custom") { 94 - href = localStorage.getItem(CUSTOM_KEY); 95 - if (!href) throw new Error("Missing custom applet URL"); 96 - } else { 97 - href = CONNECTIONS[method]; 98 - if (!href) throw new Error("No href defined for this connection method."); 99 - } 100 - 101 - connections[method] = await applet(href); 102 - return connections[method]; 103 - } 104 - 105 - // Signals 106 - const stored = localStorage.getItem(LOCALSTORAGE_KEY); 107 - const [active, setActive] = signal<Method>( 108 - stored && METHODS.includes(stored as Method) ? (stored as Method) : DEFAULT_METHOD, 109 - ); 110 - 58 + //////////////////////////////////////////// 59 + // EFFECTS 60 + //////////////////////////////////////////// 111 61 effect(() => { 112 62 const method = active(); 113 - localStorage.setItem(LOCALSTORAGE_KEY, method); 114 63 115 64 // Monitor data 116 65 (async () => { 117 66 const conn = await connection(method); 118 - dataHandler(conn.data); 119 - conn.addEventListener("data", dateEventHandler); 67 + context.data = conn.data; 68 + conn.addEventListener("data", setContextData); 120 69 })(); 121 70 }); 122 71 123 - function dateEventHandler(event: AppletEvent) { 124 - return dataHandler(event.data); 125 - } 126 - 127 - function dataHandler(data: ManagedOutput) { 128 - context.data = data; 129 - 130 - // Export data URI 131 - const dl = document.querySelector("#download"); 132 - if (dl) { 133 - const json = JSON.stringify(context.data.tracks.collection, null, 2); 134 - const href = URL.createObjectURL(new Blob([json], { type: "application/json" })); 135 - dl.setAttribute("href", href); 136 - } 137 - } 138 - 139 - // Mount + Unmount 140 - async function mountStorageMethod(method: Method) { 141 - switch (method) { 142 - case "custom": 143 - setModalIsOpen(true); 144 - break; 145 - default: 146 - const conn = await connection(method); 147 - try { 148 - await conn.sendAction("mount", undefined, { timeoutDuration: 60000 }); 149 - setActive(method); 150 - } catch (err) { 151 - const msg: string = 152 - err && typeof err === "object" && "message" in err ? `${err.message}` : `${err}`; 153 - if (msg.startsWith("[user] ")) alert(msg.slice(7)); 154 - } 155 - break; 156 - } 157 - } 158 - 159 - async function unmountStorageMethod(method: Method) { 160 - const conn = await connection(method); 161 - conn.removeEventListener("data", dateEventHandler); 162 - await conn.sendAction("unmount", undefined, { timeoutDuration: 60000 }); 163 - } 164 - 165 72 //////////////////////////////////////////// 166 73 // ACTIONS 167 74 //////////////////////////////////////////// ··· 173 80 context.setActionHandler("tracks", tracks); 174 81 175 82 //////////////////////////////////////////// 176 - // UI / LIST 83 + // UI 177 84 //////////////////////////////////////////// 178 - const list = computed<List>(() => { 179 - const a = active(); 180 - 181 - return new Map([ 182 - [ 183 - `browser-${a === "browser"}`, 184 - { 185 - title: "Browser storage", 186 - icon: "iconoir-app-window", 187 - method: "browser", 188 - activated: a === "browser", 189 - }, 190 - ], 191 - [ 192 - `device-${a === "device"}`, 193 - { 194 - title: "Device storage", 195 - icon: "iconoir-laptop", 196 - method: "device", 197 - activated: a === "device", 198 - }, 199 - ], 200 - [ 201 - `custom-${a === "custom"}`, 202 - { 203 - title: "Custom applet", 204 - icon: "iconoir-globe", 205 - method: "custom", 206 - activated: a === "custom", 207 - }, 208 - ], 209 - ]); 210 - }); 211 - 212 - const Item = (signal: Signal<ListItem<Method>>) => { 213 - const item = signal(); 214 - 215 - const colorClass = item.activated ? "pico-color-jade-500" : "pico-color-grey-500"; 216 - const icon = item.activated ? "iconoir-check-circle-solid" : "iconoir-check-circle"; 217 - 218 - return h( 219 - "p", 220 - { 221 - onclick: clickHandler(item.method), 222 - style: "cursor: pointer", 223 - }, 224 - [ 225 - h("span", { className: "with-icon" }, [ 226 - h("i", { className: item.icon }), 227 - h("strong", {}, text(item.title)), 228 - ]), 229 - h("br"), 230 - h("span", { className: `with-icon ${colorClass}` }, [ 231 - h("i", { className: icon }), 232 - h("span", {}, text(item.activated ? "Active" : "Select")), 233 - ]), 234 - ], 235 - ); 236 - }; 237 - 238 - function clickHandler(method: Method) { 239 - return async () => { 240 - const currentlyActive = active(); 241 - if (currentlyActive === method && currentlyActive !== "custom") return; 242 - if (currentlyActive) unmountStorageMethod(currentlyActive); 243 - await mountStorageMethod(method); 244 - }; 245 - } 246 - 247 - const Options = computed(() => { 248 - return h("div", { id: "options" }, repeat(list, Item)); 249 - }); 250 - 251 - // Add to DOM 252 - document.getElementById("options")?.replaceWith(Options()); 253 - 254 - //////////////////////////////////////////// 255 - // UI / CUSTOM APPLET 256 - //////////////////////////////////////////// 257 - type CustomAppletState = "waiting" | "connecting" | { error: string } | "connected"; 258 - 259 - const [modalIsOpen, setModalIsOpen] = signal(false); 260 - const [customState, setCustomState] = signal<CustomAppletState>("waiting"); 261 - 262 - const Modal = () => { 263 - const Header = h("header", {}, [ 264 - h("button", { 265 - attrs: { rel: "prev" }, 266 - ariaLabel: "Close", 267 - onclick: close, 268 - }), 269 - h("p", {}, [ 270 - h("strong", {}, [ 271 - h("span", { className: "with-icon" }, [ 272 - h("i", { className: "iconoir-globe" }), 273 - h("span", {}, text("Load a custom applet")), 274 - ]), 275 - ]), 276 - ]), 277 - ]); 278 - 279 - const Content = h("form", { onsubmit: submit }, [ 280 - h("fieldset", { role: "group" }, [ 281 - h("input", { 282 - type: "url", 283 - name: "url", 284 - placeholder: "https://applets.diffuse.sh/storage/output/indexed-db/", 285 - required: true, 286 - value: localStorage.getItem(CUSTOM_KEY) || "", 287 - }), 288 - h("input", { type: "submit", value: "Connect" }), 289 - ]), 290 - h("p", {}, [ 291 - h("small", { className: "with-icon" }, (element) => { 292 - const comp = computed(() => { 293 - const s = customState(); 294 - 295 - if (s === "connecting") { 296 - return [ 297 - h("i", { className: "iconoir-ev-plug-charging" }), 298 - h("span", {}, text("Connecting ...")), 299 - ]; 300 - } else if (typeof s !== "string") { 301 - return [ 302 - h("i", { className: "iconoir-warning-circle" }), 303 - h("span", {}, text(`Error: ${s.error}`)), 304 - ]; 305 - } 306 - 307 - return [h("span", {}, text("Enter the URL to the applet."))]; 308 - }); 309 - 310 - effect(() => { 311 - element.replaceChildren(...comp()); 312 - }); 313 - }), 314 - ]), 315 - ]); 316 - 317 - return h( 318 - "dialog", 319 - computed(() => ({ open: modalIsOpen() })), 320 - [h("article", {}, [Header, Content])], 321 - ); 322 - }; 323 - 324 - // Events 325 - function close() { 326 - setModalIsOpen(false); 327 - } 328 - 329 - async function submit(event: SubmitEvent) { 330 - event.preventDefault(); 331 - 332 - const input: HTMLInputElement | null = (event.target as HTMLFormElement).querySelector( 333 - `input[type="url"]`, 334 - ); 335 - 336 - if (!input) return; 337 - 338 - const url = input.value; 339 - setCustomState("connecting"); 340 - 341 - const apl = await applet(url).catch((err) => { 342 - setCustomState({ error: "Failed to connect" }); 343 - throw err; 344 - }); 345 - 346 - let missingAction; 347 - 348 - ["tracks", "mount", "unmount"].forEach((method) => { 349 - if (!apl.manifest.actions?.[method]) missingAction = method; 350 - }); 351 - 352 - if (missingAction) { 353 - setCustomState({ error: `Applet is missing a required action: "${missingAction}"` }); 354 - return; 355 - } 356 - 357 - localStorage.setItem(CUSTOM_KEY, url); 358 - await apl.sendAction("mount", undefined, { timeoutDuration: 60000 }); 359 - 360 - setActive("custom"); 361 - setModalIsOpen(false); 362 - setCustomState("waiting"); 363 - } 364 - 365 - // Add to DOM 366 - document.querySelector("main")?.appendChild(Modal()); 85 + const ui = inIframe() ? undefined : await import("@scripts/configurator/output/ui"); 367 86 </script>
+3 -10
src/pages/constituent/blur/artwork-controller/_applet.astro
··· 299 299 </style> 300 300 301 301 <script> 302 + import scope from "astro:scope"; 302 303 import { FastAverageColor } from "fast-average-color"; 303 304 import { Temporal } from "@js-temporal/polyfill"; 304 305 ··· 306 307 import { tags, text, type ElementConfigurator } from "spellcaster/hyperscript.js"; 307 308 308 309 import type { ManagedOutput, Track } from "@applets/core/types"; 309 - import { 310 - applet, 311 - comparable, 312 - hs, 313 - inputUrl, 314 - reactive, 315 - register, 316 - trackArtworkCacheId, 317 - } from "@scripts/applet/common"; 318 - import scope from "astro:scope"; 310 + import { applet, hs, inputUrl, reactive, register } from "@scripts/applet/common"; 311 + import { comparable, trackArtworkCacheId } from "@scripts/common"; 319 312 320 313 //////////////////////////////////////////// 321 314 // SETUP
+1 -2
src/pages/engine/audio/_applet.astro
··· 3 3 4 4 import type { State, Audio, AudioState } from "./types"; 5 5 import { register } from "@scripts/applet/common"; 6 - import type { AppletEvent } from "@web-applets/sdk"; 7 6 8 7 //////////////////////////////////////////// 9 8 // CONSTANTS ··· 51 50 52 51 // Effects 53 52 const [defaultVolume, setDefaultVolume] = signal<number | undefined>(undefined); 54 - context.scope.ondata = (event: AppletEvent) => setDefaultVolume(event.data.volume.default); 53 + context.scope.ondata = (event: any) => setDefaultVolume(event.data.volume.default); 55 54 56 55 effect(() => { 57 56 const volume = defaultVolume();
+10 -8
src/pages/input/opensubsonic/_applet.astro
··· 18 18 </main> 19 19 20 20 <script> 21 - import { createEndpoint } from "@remote-ui/rpc"; 22 - 23 - import type { Actions as AppletWorkerActions } from "./worker"; 21 + import type { Actions } from "@scripts/input/opensubsonic/worker"; 24 22 import type { Track } from "@applets/core/types.d.ts"; 25 - import { inIframe, register } from "@scripts/applet/common"; 26 - import AppletWorker from "./worker?worker"; 23 + import { register } from "@scripts/applet/common"; 24 + import { endpoint, inIframe } from "@scripts/common"; 27 25 28 26 //////////////////////////////////////////// 29 27 // SETUP 30 28 //////////////////////////////////////////// 31 - const worker = createEndpoint<AppletWorkerActions>(new AppletWorker()); 29 + const worker = endpoint<Actions>( 30 + new Worker("../../../scripts/input/opensubsonic/worker", { 31 + type: "module", 32 + }), 33 + self as any, 34 + ); 32 35 33 36 // Register applet 34 37 const context = register(); ··· 67 70 //////////////////////////////////////////// 68 71 // UI 69 72 //////////////////////////////////////////// 70 - // Only load dynamic UI when not embedded 71 - const ui = inIframe() ? undefined : await import("./ui"); 73 + const ui = inIframe() ? undefined : await import("@scripts/input/opensubsonic/ui"); 72 74 </script>
src/pages/input/opensubsonic/common.ts src/scripts/input/opensubsonic/common.ts
+1 -1
src/pages/input/opensubsonic/constants.ts src/scripts/input/opensubsonic/constants.ts
··· 1 - import manifest from "./_manifest.json"; 1 + import manifest from "../../../pages/input/opensubsonic/_manifest.json"; 2 2 3 3 export const IDB_PREFIX = "@applets/input/opensubsonic"; 4 4 export const IDB_SERVERS = `${IDB_PREFIX}/servers`;
+1 -8
src/pages/input/opensubsonic/types.d.ts
··· 1 - // https://opensubsonic.netlify.app/docs/api-reference/ 2 - export type Server = { 3 - apiKey?: string; 4 - host: string; 5 - password?: string; 6 - tls: boolean; 7 - username?: string; 8 - }; 1 + export * from "@scripts/input/opensubsonic/types.d.ts";
src/pages/input/opensubsonic/ui.ts src/scripts/input/opensubsonic/ui.ts
+10 -9
src/pages/input/opensubsonic/worker.ts src/scripts/input/opensubsonic/worker.ts
··· 1 - import { createEndpoint, type MessageEndpoint } from "@remote-ui/rpc"; 2 1 import { SubsonicAPI, type Child } from "subsonic-api"; 3 2 import * as URI from "uri-js"; 4 3 import QS from "query-string"; ··· 7 6 import type { Server } from "./types.d.ts"; 8 7 import { SCHEME } from "./constants.ts"; 9 8 import { loadServers, serverId } from "./common.ts"; 9 + import { expose } from "../../../scripts/common.ts"; 10 10 11 11 //////////////////////////////////////////// 12 12 // ACTIONS 13 13 //////////////////////////////////////////// 14 - const actions = createEndpoint<Actions>(self as MessageEndpoint); 15 - actions.expose({ consult, contextualize, list, resolve }); 14 + const actions = expose({ 15 + consult, 16 + contextualize, 17 + list, 18 + resolve, 19 + }); 20 + 21 + export type Actions = typeof actions; 16 22 17 - export type Actions = { 18 - consult: typeof consult; 19 - contextualize: typeof contextualize; 20 - list: typeof list; 21 - resolve: typeof resolve; 22 - }; 23 + // Actions 23 24 24 25 async function consult(fileUriOrScheme: string) { 25 26 // TODO: Check if server is available + CORS works?
+2 -1
src/pages/output/indexed-db/_applet.astro
··· 2 2 import * as IDB from "idb-keyval"; 3 3 4 4 import type { ManagedOutput, Track } from "@applets/core/types.d.ts"; 5 - import { jsonDecode, jsonEncode, register } from "@scripts/applet/common"; 5 + import { register } from "@scripts/applet/common"; 6 6 import { INITIAL_MANAGED_OUTPUT, outputManager } from "@scripts/output/common"; 7 + import { jsonDecode, jsonEncode } from "@scripts/common"; 7 8 8 9 //////////////////////////////////////////// 9 10 // SETUP
+2 -1
src/pages/output/native-fs/_applet.astro
··· 3 3 import type * as FSA from "wicg-file-system-access"; 4 4 5 5 import type { ManagedOutput, Track } from "@applets/core/types"; 6 - import { jsonDecode, jsonEncode, register } from "@scripts/applet/common"; 6 + import { register } from "@scripts/applet/common"; 7 7 import { INITIAL_MANAGED_OUTPUT, outputManager } from "@scripts/output/common"; 8 + import { jsonDecode, jsonEncode } from "@scripts/common"; 8 9 9 10 //////////////////////////////////////////// 10 11 // SETUP
+2 -1
src/pages/output/storacha-automerge/_applet.astro
··· 4 4 import * as Uint8 from "uint8arrays"; 5 5 6 6 import type { ManagedOutput, Track } from "@applets/core/types.d.ts"; 7 - import { cleanUndefinedValuesForTracks, register } from "@scripts/applet/common"; 7 + import { register } from "@scripts/applet/common"; 8 8 import { INITIAL_MANAGED_OUTPUT } from "@scripts/output/common"; 9 + import { cleanUndefinedValuesForTracks } from "@scripts/common"; 9 10 10 11 //////////////////////////////////////////// 11 12 // 🏔️
+1 -47
src/scripts/applet/common.ts
··· 1 1 import type { Applet, AppletEvent, AppletScope } from "@web-applets/sdk"; 2 2 3 - import * as Uint8 from "uint8arrays"; 4 3 import { applets } from "@web-applets/sdk"; 5 4 import { type ElementConfigurator, h } from "spellcaster/hyperscript.js"; 6 5 import { effect, isSignal, type Signal, signal } from "spellcaster/spellcaster.js"; 7 - import { xxh32 } from "xxh32"; 8 6 import QS from "query-string"; 9 7 10 - import type { ResolvedUri, Track } from "@applets/core/types"; 8 + import type { ResolvedUri } from "@applets/core/types"; 11 9 12 10 //////////////////////////////////////////// 13 11 // 🪟 Applet connecting ··· 363 361 return () => port; 364 362 } 365 363 366 - export function cleanUndefinedValuesForTracks(tracks: Track[]): Track[] { 367 - return tracks.map((track) => { 368 - const t = { ...track }; 369 - 370 - if (t.tags) { 371 - if ("album" in t.tags && t.tags.album === undefined) delete t.tags.album; 372 - if ("artist" in t.tags && t.tags.artist === undefined) delete t.tags.artist; 373 - if ("genre" in t.tags && t.tags.genre === undefined) delete t.tags.genre; 374 - if ("year" in t.tags && t.tags.year === undefined) delete t.tags.year; 375 - 376 - if ("of" in t.tags.disc && t.tags.disc.of === undefined) delete t.tags.disc.of; 377 - if ("of" in t.tags.track && t.tags.track.of === undefined) delete t.tags.track.of; 378 - } 379 - 380 - return t; 381 - }); 382 - } 383 - 384 - export function comparable(value: unknown) { 385 - return xxh32(JSON.stringify(value)); 386 - } 387 - 388 - export function inIframe() { 389 - return window.self !== window.top; 390 - } 391 - 392 364 export function hs( 393 365 tag: string, 394 366 astroScope: string, ··· 401 373 : addScope(astroScope, props || {}); 402 374 403 375 return h(tag, propsWithScope, configure); 404 - } 405 - 406 - export function isPrimitive(test: unknown) { 407 - return test !== Object(test); 408 - } 409 - 410 - export function jsonDecode<T>(a: any): T { 411 - return JSON.parse(new TextDecoder().decode(a)); 412 - } 413 - 414 - export function jsonEncode<T>(a: T): Uint8Array { 415 - return new TextEncoder().encode(JSON.stringify(a)); 416 - } 417 - 418 - export async function trackArtworkCacheId(track: Track): Promise<string> { 419 - return await crypto.subtle 420 - .digest("SHA-256", new TextEncoder().encode(track.uri)) 421 - .then((a) => Uint8.toString(new Uint8Array(a), "base64url")); 422 376 } 423 377 424 378 export function wait<A>(applet: Applet<A>, dataFn: (a: A | undefined) => boolean): Promise<void> {
+57
src/scripts/common.ts
··· 1 + import * as Uint8 from "uint8arrays"; 2 + import { createEndpoint, type MessageEndpoint } from "@remote-ui/rpc"; 3 + import { xxh32 } from "xxh32"; 4 + 5 + import type { Track } from "@applets/core/types"; 6 + 1 7 export function arrayShuffle<T>(array: Array<T>): Array<T> { 2 8 if (array.length === 0) { 3 9 return []; ··· 14 20 15 21 return array; 16 22 } 23 + 24 + export function cleanUndefinedValuesForTracks(tracks: Track[]): Track[] { 25 + return tracks.map((track) => { 26 + const t = { ...track }; 27 + 28 + if (t.tags) { 29 + if ("album" in t.tags && t.tags.album === undefined) delete t.tags.album; 30 + if ("artist" in t.tags && t.tags.artist === undefined) delete t.tags.artist; 31 + if ("genre" in t.tags && t.tags.genre === undefined) delete t.tags.genre; 32 + if ("year" in t.tags && t.tags.year === undefined) delete t.tags.year; 33 + 34 + if ("of" in t.tags.disc && t.tags.disc.of === undefined) delete t.tags.disc.of; 35 + if ("of" in t.tags.track && t.tags.track.of === undefined) delete t.tags.track.of; 36 + } 37 + 38 + return t; 39 + }); 40 + } 41 + 42 + export function comparable(value: unknown) { 43 + return xxh32(JSON.stringify(value)); 44 + } 45 + 46 + export const endpoint = createEndpoint; 47 + 48 + export function expose<T extends Record<string, any>>(actions: T): T { 49 + createEndpoint<T>(self as MessageEndpoint).expose(actions); 50 + return actions; 51 + } 52 + 53 + export function inIframe() { 54 + return window.self !== window.top; 55 + } 56 + 57 + export function isPrimitive(test: unknown) { 58 + return test !== Object(test); 59 + } 60 + 61 + export function jsonDecode<T>(a: any): T { 62 + return JSON.parse(new TextDecoder().decode(a)); 63 + } 64 + 65 + export function jsonEncode<T>(a: T): Uint8Array { 66 + return new TextEncoder().encode(JSON.stringify(a)); 67 + } 68 + 69 + export async function trackArtworkCacheId(track: Track): Promise<string> { 70 + return await crypto.subtle 71 + .digest("SHA-256", new TextEncoder().encode(track.uri)) 72 + .then((a) => Uint8.toString(new Uint8Array(a), "base64url")); 73 + }
+25
src/scripts/configurator/output/connections.ts
··· 1 + import type { Applet } from "@web-applets/sdk"; 2 + 3 + import type { ManagedOutput } from "@applets/core/types"; 4 + import type { Method } from "./types"; 5 + import { CONNECTIONS, CUSTOM_KEY } from "./constants"; 6 + import { applet } from "@scripts/applet/common"; 7 + 8 + const connections: Record<string, Applet<ManagedOutput>> = {}; 9 + 10 + export async function connection(method: Method) { 11 + if (connections[method]) return connections[method]; 12 + 13 + let href; 14 + 15 + if (method === "custom") { 16 + href = localStorage.getItem(CUSTOM_KEY); 17 + if (!href) throw new Error("Missing custom applet URL"); 18 + } else { 19 + href = CONNECTIONS[method]; 20 + if (!href) throw new Error("No href defined for this connection method."); 21 + } 22 + 23 + connections[method] = await applet(href); 24 + return connections[method]; 25 + }
+11
src/scripts/configurator/output/constants.ts
··· 1 + export const METHODS = ["browser", "custom", "device"] as const; 2 + 3 + export const CONNECTIONS = { 4 + browser: "../../output/indexed-db/", 5 + custom: undefined, 6 + device: "../../output/native-fs/", 7 + }; 8 + 9 + export const DEFAULT_METHOD: (typeof METHODS)[number] = "browser"; 10 + export const LOCALSTORAGE_KEY = "applets/configurator/output/active-output"; 11 + export const CUSTOM_KEY = "applets/configurator/output/custom-applet";
+4
src/scripts/configurator/output/context.ts
··· 1 + import type { ManagedOutput } from "@applets/core/types"; 2 + import { register } from "@scripts/applet/common"; 3 + 4 + export const context = register<ManagedOutput>();
+6
src/scripts/configurator/output/events.ts
··· 1 + import type { AppletEvent } from "@web-applets/sdk"; 2 + import { context } from "./context"; 3 + 4 + export function setContextData(event: AppletEvent) { 5 + context.data = event.data; 6 + }
+9
src/scripts/configurator/output/signals.ts
··· 1 + import { signal } from "spellcaster"; 2 + 3 + import type { Method } from "./types"; 4 + import { DEFAULT_METHOD, LOCALSTORAGE_KEY, METHODS } from "./constants"; 5 + 6 + export const stored = localStorage.getItem(LOCALSTORAGE_KEY); 7 + export const [active, setActive] = signal<Method>( 8 + stored && METHODS.includes(stored as Method) ? (stored as Method) : DEFAULT_METHOD, 9 + );
+5
src/scripts/configurator/output/types.d.ts
··· 1 + import { METHODS } from "./constants"; 2 + 3 + export type Method = (typeof METHODS)[number]; 4 + export type List<M extends Method = Method> = Map<string, ListItem<M>>; 5 + export type ListItem<M> = { activated: boolean; icon: string; method: M; title: string };
+252
src/scripts/configurator/output/ui.ts
··· 1 + import { type Signal, computed, effect, signal } from "spellcaster/spellcaster.js"; 2 + import { type ElementConfigurator, h, repeat, text } from "spellcaster/hyperscript.js"; 3 + 4 + import { applet, hs, reactive } from "@scripts/applet/common"; 5 + import { CUSTOM_KEY } from "./constants"; 6 + import { active, setActive } from "./signals"; 7 + import { connection } from "./connections"; 8 + import { context } from "./context"; 9 + import type { List, ListItem, Method } from "./types"; 10 + import { setContextData } from "./events"; 11 + 12 + // const h = ( 13 + // tag: string, 14 + // props?: Record<string, any> | Signal<Record<string, any>>, 15 + // configure?: ElementConfigurator, 16 + // ) => hs(tag, scope, props, configure); 17 + 18 + //////////////////////////////////////////// 19 + // EFFECTS 20 + //////////////////////////////////////////// 21 + reactive( 22 + context.scope, 23 + (data) => data.tracks.cacheId, 24 + () => { 25 + // Export data URI 26 + const dl = document.querySelector("#download"); 27 + if (dl) { 28 + const json = JSON.stringify(context.data.tracks.collection, null, 2); 29 + const href = URL.createObjectURL(new Blob([json], { type: "application/json" })); 30 + dl.setAttribute("href", href); 31 + } 32 + }, 33 + ); 34 + 35 + // Mount + Unmount 36 + async function mountStorageMethod(method: Method) { 37 + switch (method) { 38 + case "custom": 39 + setModalIsOpen(true); 40 + break; 41 + default: 42 + const conn = await connection(method); 43 + try { 44 + await conn.sendAction("mount", undefined, { timeoutDuration: 60000 }); 45 + setActive(method); 46 + } catch (err) { 47 + const msg: string = 48 + err && typeof err === "object" && "message" in err ? `${err.message}` : `${err}`; 49 + if (msg.startsWith("[user] ")) alert(msg.slice(7)); 50 + } 51 + break; 52 + } 53 + } 54 + 55 + async function unmountStorageMethod(method: Method) { 56 + const conn = await connection(method); 57 + conn.removeEventListener("data", setContextData); 58 + await conn.sendAction("unmount", undefined, { timeoutDuration: 60000 }); 59 + } 60 + 61 + //////////////////////////////////////////// 62 + // LIST 63 + //////////////////////////////////////////// 64 + const list = computed<List>(() => { 65 + const a = active(); 66 + 67 + return new Map([ 68 + [ 69 + `browser-${a === "browser"}`, 70 + { 71 + title: "Browser storage", 72 + icon: "iconoir-app-window", 73 + method: "browser", 74 + activated: a === "browser", 75 + }, 76 + ], 77 + [ 78 + `device-${a === "device"}`, 79 + { 80 + title: "Device storage", 81 + icon: "iconoir-laptop", 82 + method: "device", 83 + activated: a === "device", 84 + }, 85 + ], 86 + [ 87 + `custom-${a === "custom"}`, 88 + { 89 + title: "Custom applet", 90 + icon: "iconoir-globe", 91 + method: "custom", 92 + activated: a === "custom", 93 + }, 94 + ], 95 + ]); 96 + }); 97 + 98 + const Item = (signal: Signal<ListItem<Method>>) => { 99 + const item = signal(); 100 + 101 + const colorClass = item.activated ? "pico-color-jade-500" : "pico-color-grey-500"; 102 + const icon = item.activated ? "iconoir-check-circle-solid" : "iconoir-check-circle"; 103 + 104 + return h( 105 + "p", 106 + { 107 + onclick: clickHandler(item.method), 108 + style: "cursor: pointer", 109 + }, 110 + [ 111 + h("span", { className: "with-icon" }, [ 112 + h("i", { className: item.icon }), 113 + h("strong", {}, text(item.title)), 114 + ]), 115 + h("br"), 116 + h("span", { className: `with-icon ${colorClass}` }, [ 117 + h("i", { className: icon }), 118 + h("span", {}, text(item.activated ? "Active" : "Select")), 119 + ]), 120 + ], 121 + ); 122 + }; 123 + 124 + function clickHandler(method: Method) { 125 + return async () => { 126 + const currentlyActive = active(); 127 + if (currentlyActive === method && currentlyActive !== "custom") return; 128 + if (currentlyActive) unmountStorageMethod(currentlyActive); 129 + await mountStorageMethod(method); 130 + }; 131 + } 132 + 133 + const Options = computed(() => { 134 + return h("div", { id: "options" }, repeat(list, Item)); 135 + }); 136 + 137 + // Add to DOM 138 + document.getElementById("options")?.replaceWith(Options()); 139 + 140 + //////////////////////////////////////////// 141 + // CUSTOM APPLET 142 + //////////////////////////////////////////// 143 + type CustomAppletState = "waiting" | "connecting" | { error: string } | "connected"; 144 + 145 + const [modalIsOpen, setModalIsOpen] = signal(false); 146 + const [customState, setCustomState] = signal<CustomAppletState>("waiting"); 147 + 148 + const Modal = () => { 149 + const Header = h("header", {}, [ 150 + h("button", { 151 + attrs: { rel: "prev" }, 152 + ariaLabel: "Close", 153 + onclick: close, 154 + }), 155 + h("p", {}, [ 156 + h("strong", {}, [ 157 + h("span", { className: "with-icon" }, [ 158 + h("i", { className: "iconoir-globe" }), 159 + h("span", {}, text("Load a custom applet")), 160 + ]), 161 + ]), 162 + ]), 163 + ]); 164 + 165 + const Content = h("form", { onsubmit: submit }, [ 166 + h("fieldset", { role: "group" }, [ 167 + h("input", { 168 + type: "url", 169 + name: "url", 170 + placeholder: "https://applets.diffuse.sh/storage/output/indexed-db/", 171 + required: true, 172 + value: localStorage.getItem(CUSTOM_KEY) || "", 173 + }), 174 + h("input", { type: "submit", value: "Connect" }), 175 + ]), 176 + h("p", {}, [ 177 + h("small", { className: "with-icon" }, (element) => { 178 + const comp = computed(() => { 179 + const s = customState(); 180 + 181 + if (s === "connecting") { 182 + return [ 183 + h("i", { className: "iconoir-ev-plug-charging" }), 184 + h("span", {}, text("Connecting ...")), 185 + ]; 186 + } else if (typeof s !== "string") { 187 + return [ 188 + h("i", { className: "iconoir-warning-circle" }), 189 + h("span", {}, text(`Error: ${s.error}`)), 190 + ]; 191 + } 192 + 193 + return [h("span", {}, text("Enter the URL to the applet."))]; 194 + }); 195 + 196 + effect(() => { 197 + element.replaceChildren(...comp()); 198 + }); 199 + }), 200 + ]), 201 + ]); 202 + 203 + return h( 204 + "dialog", 205 + computed(() => ({ open: modalIsOpen() })), 206 + [h("article", {}, [Header, Content])], 207 + ); 208 + }; 209 + 210 + // Events 211 + function close() { 212 + setModalIsOpen(false); 213 + } 214 + 215 + async function submit(event: SubmitEvent) { 216 + event.preventDefault(); 217 + 218 + const input: HTMLInputElement | null = (event.target as HTMLFormElement).querySelector( 219 + `input[type="url"]`, 220 + ); 221 + 222 + if (!input) return; 223 + 224 + const url = input.value; 225 + setCustomState("connecting"); 226 + 227 + const apl = await applet(url).catch((err) => { 228 + setCustomState({ error: "Failed to connect" }); 229 + throw err; 230 + }); 231 + 232 + let missingAction; 233 + 234 + ["tracks", "mount", "unmount"].forEach((method) => { 235 + if (!apl.manifest.actions?.[method]) missingAction = method; 236 + }); 237 + 238 + if (missingAction) { 239 + setCustomState({ error: `Applet is missing a required action: "${missingAction}"` }); 240 + return; 241 + } 242 + 243 + localStorage.setItem(CUSTOM_KEY, url); 244 + await apl.sendAction("mount", undefined, { timeoutDuration: 60000 }); 245 + 246 + setActive("custom"); 247 + setModalIsOpen(false); 248 + setCustomState("waiting"); 249 + } 250 + 251 + // Add to DOM 252 + document.querySelector("main")?.appendChild(Modal());
+8
src/scripts/input/opensubsonic/types.d.ts
··· 1 + // https://opensubsonic.netlify.app/docs/api-reference/ 2 + export type Server = { 3 + apiKey?: string; 4 + host: string; 5 + password?: string; 6 + tls: boolean; 7 + username?: string; 8 + };
-2
src/scripts/theme/blur/index.ts
··· 1 - import type { Applet } from "@web-applets/sdk"; 2 - 3 1 import type { ManagedOutput } from "@applets/core/types"; 4 2 import { applet, reactive, wait } from "@scripts/applet/common"; 5 3
+2 -1
tsconfig.json
··· 8 8 "esModuleInterop": true, 9 9 "experimentalDecorators": false, 10 10 "isolatedModules": true, 11 + "lib": ["DOM", "ESNext", "WebWorker"], 11 12 "module": "esnext", 12 13 "moduleResolution": "bundler", 13 - "moduleDetection": "force", 14 + "moduleDetection": "auto", 14 15 "noEmit": true, 15 16 "noImplicitOverride": true, 16 17 "skipLibCheck": true,