Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

feat: redesign output system

+437 -441
+1
deno.lock
··· 29 29 "npm:@tokenizer/http@~0.9.2", 30 30 "npm:@tokenizer/range@0.13", 31 31 "npm:@types/throttle-debounce@^5.0.2", 32 + "npm:@types/wicg-file-system-access@^2023.10.6", 32 33 "npm:astro-purgecss@^5.2.2", 33 34 "npm:astro-scope@^3.0.1", 34 35 "npm:astro@^5.7.4",
+8
package-lock.json
··· 27 27 }, 28 28 "devDependencies": { 29 29 "@types/throttle-debounce": "^5.0.2", 30 + "@types/wicg-file-system-access": "^2023.10.6", 30 31 "astro": "^5.7.4", 31 32 "astro-purgecss": "^5.2.2", 32 33 "astro-scope": "^3.0.1", ··· 1911 1912 "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", 1912 1913 "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", 1913 1914 "dev": true 1915 + }, 1916 + "node_modules/@types/wicg-file-system-access": { 1917 + "version": "2023.10.6", 1918 + "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.6.tgz", 1919 + "integrity": "sha512-YO/183gNRzZFSdKu+ikkD7ambAj4PhgjFAF2A/Mw/7wroSF6ne8r804RkpZzqrJ/F6DO2/IYlQF/ULOZ/bhKyA==", 1920 + "dev": true, 1921 + "license": "MIT" 1914 1922 }, 1915 1923 "node_modules/@ungap/structured-clone": { 1916 1924 "version": "1.3.0",
+1
package.json
··· 22 22 }, 23 23 "devDependencies": { 24 24 "@types/throttle-debounce": "^5.0.2", 25 + "@types/wicg-file-system-access": "^2023.10.6", 25 26 "astro": "^5.7.4", 26 27 "astro-purgecss": "^5.2.2", 27 28 "astro-scope": "^3.0.1",
+1 -1
src/components/List.astro
··· 6 6 { 7 7 items.map((item: { title: string; url: string }) => ( 8 8 <li> 9 - {item.title.startsWith("(TODO) ") ? ( 9 + {item.title.startsWith("(TODO) ") || item.title.startsWith("(WIP) ") ? ( 10 10 <span>{item.title}</span> 11 11 ) : ( 12 12 <a href={item.url}>{item.title}</a>
+56 -93
src/pages/configurator/output/_applet.astro
··· 28 28 </p> 29 29 </main> 30 30 31 - <style> 32 - #iframes { 31 + <style is:global> 32 + iframe { 33 33 display: none; 34 34 } 35 35 </style> ··· 40 40 import { type Signal, computed, effect, signal } from "spellcaster/spellcaster.js"; 41 41 import { type ElementConfigurator, repeat, text } from "spellcaster/hyperscript.js"; 42 42 43 + import type { ManagedOutput } from "@applets/core/types"; 43 44 import { applet, hs, register } from "@scripts/applets/common"; 44 - import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 45 + import { INITIAL_MANAGED_OUTPUT } from "@scripts/output/common"; 46 + import { Applet, AppletEvent } from "@web-applets/sdk"; 45 47 46 48 const METHODS = ["browser", "custom", "device"] as const; 47 49 50 + const CONNECTIONS = { 51 + browser: "../../../output/indexed-db/", 52 + custom: undefined, 53 + device: "../../../output/native-fs/", 54 + }; 55 + 48 56 type Method = (typeof METHODS)[number]; 49 57 type List<M extends Method = Method> = Map<string, ListItem<M>>; 50 58 type ListItem<M> = { activated: boolean; icon: string; method: M; title: string }; ··· 62 70 //////////////////////////////////////////// 63 71 // SETUP 64 72 //////////////////////////////////////////// 65 - const context = register<{ ready: boolean }>(); 73 + const context = register<ManagedOutput>(); 66 74 67 - // Applets container 68 - const container = document.createElement("div"); 69 - container.id = "iframes"; 70 - document.body.appendChild(container); 75 + // Applet connections 76 + const connections: Record<string, Applet<ManagedOutput>> = {}; 71 77 72 - // TODO: Use https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API 73 - // so that the other instances of this applet can be notified 74 - // that something changed? 75 - // 76 - // TODO: Should migrate + merge data when switching storages 77 - // Or button to do that? 78 - // 79 - // TODO: Pressing ESC button should hide dialog/modal? 78 + async function connection(method: Method) { 79 + if (connections[method]) return connections[method]; 80 + 81 + let href; 82 + 83 + if (method === "custom") { 84 + href = localStorage.getItem(CUSTOM_KEY); 85 + if (!href) throw new Error("Missing custom applet URL"); 86 + } else { 87 + href = CONNECTIONS[method]; 88 + if (!href) throw new Error("No href defined for this connection method."); 89 + } 80 90 81 - // Applet connections 82 - const storage = { 83 - output: { 84 - indexedDB: await applet("../../../output/indexed-db/", { container }), 85 - nativeFs: await applet("../../../output/native-fs/", { container }), 86 - }, 87 - }; 91 + connections[method] = await applet(href); 92 + return connections[method]; 93 + } 88 94 89 95 // Initial state 90 - context.data = { ready: false }; 96 + context.data = INITIAL_MANAGED_OUTPUT; 91 97 92 98 // Signals 93 99 const stored = localStorage.getItem(LOCALSTORAGE_KEY); ··· 96 102 ); 97 103 98 104 effect(() => { 99 - localStorage.setItem(LOCALSTORAGE_KEY, active()); 105 + const method = active(); 106 + localStorage.setItem(LOCALSTORAGE_KEY, method); 107 + 108 + // Monitor data 109 + // TODO: encode/decode data? 110 + (async () => { 111 + const conn = await connection(method); 112 + context.data = conn.data; 113 + conn.addEventListener("data", dataEventHandler); 114 + })(); 100 115 }); 101 116 117 + function dataEventHandler(event: AppletEvent) { 118 + context.data = event.data as ManagedOutput; 119 + } 120 + 102 121 // Mount + Unmount 103 122 async function mountStorageMethod(method: Method) { 104 123 switch (method) { 105 - case "browser": 106 - await storage.output.indexedDB.sendAction("mount"); 107 - setActive(method); 108 - break; 109 124 case "custom": 110 125 setModalIsOpen(true); 111 126 break; 112 - case "device": 113 - await storage.output.nativeFs.sendAction("mount"); 127 + default: 128 + const conn = await connection(method); 129 + await conn.sendAction("mount"); 114 130 setActive(method); 115 - break; 116 131 } 117 132 } 118 133 119 134 async function unmountStorageMethod(method: Method) { 120 - switch (method) { 121 - case "browser": 122 - await storage.output.indexedDB.sendAction("unmount"); 123 - break; 124 - case "custom": 125 - const applet = await connectToCustomApplet(); 126 - await applet.sendAction("unmount"); 127 - localStorage.removeItem(CUSTOM_KEY); 128 - break; 129 - case "device": 130 - await storage.output.nativeFs.sendAction("unmount"); 131 - break; 132 - } 135 + const conn = await connection(method); 136 + conn.removeEventListener("data", dataEventHandler); 137 + await conn.sendAction("unmount"); 133 138 } 134 139 135 140 //////////////////////////////////////////// ··· 298 303 const url = input.value; 299 304 setCustomState("connecting"); 300 305 301 - const apl = await applet(url, { container }).catch((err) => { 306 + const apl = await applet(url).catch((err) => { 302 307 setCustomState({ error: "Failed to connect" }); 303 308 throw err; 304 309 }); 305 310 306 311 let missingAction; 307 312 308 - ["get", "put", "mount", "unmount"].forEach((method) => { 313 + ["tracks", "mount", "unmount"].forEach((method) => { 309 314 if (!apl.manifest.actions?.[method]) missingAction = method; 310 315 }); 311 316 ··· 322 327 setCustomState("waiting"); 323 328 } 324 329 325 - // 🛠️ 326 - async function connectToCustomApplet() { 327 - const url = localStorage.getItem(CUSTOM_KEY); 328 - if (!url) throw new Error("Missing custom applet URL"); 329 - return await applet(url, { container }); 330 - } 331 - 332 330 // Add to DOM 333 331 document.querySelector("main")?.appendChild(Modal()); 334 332 335 333 //////////////////////////////////////////// 336 334 // ACTIONS 337 335 //////////////////////////////////////////// 338 - const get: OutputGetter = async (args) => { 339 - let data: Uint8Array | undefined; 340 - 341 - switch (active()) { 342 - case "browser": { 343 - return await storage.output.indexedDB.sendAction<Uint8Array | undefined>("get", args); 344 - } 345 - case "custom": 346 - const a = await connectToCustomApplet(); 347 - return await a.sendAction<Uint8Array | undefined>("get", args); 348 - case "device": { 349 - return await storage.output.nativeFs.sendAction<Uint8Array | undefined>("get", args); 350 - } 351 - default: { 352 - return undefined; 353 - } 354 - } 336 + const tracks = async (...args: unknown[]) => { 337 + const conn = await connection(active()); 338 + await conn.sendAction("tracks", ...args); 355 339 }; 356 340 357 - const put: OutputSetter = async (args) => { 358 - switch (active()) { 359 - case "browser": 360 - await storage.output.indexedDB.sendAction("put", args); 361 - break; 362 - case "custom": 363 - const a = await connectToCustomApplet(); 364 - await a.sendAction("put", args); 365 - break; 366 - case "device": 367 - await storage.output.nativeFs.sendAction("put", args); 368 - break; 369 - } 370 - }; 371 - 372 - context.setActionHandler("get", get); 373 - context.setActionHandler("put", put); 374 - 375 - //////////////////////////////////////////// 376 - // 🚦 377 - //////////////////////////////////////////// 378 - context.data = { ready: true }; 341 + context.setActionHandler("tracks", tracks); 379 342 </script>
+8 -20
src/pages/configurator/output/_manifest.json
··· 3 3 "title": "Diffuse Configurator | Output", 4 4 "entrypoint": "index.html", 5 5 "actions": { 6 - "get": { 7 - "title": "Get", 8 - "description": "Get data from the configured storage", 9 - "params_schema": { 10 - "type": "object", 11 - "properties": { 12 - "name": { "type": "string" } 13 - }, 14 - "required": ["name"] 15 - } 16 - }, 17 - "put": { 18 - "title": "Put", 19 - "description": "Put data on the configured storage", 6 + "tracks": { 7 + "title": "Tracks", 8 + "description": "Store or retrieve tracks. Passing in an array of tracks, stores them; passing no parameter, retrieves them.", 20 9 "params_schema": { 21 - "type": "object", 22 - "properties": { 23 - "data": { "type": "object" }, 24 - "name": { "type": "string" } 25 - }, 26 - "required": ["data", "name"] 10 + "type": "array", 11 + "description": "List of tracks", 12 + "items": { 13 + "type": "object" 14 + } 27 15 } 28 16 } 29 17 }
+10 -5
src/pages/core/types.d.ts
··· 1 1 /* OUTPUT */ 2 2 3 - export interface Output<T = TrackTags> { 4 - tracks: Track<T>[]; 3 + export interface Output<S = TrackStats, T = TrackTags> { 4 + tracks: Track<S, T>[]; 5 5 } 6 6 7 - export type OutputGetter = ({ name }: { name: string }) => Promise<Uint8Array | undefined>; 8 - export type OutputSetter = ({ data, name }: { data: Uint8Array; name: string }) => Promise<void>; 7 + export interface ManagedOutput<S = TrackStats, T = TrackTags> { 8 + tracks: { 9 + cacheId: string; 10 + state: "loading" | "loaded"; 11 + collection: Track<S, T>[]; 12 + }; 13 + } 9 14 10 15 /* TRACKS */ 11 16 12 17 export type ResolvedUri = undefined | { url: string; expiresAt: number }; // TODO: Streams? 13 18 14 - export interface Track<Tags = TrackTags, Stats = TrackStats> { 19 + export interface Track<Stats = TrackStats, Tags = TrackTags> { 15 20 id: string; 16 21 17 22 stats?: Stats;
+1 -1
src/pages/index.astro
··· 41 41 42 42 const orchestrators = [ 43 43 { url: "orchestrator/input-cache/", title: "Input caching" }, 44 - { url: "orchestrator/output-management/", title: "Output management" }, 45 44 { url: "orchestrator/single-queue/", title: "Single queue" }, 46 45 ]; 47 46 48 47 const output = [ 49 48 { url: "output/indexed-db/", title: "IndexedDB" }, 50 49 { url: "output/native-fs/", title: "Native File System" }, 50 + { url: "output/storacha-automerge", title: "Storacha Storage + Automerge CRDT" }, 51 51 { url: "output/todo/", title: "(TODO) Keyhive/Beelay" }, 52 52 { url: "output/todo/", title: "(TODO) Dialog DB" }, 53 53 ];
+1 -1
src/pages/input/native-fs/_applet.astro
··· 24 24 import QS from "query-string"; 25 25 26 26 import type { Track } from "@applets/core/types.d.ts"; 27 - import { isAudioFile } from "@scripts/inputs/common"; 27 + import { isAudioFile } from "@scripts/input/common"; 28 28 import { register } from "@scripts/applets/common"; 29 29 30 30 import manifest from "./_manifest.json";
+1 -1
src/pages/input/s3/_applet.astro
··· 45 45 import QS from "query-string"; 46 46 47 47 import type { Track } from "@applets/core/types.d.ts"; 48 - import { isAudioFile } from "@scripts/inputs/common"; 48 + import { isAudioFile } from "@scripts/input/common"; 49 49 import { register } from "@scripts/applets/common"; 50 50 import manifest from "./_manifest.json"; 51 51
+7 -10
src/pages/orchestrator/input-cache/_applet.astro
··· 1 1 <script> 2 - import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 2 + import type { ManagedOutput, ResolvedUri, Track } from "@applets/core/types.d.ts"; 3 3 4 4 import { 5 5 applet, ··· 11 11 //////////////////////////////////////////// 12 12 // SETUP 13 13 //////////////////////////////////////////// 14 - import type * as OutputOrchestrator from "@applets/orchestrator/output-management/types.d.ts"; 15 - 16 14 const context = register<{ isProcessing: boolean; ready: boolean }>(); 17 15 18 16 // Initial data ··· 24 22 // Applet connections 25 23 const configurator = { 26 24 input: await applet("../../configurator/input"), 27 - }; 28 - 29 - const orchestrator = { 30 - output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management"), 25 + output: await applet<ManagedOutput>("../../configurator/output"), 31 26 }; 32 27 33 28 const processor = { ··· 35 30 }; 36 31 37 32 // 🚀 38 - waitUntilAppletData(orchestrator.output, (d) => !!d?.hasSyncedTracks).then(() => process()); 33 + waitUntilAppletData(configurator.output, (d) => d?.tracks.state === "loaded").then(() => 34 + process(), 35 + ); 39 36 40 37 //////////////////////////////////////////// 41 38 // ACTIONS ··· 49 46 50 47 await waitUntilAppletIsReady(configurator.input); 51 48 52 - const cachedTracks = orchestrator.output.data.tracks.collection; 49 + const cachedTracks = configurator.output.data.tracks.collection; 53 50 await configurator.input.sendAction("contextualize", cachedTracks); 54 51 55 52 const tracks = await configurator.input.sendAction<Track[]>("list", cachedTracks, { ··· 97 94 ); 98 95 99 96 // Save 100 - await orchestrator.output.sendAction("tracks", tracksWithMetadata, { 97 + await configurator.output.sendAction("tracks", tracksWithMetadata, { 101 98 timeoutDuration: 60000 * 5, 102 99 }); 103 100
-172
src/pages/orchestrator/output-management/_applet.astro
··· 1 - <!-- TODO: Button to export all user/output data. --><!-- TODO: Button to import data? --> 2 - <script> 3 - import { debounce } from "throttle-debounce"; 4 - import * as Automerge from "@automerge/automerge"; 5 - import * as Uint8 from "uint8arrays"; 6 - 7 - import type { Track } from "@applets/core/types.d.ts"; 8 - import type { State } from "./types.d.ts"; 9 - import { applet, register, waitUntilAppletIsReady } from "@scripts/applets/common"; 10 - 11 - type TracksDoc = { collection: Track[] }; 12 - 13 - const TRACKS_INITIAL_DOC = Automerge.load<TracksDoc>( 14 - Uint8.fromString( 15 - "hW9Kg5qsIsEAeAEQkb+c0IkXTSWyGqZ6jXtFxgETwM42fL3CMN78UZ4Qa3a9RfOrJu5qKzlM7IxwAUXelQYBAgMCEwIjBkACVgIHFQwhAiMCNAFCAlYCgAECfwB/AX8Bf+ub7MEGfwB/B38KY29sbGVjdGlvbn8AfwEBfwJ/AH8AAA", 16 - "base64", 17 - ), 18 - ); 19 - 20 - //////////////////////////////////////////// 21 - // SETUP 22 - //////////////////////////////////////////// 23 - const context = register<State>(); 24 - 25 - // Data codec 26 - const codec = { 27 - decode(data: any) { 28 - return { 29 - hasSyncedTracks: data.hasSyncedTracks, 30 - ready: context.data.ready, 31 - tracks: Automerge.load<TracksDoc>(data.tracks), 32 - }; 33 - }, 34 - 35 - encode(data: State) { 36 - return { 37 - hasSyncedTracks: true, 38 - ready: context.data.ready, 39 - tracks: Automerge.save(data.tracks), 40 - }; 41 - }, 42 - }; 43 - 44 - context.codec = codec; 45 - 46 - // Initial data 47 - context.data = { 48 - // Empty tracks collection, DO NOT CHANGE. 49 - // (avoids the initial sync problem with Automerge) 50 - tracks: TRACKS_INITIAL_DOC, 51 - 52 - hasSyncedTracks: false, 53 - 54 - ready: false, 55 - }; 56 - 57 - // Applet connections 58 - const configurator = { 59 - output: await applet("../../configurator/output"), 60 - }; 61 - 62 - // Load tracks if needed 63 - if (context.isMainInstance()) 64 - loadTracks().then((doc) => { 65 - console.log("LOADED DOC", doc); 66 - 67 - if (doc) { 68 - const mergedDoc = Automerge.merge(context.data.tracks, doc); 69 - console.log("MERGED DOC", doc); 70 - update({ tracks: mergedDoc }); 71 - } 72 - 73 - update({ hasSyncedTracks: true }); 74 - }); 75 - 76 - // State helpers 77 - function update(partial: Partial<State>): void { 78 - context.data = { ...context.data, ...partial }; 79 - } 80 - 81 - function updateTracks(tracks: Track[]): Automerge.Doc<TracksDoc> { 82 - console.log(context.data.tracks); 83 - console.log(context.isMainInstance()); 84 - 85 - const doc = Automerge.change(context.data.tracks, (d) => { 86 - d.collection = cleanUndefinedValuesForTracks(tracks); 87 - }); 88 - 89 - update({ tracks: doc }); 90 - 91 - return doc; 92 - } 93 - 94 - //////////////////////////////////////////// 95 - // LOADERS 96 - //////////////////////////////////////////// 97 - async function loadTracks() { 98 - await waitUntilAppletIsReady(configurator.output); 99 - 100 - const data = await configurator.output.sendAction( 101 - "get", 102 - { 103 - name: "tracks.json", 104 - }, 105 - { 106 - timeoutDuration: 120000, 107 - }, 108 - ); 109 - 110 - console.log("🔮 Loading tracks, got:", data); 111 - 112 - if (!data) { 113 - return undefined; 114 - } 115 - 116 - return Automerge.load<TracksDoc>(data as Uint8Array); 117 - } 118 - 119 - //////////////////////////////////////////// 120 - // ACTIONS 121 - //////////////////////////////////////////// 122 - const tracksHandler = (tracks: Track[]) => { 123 - const doc = updateTracks(tracks); 124 - 125 - console.log("🔮 Tracks collection updated in memory"); 126 - 127 - // Save tracks to output, but only the ones that need to be saved. 128 - // TODO: For each track.uri scheme ask the input configurator if it needs to be cached? 129 - saveTracksToOutput(doc); 130 - }; 131 - 132 - const saveTracksToOutput = debounce(5000, async function (doc: Automerge.Doc<TracksDoc>) { 133 - const data = Automerge.save(doc); 134 - 135 - console.log("🔮 Saving tracks"); 136 - 137 - await configurator.output.sendAction("put", { 138 - name: "tracks.json", 139 - data, 140 - }); 141 - 142 - console.log("🔮 Tracks saved to output"); 143 - }); 144 - 145 - context.setActionHandler("tracks", tracksHandler); 146 - 147 - //////////////////////////////////////////// 148 - // 🛠️ 149 - //////////////////////////////////////////// 150 - function cleanUndefinedValuesForTracks(tracks: Track[]): Track[] { 151 - return tracks.map((track) => { 152 - const t = { ...track }; 153 - 154 - if (t.tags) { 155 - if ("album" in t.tags && t.tags.album === undefined) delete t.tags.album; 156 - if ("artist" in t.tags && t.tags.artist === undefined) delete t.tags.artist; 157 - if ("genre" in t.tags && t.tags.genre === undefined) delete t.tags.genre; 158 - if ("year" in t.tags && t.tags.year === undefined) delete t.tags.year; 159 - 160 - if ("of" in t.tags.disc && t.tags.disc.of === undefined) delete t.tags.disc.of; 161 - if ("of" in t.tags.track && t.tags.track.of === undefined) delete t.tags.track.of; 162 - } 163 - 164 - return t; 165 - }); 166 - } 167 - 168 - //////////////////////////////////////////// 169 - // 🚦 170 - //////////////////////////////////////////// 171 - update({ ready: true }); 172 - </script>
-18
src/pages/orchestrator/output-management/_manifest.json
··· 1 - { 2 - "name": "diffuse/orchestrator/output-management", 3 - "title": "Diffuse Orchestrator | Output management", 4 - "entrypoint": "index.html", 5 - "actions": { 6 - "tracks": { 7 - "title": "Tracks", 8 - "description": "Manage tracks.", 9 - "params_schema": { 10 - "type": "array", 11 - "description": "A list of tracks", 12 - "items": { 13 - "type": "object" 14 - } 15 - } 16 - } 17 - } 18 - }
src/pages/orchestrator/output-management/index.astro src/pages/output/storacha-automerge/index.astro
-10
src/pages/orchestrator/output-management/types.d.ts
··· 1 - import type { Doc } from "@automerge/automerge"; 2 - import type { Output } from "@applets/core/types"; 3 - 4 - export type State = { 5 - tracks: Doc<{ collection: Output["tracks"] }>; 6 - 7 - hasSyncedTracks: boolean; 8 - 9 - ready: boolean; 10 - };
+10 -14
src/pages/orchestrator/single-queue/_applet.astro
··· 1 1 <script> 2 - import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 3 - import { applet, comparable, reactive, register } from "@scripts/applets/common"; 2 + import type { ManagedOutput, ResolvedUri, Track } from "@applets/core/types.d.ts"; 3 + import { applet, reactive, register } from "@scripts/applets/common"; 4 4 5 5 //////////////////////////////////////////// 6 6 // SETUP 7 7 //////////////////////////////////////////// 8 8 import type * as AudioEngine from "@applets/engine/audio/types.d.ts"; 9 9 import type * as QueueEngine from "@applets/engine/queue/types.d.ts"; 10 - import type * as OutputOrchestrator from "@applets/orchestrator/output-management/types.d.ts"; 11 10 12 11 // Register applet 13 12 const context = register<unknown>(); ··· 15 14 // Applet connections 16 15 const configurator = { 17 16 input: await applet("../../configurator/input"), 17 + output: await applet<ManagedOutput>("../../configurator/output"), 18 18 }; 19 19 20 20 const engine = { 21 21 audio: await applet<AudioEngine.State>("../../engine/audio"), 22 22 queue: await applet<QueueEngine.State>("../../engine/queue"), 23 - }; 24 - 25 - const orchestrator = { 26 - output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management"), 27 23 }; 28 24 29 25 //////////////////////////////////////////// ··· 106 102 }, 107 103 }); 108 104 109 - fill(orchestrator.output.data.tracks.collection); 105 + fill(configurator.output.data.tracks.collection); 110 106 }, 111 107 ); 112 108 113 109 //////////////////////////////////////////// 114 - // 🎻 [Connections → Orchestrators] 115 - // 📦 STORAGE 110 + // 🎻 [Connections → Configurators] 111 + // 📦 OUTPUT 116 112 //////////////////////////////////////////// 117 113 reactive( 118 - orchestrator.output, 119 - (data) => (data ? comparable(data.tracks.collection) : undefined), 120 - (hash) => { 121 - if (hash) fill(orchestrator.output.data.tracks.collection); 114 + configurator.output, 115 + (data) => data.tracks.cacheId, 116 + () => { 117 + fill(configurator.output.data.tracks.collection); 122 118 }, 123 119 ); 124 120 </script>
+47 -13
src/pages/output/indexed-db/_applet.astro
··· 1 1 <script> 2 2 import * as IDB from "idb-keyval"; 3 3 4 - import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 5 - import { register } from "@scripts/applets/common"; 4 + import type { ManagedOutput, Track } from "@applets/core/types.d.ts"; 5 + import { jsonDecode, jsonEncode, register } from "@scripts/applets/common"; 6 + import { INITIAL_MANAGED_OUTPUT } from "@scripts/output/common"; 6 7 7 8 //////////////////////////////////////////// 8 9 // SETUP 9 10 //////////////////////////////////////////// 10 11 const IDB_PREFIX = "@applets/output/indexed-db"; 11 - const context = register(); 12 + 13 + const context = register<ManagedOutput>(); 14 + context.data = INITIAL_MANAGED_OUTPUT; 15 + 16 + // Load initial data 17 + if (context.isMainInstance()) 18 + tracks().then((collection) => { 19 + context.data = { 20 + ...context.data, 21 + tracks: { 22 + ...context.data.tracks, 23 + cacheId: crypto.randomUUID(), 24 + state: "loaded", 25 + collection, 26 + }, 27 + }; 28 + }); 12 29 13 30 //////////////////////////////////////////// 14 31 // ACTIONS 15 32 //////////////////////////////////////////// 16 - const get: OutputGetter = async ({ name }) => { 17 - return await IDB.get(`${IDB_PREFIX}/${name}`); 18 - }; 33 + async function tracks(): Promise<Track[]>; 34 + async function tracks(tracks: Track[]): Promise<void>; 35 + async function tracks(tracks?: Track[]): Promise<Track[] | void> { 36 + if (tracks) { 37 + const data = jsonEncode(tracks); 38 + await put({ name: "tracks.json", data }); 39 + return; 40 + } else { 41 + const encoded = await get({ name: "tracks.json" }); 42 + if (!encoded) return []; 43 + return jsonDecode<Track[]>(encoded); 44 + } 45 + } 19 46 20 - const put: OutputSetter = async ({ data, name }) => { 21 - return await IDB.set(`${IDB_PREFIX}/${name}`, data); 22 - }; 47 + async function mount() {} 48 + async function unmount() {} 23 49 24 - const mount = async () => {}; 25 - const unmount = async () => {}; 50 + context.setActionHandler("tracks", tracks); 26 51 27 - context.setActionHandler("get", get); 28 - context.setActionHandler("put", put); 29 52 context.setActionHandler("mount", mount); 30 53 context.setActionHandler("unmount", unmount); 54 + 55 + //////////////////////////////////////////// 56 + // 🛠️ 57 + //////////////////////////////////////////// 58 + async function get({ name }: { name: string }) { 59 + return await IDB.get(`${IDB_PREFIX}/${name}`); 60 + } 61 + 62 + async function put({ data, name }: { data: Uint8Array; name: string }) { 63 + return await IDB.set(`${IDB_PREFIX}/${name}`, data); 64 + } 31 65 </script>
+8 -20
src/pages/output/indexed-db/_manifest.json
··· 3 3 "title": "Diffuse Output | IndexedDB", 4 4 "entrypoint": "index.html", 5 5 "actions": { 6 - "get": { 7 - "title": "Get", 8 - "description": "Retrieve data.", 9 - "params_schema": { 10 - "type": "object", 11 - "properties": { 12 - "name": { "type": "string" } 13 - }, 14 - "required": ["name"] 15 - } 16 - }, 17 - "put": { 18 - "title": "Put", 19 - "description": "Store data.", 6 + "tracks": { 7 + "title": "Tracks", 8 + "description": "Store or retrieve tracks. Passing in an array of tracks, stores them; passing no parameter, retrieves them.", 20 9 "params_schema": { 21 - "type": "object", 22 - "properties": { 23 - "data": { "type": "object" }, 24 - "name": { "type": "string" } 25 - }, 26 - "required": ["data", "name"] 10 + "type": "array", 11 + "description": "List of tracks", 12 + "items": { 13 + "type": "object" 14 + } 27 15 } 28 16 }, 29 17 "mount": {
+66 -28
src/pages/output/native-fs/_applet.astro
··· 1 1 <script> 2 2 import * as IDB from "idb-keyval"; 3 - import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter"; 3 + import "wicg-file-system-access"; 4 4 5 - import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 6 - import { register } from "@scripts/applets/common"; 5 + import type { ManagedOutput, Track } from "@applets/core/types"; 6 + import { jsonDecode, jsonEncode, register } from "@scripts/applets/common"; 7 + import { INITIAL_MANAGED_OUTPUT } from "@scripts/output/common"; 7 8 8 9 //////////////////////////////////////////// 9 10 // SETUP ··· 11 12 const IDB_PREFIX = "@applets/output/native-fs"; 12 13 const IDB_DEVICE_KEY = `${IDB_PREFIX}/device`; 13 14 14 - const context = register(); 15 + const context = register<ManagedOutput>(); 16 + context.data = INITIAL_MANAGED_OUTPUT; 17 + 18 + // Load initial data 19 + if (context.isMainInstance()) 20 + tracks().then((collection) => { 21 + context.data = { 22 + ...context.data, 23 + tracks: { 24 + ...context.data.tracks, 25 + cacheId: crypto.randomUUID(), 26 + state: "loaded", 27 + collection, 28 + }, 29 + }; 30 + }); 15 31 16 32 //////////////////////////////////////////// 17 33 // ACTIONS 18 34 //////////////////////////////////////////// 19 - const get: OutputGetter = async ({ name }) => { 35 + async function tracks(): Promise<Track[]>; 36 + async function tracks(tracks: Track[]): Promise<void>; 37 + async function tracks(tracks?: Track[]): Promise<Track[] | void> { 38 + if (tracks) { 39 + const data = jsonEncode(tracks); 40 + await put({ name: "tracks.json", data }); 41 + return; 42 + } else { 43 + const encoded = await get({ name: "tracks.json" }); 44 + if (!encoded) return []; 45 + return jsonDecode<Track[]>(encoded); 46 + } 47 + } 48 + 49 + async function mount() { 50 + if (!("showDirectoryPicker" in self)) { 51 + alert("File System Access API is not supported on this platform."); 52 + return; 53 + } 54 + 55 + const existingHandle = await IDB.get(IDB_DEVICE_KEY); 56 + if (!existingHandle) { 57 + const directoryHandle = await self.showDirectoryPicker(); 58 + await IDB.set(IDB_DEVICE_KEY, directoryHandle); 59 + await directoryHandle.requestPermission({ mode: "readwrite" }); 60 + } 61 + } 62 + 63 + async function unmount() { 64 + try { 65 + await IDB.del(IDB_DEVICE_KEY); 66 + } catch (err) {} 67 + } 68 + 69 + context.setActionHandler("tracks", tracks); 70 + 71 + context.setActionHandler("mount", mount); 72 + context.setActionHandler("unmount", unmount); 73 + 74 + //////////////////////////////////////////// 75 + // 🛠️ 76 + //////////////////////////////////////////// 77 + async function get({ name }: { name: string }) { 20 78 const handle: FileSystemDirectoryHandle | null = (await IDB.get(IDB_DEVICE_KEY)) ?? null; 21 79 if (!handle) throw new Error("Storage not configured properly, handle not found."); 22 80 ··· 28 86 } catch (err) { 29 87 return undefined; 30 88 } 31 - }; 89 + } 32 90 33 - const put: OutputSetter = async ({ data, name }) => { 91 + async function put({ data, name }: { data: Uint8Array; name: string }) { 34 92 const handle: FileSystemDirectoryHandle | null = (await IDB.get(IDB_DEVICE_KEY)) ?? null; 35 93 if (!handle) throw new Error("Storage not configured properly, handle not found."); 36 94 const fileHandle = await handle.getFileHandle(name, { create: true }); 37 95 const stream = await fileHandle.createWritable(); 38 96 await stream.write(data); 39 97 await stream.close(); 40 - }; 41 - 42 - const mount = async () => { 43 - const existingHandle = await IDB.get(IDB_DEVICE_KEY); 44 - if (!existingHandle) { 45 - const directoryHandle = await showDirectoryPicker(); 46 - await IDB.set(IDB_DEVICE_KEY, directoryHandle); 47 - await directoryHandle.requestPermission({ mode: "readwrite" }); 48 - } 49 - }; 50 - 51 - const unmount = async () => { 52 - try { 53 - await IDB.del(IDB_DEVICE_KEY); 54 - } catch (err) {} 55 - }; 56 - 57 - context.setActionHandler("get", get); 58 - context.setActionHandler("put", put); 59 - context.setActionHandler("mount", mount); 60 - context.setActionHandler("unmount", unmount); 98 + } 61 99 </script>
+8 -20
src/pages/output/native-fs/_manifest.json
··· 3 3 "title": "Diffuse Output | Native File System", 4 4 "entrypoint": "index.html", 5 5 "actions": { 6 - "get": { 7 - "title": "Get", 8 - "description": "Retrieve data.", 9 - "params_schema": { 10 - "type": "object", 11 - "properties": { 12 - "name": { "type": "string" } 13 - }, 14 - "required": ["name"] 15 - } 16 - }, 17 - "put": { 18 - "title": "Put", 19 - "description": "Store data.", 6 + "tracks": { 7 + "title": "Tracks", 8 + "description": "Store or retrieve tracks. Passing in an array of tracks, stores them; passing no parameter, retrieves them.", 20 9 "params_schema": { 21 - "type": "object", 22 - "properties": { 23 - "data": { "type": "object" }, 24 - "name": { "type": "string" } 25 - }, 26 - "required": ["data", "name"] 10 + "type": "array", 11 + "description": "List of tracks", 12 + "items": { 13 + "type": "object" 14 + } 27 15 } 28 16 }, 29 17 "mount": {
+123
src/pages/output/storacha-automerge/_applet.astro
··· 1 + <script> 2 + import * as IDB from "idb-keyval"; 3 + import * as Automerge from "@automerge/automerge"; 4 + import * as Uint8 from "uint8arrays"; 5 + 6 + import type { ManagedOutput, Track } from "@applets/core/types.d.ts"; 7 + import { cleanUndefinedValuesForTracks, register } from "@scripts/applets/common"; 8 + import { INITIAL_MANAGED_OUTPUT } from "@scripts/output/common"; 9 + 10 + //////////////////////////////////////////// 11 + // 🏔️ 12 + //////////////////////////////////////////// 13 + type TracksDocument = { collection: Track[] }; 14 + 15 + const INITIAL_TRACKS_DOCUMENT = Automerge.load<TracksDocument>( 16 + Uint8.fromString( 17 + "hW9Kg5qsIsEAeAEQkb+c0IkXTSWyGqZ6jXtFxgETwM42fL3CMN78UZ4Qa3a9RfOrJu5qKzlM7IxwAUXelQYBAgMCEwIjBkACVgIHFQwhAiMCNAFCAlYCgAECfwB/AX8Bf+ub7MEGfwB/B38KY29sbGVjdGlvbn8AfwEBfwJ/AH8AAA", 18 + "base64", 19 + ), 20 + ); 21 + 22 + //////////////////////////////////////////// 23 + // SETUP 24 + //////////////////////////////////////////// 25 + const IDB_PREFIX = "@applets/output/storacha-automerge"; 26 + 27 + const context = register<ManagedOutput>(); 28 + context.data = { 29 + ...INITIAL_MANAGED_OUTPUT, 30 + tracks: { 31 + ...INITIAL_MANAGED_OUTPUT.tracks, 32 + ...INITIAL_TRACKS_DOCUMENT, 33 + }, 34 + }; 35 + 36 + // Data codec 37 + const codec = { 38 + decode(data: any) { 39 + return { 40 + ...data, 41 + tracks: Automerge.load<TracksDocument>(data.tracks), 42 + }; 43 + }, 44 + 45 + encode(data: ManagedOutput) { 46 + return { 47 + ...data, 48 + tracks: Automerge.save(data.tracks), 49 + }; 50 + }, 51 + }; 52 + 53 + context.codec = codec; 54 + 55 + // Load initial data 56 + if (context.isMainInstance()) 57 + tracks().then((trackDocument) => { 58 + context.data = { 59 + ...context.data, 60 + tracks: { 61 + ...context.data.tracks, 62 + ...trackDocument, 63 + cacheId: crypto.randomUUID(), 64 + state: "loaded", 65 + }, 66 + }; 67 + }); 68 + 69 + //////////////////////////////////////////// 70 + // ACTIONS 71 + //////////////////////////////////////////// 72 + async function tracks(): Promise<Track[]>; 73 + async function tracks(tracks: Track[]): Promise<void>; 74 + async function tracks(tracks?: Track[]): Promise<Track[] | void> { 75 + if (tracks) { 76 + const doc = Automerge.change(context.data.tracks, (d) => { 77 + d.collection = cleanUndefinedValuesForTracks(tracks); 78 + }); 79 + 80 + context.data = { 81 + ...context.data, 82 + tracks: { 83 + ...context.data.tracks, 84 + ...doc, 85 + }, 86 + }; 87 + 88 + const data = Automerge.save(doc); 89 + // TODO: Save to Storacha 90 + 91 + return; 92 + } else { 93 + const doc = await tracksDocument(); 94 + return doc.collection; 95 + } 96 + } 97 + 98 + async function tracksDocument(): Promise<TracksDocument> { 99 + // TODO: Load from Storacha 100 + // const data = new Uint8Array() 101 + // const doc = Automerge.load<TracksDocument>(data as Uint8Array); 102 + return INITIAL_TRACKS_DOCUMENT; 103 + } 104 + 105 + async function mount() {} 106 + async function unmount() {} 107 + 108 + context.setActionHandler("tracks", tracks); 109 + 110 + context.setActionHandler("mount", mount); 111 + context.setActionHandler("unmount", unmount); 112 + 113 + //////////////////////////////////////////// 114 + // 🛠️ 115 + //////////////////////////////////////////// 116 + async function get({ name }: { name: string }) { 117 + return await IDB.get(`${IDB_PREFIX}/${name}`); 118 + } 119 + 120 + async function put({ data, name }: { data: Uint8Array; name: string }) { 121 + return await IDB.set(`${IDB_PREFIX}/${name}`, data); 122 + } 123 + </script>
+26
src/pages/output/storacha-automerge/_manifest.json
··· 1 + { 2 + "name": "diffuse/output/storacha-automerge", 3 + "title": "Diffuse Output | Storacha Storage + Automerge CRDT", 4 + "entrypoint": "index.html", 5 + "actions": { 6 + "tracks": { 7 + "title": "Tracks", 8 + "description": "Store or retrieve tracks. Passing in an array of tracks, stores them; passing no parameter, retrieves them.", 9 + "params_schema": { 10 + "type": "array", 11 + "description": "List of tracks", 12 + "items": { 13 + "type": "object" 14 + } 15 + } 16 + }, 17 + "mount": { 18 + "title": "Mount", 19 + "description": "Prepare for usage." 20 + }, 21 + "unmount": { 22 + "title": "Unmount", 23 + "description": "Callback after usage." 24 + } 25 + } 26 + }
+3 -3
src/pages/processor/metadata-fetcher/_applet.astro
··· 1 1 <script> 2 - import { applets } from "@web-applets/sdk"; 3 2 import { parseFromTokenizer, parseWebStream } from "music-metadata"; 4 3 import { contentType } from "@std/media-types"; 5 4 import * as URI from "uri-js"; 6 5 import * as HTTP_TOKENIZER from "@tokenizer/http"; 7 6 import * as RANGE_TOKENIZER from "@tokenizer/range"; 8 7 9 - import { TrackStats, TrackTags } from "@applets/core/types"; 8 + import type { TrackStats, TrackTags } from "@applets/core/types"; 9 + import { register } from "@scripts/applets/common"; 10 10 11 11 //////////////////////////////////////////// 12 12 // SETUP 13 13 //////////////////////////////////////////// 14 - const context = applets.register(); 14 + const context = register(); 15 15 16 16 type Extraction = { stats: TrackStats; tags: TrackTags }; 17 17 type Urls = { get: string; head: string };
+27
src/scripts/applets/common.ts
··· 5 5 import { type ElementConfigurator, h } from "spellcaster/hyperscript.js"; 6 6 import { effect, isSignal, Signal, signal } from "spellcaster/spellcaster.js"; 7 7 import { xxh32 } from "xxh32"; 8 + import { Track } from "@applets/core/types"; 8 9 9 10 //////////////////////////////////////////// 10 11 // 🪟 Applet connector ··· 231 232 return () => port; 232 233 } 233 234 235 + export function cleanUndefinedValuesForTracks(tracks: Track[]): Track[] { 236 + return tracks.map((track) => { 237 + const t = { ...track }; 238 + 239 + if (t.tags) { 240 + if ("album" in t.tags && t.tags.album === undefined) delete t.tags.album; 241 + if ("artist" in t.tags && t.tags.artist === undefined) delete t.tags.artist; 242 + if ("genre" in t.tags && t.tags.genre === undefined) delete t.tags.genre; 243 + if ("year" in t.tags && t.tags.year === undefined) delete t.tags.year; 244 + 245 + if ("of" in t.tags.disc && t.tags.disc.of === undefined) delete t.tags.disc.of; 246 + if ("of" in t.tags.track && t.tags.track.of === undefined) delete t.tags.track.of; 247 + } 248 + 249 + return t; 250 + }); 251 + } 252 + 234 253 export function comparable(value: unknown) { 235 254 return xxh32(JSON.stringify(value)); 236 255 } ··· 251 270 252 271 export function isPrimitive(test: unknown) { 253 272 return test !== Object(test); 273 + } 274 + 275 + export function jsonDecode<T>(a: any): T { 276 + return JSON.parse(new TextDecoder().decode(a)); 277 + } 278 + 279 + export function jsonEncode<T>(a: T): Uint8Array { 280 + return new TextEncoder().encode(JSON.stringify(a)); 254 281 } 255 282 256 283 export function waitUntilAppletData<A>(
src/scripts/inputs/common.ts src/scripts/input/common.ts
+9
src/scripts/output/common.ts
··· 1 + import type { ManagedOutput } from "@applets/core/types"; 2 + 3 + export const INITIAL_MANAGED_OUTPUT: ManagedOutput = { 4 + tracks: { 5 + cacheId: crypto.randomUUID(), 6 + state: "loading", 7 + collection: [], 8 + }, 9 + };
-1
src/scripts/themes/pilot/index.ts
··· 24 24 input: await applet("../../orchestrator/input-cache", { 25 25 applets: { input: "todo" }, 26 26 }), 27 - output: await applet("../../orchestrator/output-management"), 28 27 queue: await applet("../../orchestrator/single-queue"), 29 28 }; 30 29
+15 -10
src/scripts/themes/webamp/index.ts
··· 1 1 import Webamp from "webamp"; 2 2 import { URLTrack } from "webamp"; 3 3 4 - import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 5 - import { applet } from "@scripts/applets/common"; 4 + import type { ManagedOutput, ResolvedUri, Track } from "@applets/core/types.d.ts"; 5 + import { applet, waitUntilAppletData } from "@scripts/applets/common"; 6 6 7 7 //////////////////////////////////////////// 8 8 // 🎨 Styles ··· 12 12 //////////////////////////////////////////// 13 13 // 🗂️ Applets 14 14 //////////////////////////////////////////// 15 - import type * as OutputOrchestrator from "@applets/orchestrator/output-management/types.d.ts"; 16 - 17 15 const configurator = { 18 16 input: await applet("../../configurator/input"), 17 + output: await applet<ManagedOutput>("../../configurator/output"), 19 18 }; 20 19 21 20 const orchestrator = { 22 - output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management"), 23 - 24 - // TODO: Should this be explicitely be ran after the output orchestrator is loaded? 25 21 input: await applet("../../orchestrator/input-cache"), 26 22 }; 27 23 ··· 37 33 document.body.appendChild(ampNode); 38 34 amp.renderWhenReady(ampNode); 39 35 40 - orchestrator.output.ondata = async () => { 36 + waitUntilAppletData(configurator.output, (d) => d?.tracks.state === "loaded").then(loadAndInsert); 37 + configurator.output.ondata = loadAndInsert; 38 + 39 + let inserting = false; 40 + 41 + async function loadAndInsert() { 42 + if (configurator.output.data.tracks.state !== "loaded") return; 43 + if (inserting) return; 44 + inserting = true; 41 45 const tracks = await loadTracks(); 42 46 amp.setTracksToPlay([]); 43 47 amp.appendTracks(tracks); 44 48 amp.nextTrack(); 45 - }; 49 + inserting = false; 50 + } 46 51 47 52 //////////////////////////////////////////// 48 53 // 🛠️ 49 54 //////////////////////////////////////////// 50 55 async function loadTracks(): Promise<URLTrack[]> { 51 - return await orchestrator.output.data.tracks.collection.reduce( 56 + return await configurator.output.data.tracks.collection.reduce( 52 57 async (promise: Promise<URLTrack[]>, track: Track) => { 53 58 const acc = await promise; 54 59