Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

feat: use broadcast channel to have applets talk to each other instead of providing context

+431 -268
+3
astro.config.js
··· 11 11 }, 12 12 vite: { 13 13 plugins: [wasm()], 14 + server: { 15 + hmr: false, 16 + }, 14 17 }, 15 18 });
+2 -1
deno.lock
··· 22 22 "packageJson": { 23 23 "dependencies": [ 24 24 "npm:98.css@~0.1.21", 25 - "npm:@automerge/automerge@^2.2.9", 25 + "npm:@automerge/automerge@^3.0.0-beta.0", 26 26 "npm:@jsr/bradenmacdonald__s3-lite-client@0.9", 27 27 "npm:@jsr/std__media-types@^1.1.0", 28 28 "npm:@picocss/pico@^2.1.1", ··· 41 41 "npm:sass@^1.87.0", 42 42 "npm:spellcaster@6", 43 43 "npm:throttle-debounce@^5.0.2", 44 + "npm:uint8arrays@^5.1.0", 44 45 "npm:uri-js@^4.4.1", 45 46 "npm:vite-plugin-wasm@^3.4.1", 46 47 "npm:webamp@^1.5.0",
+20 -4
package-lock.json
··· 5 5 "packages": { 6 6 "": { 7 7 "dependencies": { 8 - "@automerge/automerge": "^2.2.9", 8 + "@automerge/automerge": "^3.0.0-beta.0", 9 9 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 10 10 "@picocss/pico": "^2.1.1", 11 11 "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", ··· 20 20 "query-string": "^9.1.2", 21 21 "spellcaster": "^6.0.0", 22 22 "throttle-debounce": "^5.0.2", 23 + "uint8arrays": "^5.1.0", 23 24 "uri-js": "^4.4.1", 24 25 "webamp": "^1.5.0", 25 26 "xxh32": "^2.0.5" ··· 112 113 } 113 114 }, 114 115 "node_modules/@automerge/automerge": { 115 - "version": "2.2.9", 116 - "resolved": "https://registry.npmjs.org/@automerge/automerge/-/automerge-2.2.9.tgz", 117 - "integrity": "sha512-6HM52Ops79hAQBWMg/t0MNfGOdEiXyenQjO9F1hKZq0RWDsMLpPa1SzRy/C4/4UyX67sTHuA5CwBpH34SpfZlA==", 116 + "version": "3.0.0-preview.13", 117 + "resolved": "https://registry.npmjs.org/@automerge/automerge/-/automerge-3.0.0-preview.13.tgz", 118 + "integrity": "sha512-1r7ggaTqsQ4PHGv45QjVOxPOvJIKjSrHY+HTiFxCU04Qlx3kvXxDLVyBbZeN1jg2I+Y8tpuG0eVtC4QxL9wGIg==", 118 119 "license": "MIT", 119 120 "dependencies": { 120 121 "uuid": "^9.0.0" ··· 4876 4877 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 4877 4878 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 4878 4879 }, 4880 + "node_modules/multiformats": { 4881 + "version": "13.3.6", 4882 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.6.tgz", 4883 + "integrity": "sha512-yakbt9cPYj8d3vi/8o/XWm61MrOILo7fsTL0qxNx6zS0Nso6K5JqqS2WV7vK/KSuDBvrW3KfCwAdAgarAgOmww==", 4884 + "license": "Apache-2.0 OR MIT" 4885 + }, 4879 4886 "node_modules/music-metadata": { 4880 4887 "version": "11.2.3", 4881 4888 "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.2.3.tgz", ··· 6478 6485 }, 6479 6486 "funding": { 6480 6487 "url": "https://github.com/sponsors/sindresorhus" 6488 + } 6489 + }, 6490 + "node_modules/uint8arrays": { 6491 + "version": "5.1.0", 6492 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", 6493 + "integrity": "sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==", 6494 + "license": "Apache-2.0 OR MIT", 6495 + "dependencies": { 6496 + "multiformats": "^13.0.0" 6481 6497 } 6482 6498 }, 6483 6499 "node_modules/ultrahtml": {
+2 -1
package.json
··· 1 1 { 2 2 "dependencies": { 3 - "@automerge/automerge": "^2.2.9", 3 + "@automerge/automerge": "^3.0.0-beta.0", 4 4 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 5 5 "@picocss/pico": "^2.1.1", 6 6 "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", ··· 15 15 "query-string": "^9.1.2", 16 16 "spellcaster": "^6.0.0", 17 17 "throttle-debounce": "^5.0.2", 18 + "uint8arrays": "^5.1.0", 18 19 "uri-js": "^4.4.1", 19 20 "webamp": "^1.5.0", 20 21 "xxh32": "^2.0.5"
+2 -4
src/pages/configurator/input/_applet.astro
··· 30 30 </style> 31 31 32 32 <script> 33 - import { applets } from "@web-applets/sdk"; 34 - 35 33 import type { Track } from "@applets/core/types.d.ts"; 36 - import { applet } from "@scripts/theme"; 34 + import { applet, register } from "@scripts/applets/common"; 37 35 38 36 //////////////////////////////////////////// 39 37 // SETUP 40 38 //////////////////////////////////////////// 41 - const context = applets.register<{ ready: boolean }>(); 39 + const context = register<{ ready: boolean }>(); 42 40 43 41 // Initial state 44 42 context.data = {
+2 -3
src/pages/configurator/output/_applet.astro
··· 39 39 import scope from "astro:scope"; 40 40 import { type Signal, computed, effect, signal } from "spellcaster/spellcaster.js"; 41 41 import { type ElementConfigurator, repeat, text } from "spellcaster/hyperscript.js"; 42 - import { applets } from "@web-applets/sdk"; 43 42 44 - import { applet, hs } from "@src/scripts/theme"; 43 + import { applet, hs, register } from "@scripts/applets/common"; 45 44 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 46 45 47 46 const METHODS = ["browser", "custom", "device"] as const; ··· 63 62 //////////////////////////////////////////// 64 63 // SETUP 65 64 //////////////////////////////////////////// 66 - const context = applets.register<{ ready: boolean }>(); 65 + const context = register<{ ready: boolean }>(); 67 66 68 67 // Applets container 69 68 const container = document.createElement("div");
+12 -13
src/pages/engine/audio/_applet.astro
··· 1 1 <script> 2 - import { applets } from "@web-applets/sdk"; 3 - import { State, Track, TrackState } from "./types"; 2 + import type { State, Track, TrackState } from "./types"; 3 + import { register } from "@scripts/applets/common"; 4 4 5 5 //////////////////////////////////////////// 6 6 // CONSTANTS ··· 11 11 //////////////////////////////////////////// 12 12 // SETUP 13 13 //////////////////////////////////////////// 14 - const context = applets.register<State>(); 14 + const context = register<State>(); 15 + 16 + // Audio elements container 15 17 const container = document.createElement("div"); 16 - 17 18 container.id = "container"; 18 19 document.body.appendChild(container); 19 20 ··· 40 41 //////////////////////////////////////////// 41 42 // ACTIONS 42 43 //////////////////////////////////////////// 43 - context.setActionHandler( 44 - "render", 45 - async (args: { play?: { trackId: string; volume?: number }; tracks: Track[] }) => { 46 - await render(args.tracks); 47 - if (args.play) play({ trackId: args.play.trackId, volume: args.play.volume }); 48 - }, 49 - ); 50 - 51 44 context.setActionHandler("pause", pause); 52 45 context.setActionHandler("play", play); 53 46 context.setActionHandler("reload", reload); 47 + context.setActionHandler("render", render); 54 48 context.setActionHandler("seek", seek); 55 49 context.setActionHandler("volume", volume); 56 50 ··· 102 96 }); 103 97 } 104 98 99 + async function render(args: { play?: { trackId: string; volume?: number }; tracks: Track[] }) { 100 + await renderTracks(args.tracks); 101 + if (args.play) play({ trackId: args.play.trackId, volume: args.play.volume }); 102 + } 103 + 105 104 function seek({ percentage, trackId }: { percentage: number; trackId: string }) { 106 105 withAudioNode(trackId, (audio) => { 107 106 if (!isNaN(audio.duration)) { ··· 122 121 //////////////////////////////////////////// 123 122 // RENDER 124 123 //////////////////////////////////////////// 125 - async function render(tracks: Array<Track>) { 124 + async function renderTracks(tracks: Array<Track>) { 126 125 const ids = tracks.map((e) => e.id); 127 126 const existingNodes: Record<string, HTMLAudioElement> = {}; 128 127
+2 -2
src/pages/engine/queue/_applet.astro
··· 1 1 <script> 2 - import { applets } from "@web-applets/sdk"; 3 2 import { QueueItem, State } from "./types"; 3 + import { register } from "@scripts/applets/common"; 4 4 5 5 //////////////////////////////////////////// 6 6 // SETUP 7 7 //////////////////////////////////////////// 8 - const context = applets.register<State>(); 8 + const context = register<State>(); 9 9 10 10 // Initial state 11 11 context.data = {
+3 -3
src/pages/index.astro
··· 141 141 </Applet> 142 142 143 143 <Applet title="Orchestrators" list={orchestrators}> 144 - These too are applet compositions. However, unlike themes, these are purely logical, and 145 - reuse applet instances from the parent context (when available). Mostly exist in order to 146 - construct sensible defaults to use across themes and abstractions. 144 + These too are applet compositions. However, unlike themes, these are purely logical. 145 + Mostly exist in order to construct sensible defaults to use across themes and 146 + abstractions. 147 147 </Applet> 148 148 149 149 <Applet title="Output" list={output}>
+2 -2
src/pages/input/native-fs/_applet.astro
··· 16 16 </main> 17 17 18 18 <script> 19 - import { applets } from "@web-applets/sdk"; 20 19 import { computed, effect, Signal, signal } from "spellcaster"; 21 20 import { repeat, tags, text } from "spellcaster/hyperscript.js"; 22 21 import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter"; ··· 26 25 27 26 import type { Track } from "@applets/core/types.d.ts"; 28 27 import { isAudioFile } from "@scripts/inputs/common"; 28 + import { register } from "@scripts/applets/common"; 29 29 30 30 import manifest from "./_manifest.json"; 31 31 ··· 41 41 const SCHEME = manifest.input_properties.scheme; 42 42 43 43 // Register applet 44 - const context = applets.register(); 44 + const context = register(); 45 45 46 46 //////////////////////////////////////////// 47 47 // UI
+3 -4
src/pages/input/s3/_applet.astro
··· 38 38 39 39 <script> 40 40 import { S3Client } from "@bradenmacdonald/s3-lite-client"; 41 - import { applets } from "@web-applets/sdk"; 42 41 import { computed, effect, Signal, signal } from "spellcaster"; 43 42 import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js"; 44 43 import * as IDB from "idb-keyval"; ··· 46 45 import QS from "query-string"; 47 46 48 47 import type { Track } from "@applets/core/types.d.ts"; 49 - 50 - import manifest from "./_manifest.json"; 51 48 import { isAudioFile } from "@scripts/inputs/common"; 49 + import { register } from "@scripts/applets/common"; 50 + import manifest from "./_manifest.json"; 52 51 53 52 type Bucket = { 54 53 accessKey: string; ··· 86 85 const SCHEME = manifest.input_properties.scheme; 87 86 88 87 // Register applet 89 - const context = applets.register(); 88 + const context = register(); 90 89 91 90 //////////////////////////////////////////// 92 91 // UI
+15 -16
src/pages/orchestrator/input-cache/_applet.astro
··· 1 1 <script> 2 - import { applets } from "@web-applets/sdk"; 3 - 4 2 import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 5 - import { applet, waitUntilAppletData, waitUntilAppletIsReady } from "@scripts/theme"; 3 + 4 + import { 5 + applet, 6 + register, 7 + waitUntilAppletData, 8 + waitUntilAppletIsReady, 9 + } from "@scripts/applets/common"; 6 10 7 11 //////////////////////////////////////////// 8 12 // SETUP 9 13 //////////////////////////////////////////// 10 14 import type * as OutputOrchestrator from "@applets/orchestrator/output-management/types.d.ts"; 11 15 12 - const context = applets.register<{ isProcessing: boolean; ready: boolean }>(); 13 - const topContext = self.top || self.parent; 16 + const context = register<{ isProcessing: boolean; ready: boolean }>(); 14 17 15 18 // Initial data 16 19 context.data = { ··· 20 23 21 24 // Applet connections 22 25 const configurator = { 23 - input: await applet("../../configurator/input", { context: topContext }), 26 + input: await applet("../../configurator/input"), 24 27 }; 25 28 26 29 const orchestrator = { 27 - output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management", { 28 - context: topContext, 29 - }), 30 + output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management"), 30 31 }; 31 32 32 33 const processor = { 33 - metadataFetcher: await applet("../../processor/metadata-fetcher", { 34 - context: topContext, 35 - }), 34 + metadataFetcher: await applet("../../processor/metadata-fetcher"), 36 35 }; 37 36 38 37 // 🚀 ··· 68 67 "resolve", 69 68 { method: "GET", uri: track.uri }, 70 69 { 71 - timeoutDuration: 60000, 70 + timeoutDuration: 60000 * 5, 72 71 }, 73 72 ); 74 73 ··· 76 75 "resolve", 77 76 { method: "HEAD", uri: track.uri }, 78 77 { 79 - timeoutDuration: 60000, 78 + timeoutDuration: 60000 * 5, 80 79 }, 81 80 ); 82 81 ··· 86 85 "extract", 87 86 { urls: { get: resGet.url, head: resHead?.url || resGet.url } }, 88 87 { 89 - timeoutDuration: 60000, 88 + timeoutDuration: 60000 * 15, 90 89 }, 91 90 ); 92 91 ··· 99 98 100 99 // Save 101 100 await orchestrator.output.sendAction("tracks", tracksWithMetadata, { 102 - timeoutDuration: 60000 * 2, 101 + timeoutDuration: 60000 * 5, 103 102 }); 104 103 105 104 // Fin
+63 -27
src/pages/orchestrator/output-management/_applet.astro
··· 1 1 <!-- TODO: Button to export all user/output data. --><!-- TODO: Button to import data? --> 2 2 <script> 3 - import { applets } from "@web-applets/sdk"; 4 3 import { debounce } from "throttle-debounce"; 5 4 import * as Automerge from "@automerge/automerge"; 5 + import * as Uint8 from "uint8arrays"; 6 6 7 7 import type { Track } from "@applets/core/types.d.ts"; 8 8 import type { State } from "./types.d.ts"; 9 - import { applet, waitUntilAppletIsReady } from "@scripts/theme"; 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 + ); 10 19 11 20 //////////////////////////////////////////// 12 21 // SETUP 13 22 //////////////////////////////////////////// 14 - const context = applets.register<State>(); 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; 15 45 16 46 // Initial data 17 47 context.data = { 18 - tracks: Automerge.from({ collection: [] }, {}), 48 + // Empty tracks collection, DO NOT CHANGE. 49 + // (avoids the initial sync problem with Automerge) 50 + tracks: TRACKS_INITIAL_DOC, 19 51 20 52 hasSyncedTracks: false, 21 53 ··· 24 56 25 57 // Applet connections 26 58 const configurator = { 27 - output: await applet("../../configurator/output", { context: self.top || self.parent }), 59 + output: await applet("../../configurator/output"), 28 60 }; 29 61 30 - // Load tracks 31 - loadTracks().then((doc) => { 32 - if (doc) { 33 - const mergedDoc = Automerge.merge(doc, context.data.tracks); 34 - update({ tracks: mergedDoc }); 35 - } 62 + // Load tracks if needed 63 + if (context.isMainInstance()) 64 + loadTracks().then((doc) => { 65 + console.log("LOADED DOC", doc); 36 66 37 - update({ hasSyncedTracks: true }); 38 - }); 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 + }); 39 75 40 76 // State helpers 41 77 function update(partial: Partial<State>): void { 42 78 context.data = { ...context.data, ...partial }; 43 79 } 44 80 45 - function updateTracks(tracks: Track[]): Automerge.Doc<{ collection: Track[] }> { 81 + function updateTracks(tracks: Track[]): Automerge.Doc<TracksDoc> { 82 + console.log(context.data.tracks); 83 + console.log(context.isMainInstance()); 84 + 46 85 const doc = Automerge.change(context.data.tracks, (d) => { 47 86 d.collection = cleanUndefinedValuesForTracks(tracks); 48 87 }); ··· 74 113 return undefined; 75 114 } 76 115 77 - return Automerge.load<{ collection: Track[] }>(data as Uint8Array); 116 + return Automerge.load<TracksDoc>(data as Uint8Array); 78 117 } 79 118 80 119 //////////////////////////////////////////// ··· 90 129 saveTracksToOutput(doc); 91 130 }; 92 131 93 - const saveTracksToOutput = debounce( 94 - 5000, 95 - async function (doc: Automerge.Doc<{ collection: Track[] }>) { 96 - const data = Automerge.save(doc); 132 + const saveTracksToOutput = debounce(5000, async function (doc: Automerge.Doc<TracksDoc>) { 133 + const data = Automerge.save(doc); 97 134 98 - console.log("🔮 Saving tracks"); 135 + console.log("🔮 Saving tracks"); 99 136 100 - await configurator.output.sendAction("put", { 101 - name: "tracks.json", 102 - data, 103 - }); 137 + await configurator.output.sendAction("put", { 138 + name: "tracks.json", 139 + data, 140 + }); 104 141 105 - console.log("🔮 Tracks saved to output"); 106 - }, 107 - ); 142 + console.log("🔮 Tracks saved to output"); 143 + }); 108 144 109 145 context.setActionHandler("tracks", tracksHandler); 110 146
+6 -16
src/pages/orchestrator/single-queue/_applet.astro
··· 1 1 <script> 2 - import { applets } from "@web-applets/sdk"; 3 - 4 2 import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 5 - import { applet, comparable, reactive } from "@scripts/theme"; 3 + import { applet, comparable, reactive, register } from "@scripts/applets/common"; 6 4 7 5 //////////////////////////////////////////// 8 6 // SETUP ··· 12 10 import type * as OutputOrchestrator from "@applets/orchestrator/output-management/types.d.ts"; 13 11 14 12 // Register applet 15 - const context = applets.register<unknown>(); 13 + const context = register<unknown>(); 16 14 17 15 // Applet connections 18 16 const configurator = { 19 - input: await applet("../../configurator/input", { 20 - context: self.top || self.parent, 21 - }), 17 + input: await applet("../../configurator/input"), 22 18 }; 23 19 24 20 const engine = { 25 - audio: await applet<AudioEngine.State>("../../engine/audio", { 26 - context: self.top || self.parent, 27 - }), 28 - queue: await applet<QueueEngine.State>("../../engine/queue", { 29 - context: self.top || self.parent, 30 - }), 21 + audio: await applet<AudioEngine.State>("../../engine/audio"), 22 + queue: await applet<QueueEngine.State>("../../engine/queue"), 31 23 }; 32 24 33 25 const orchestrator = { 34 - output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management", { 35 - context: self.top || self.parent, 36 - }), 26 + output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management"), 37 27 }; 38 28 39 29 ////////////////////////////////////////////
+2 -2
src/pages/output/indexed-db/_applet.astro
··· 1 1 <script> 2 2 import * as IDB from "idb-keyval"; 3 - import { applets } from "@web-applets/sdk"; 4 3 5 4 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 5 + import { register } from "@scripts/applets/common"; 6 6 7 7 //////////////////////////////////////////// 8 8 // SETUP 9 9 //////////////////////////////////////////// 10 10 const IDB_PREFIX = "@applets/output/indexed-db"; 11 - const context = applets.register(); 11 + const context = register(); 12 12 13 13 //////////////////////////////////////////// 14 14 // ACTIONS
+2 -2
src/pages/output/native-fs/_applet.astro
··· 1 1 <script> 2 2 import * as IDB from "idb-keyval"; 3 - import { applets } from "@web-applets/sdk"; 4 3 import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter"; 5 4 6 5 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 6 + import { register } from "@scripts/applets/common"; 7 7 8 8 //////////////////////////////////////////// 9 9 // SETUP ··· 11 11 const IDB_PREFIX = "@applets/output/native-fs"; 12 12 const IDB_DEVICE_KEY = `${IDB_PREFIX}/device`; 13 13 14 - const context = applets.register(); 14 + const context = register(); 15 15 16 16 //////////////////////////////////////////// 17 17 // ACTIONS
+4 -1
src/pages/processor/metadata-fetcher/_applet.astro
··· 24 24 async function extract(args: { mimeType?: string; stream?: ReadableStream; urls?: Urls }) { 25 25 // Construct records 26 26 // TODO: Use other metadata lib as fallback: https://github.com/buzz/mediainfo.js 27 - const { stats, tags } = await musicMetadataTags(args, false); 27 + const { stats, tags } = await musicMetadataTags(args, false).catch(() => ({ 28 + stats: undefined, 29 + tags: undefined, 30 + })); 28 31 29 32 // Fin 30 33 return { stats, tags };
+279
src/scripts/applets/common.ts
··· 1 + import type { Applet, AppletEvent } from "@web-applets/sdk"; 2 + 3 + import QS from "query-string"; 4 + import { applets } from "@web-applets/sdk"; 5 + import { type ElementConfigurator, h } from "spellcaster/hyperscript.js"; 6 + import { effect, isSignal, Signal, signal } from "spellcaster/spellcaster.js"; 7 + import { xxh32 } from "xxh32"; 8 + 9 + //////////////////////////////////////////// 10 + // 🪟 Applet connector 11 + //////////////////////////////////////////// 12 + export async function applet<D>( 13 + src: string, 14 + opts: { 15 + addSlashSuffix?: boolean; 16 + applets?: Record<string, string>; 17 + container?: HTMLElement | Element; 18 + id?: string; 19 + setHeight?: boolean; 20 + } = {}, 21 + ): Promise<Applet<D>> { 22 + src = `${src}${ 23 + src.endsWith("/") 24 + ? "" 25 + : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true 26 + ? "/" 27 + : "" 28 + }`; 29 + 30 + if (opts.applets) { 31 + src = QS.stringifyUrl({ url: src, query: opts.applets }); 32 + } 33 + 34 + const existingFrame: HTMLIFrameElement | null = window.document.querySelector(`[src="${src}"]`); 35 + 36 + let frame; 37 + 38 + if (existingFrame) { 39 + frame = existingFrame; 40 + } else { 41 + frame = document.createElement("iframe"); 42 + frame.src = src; 43 + if (opts.id) frame.id = opts.id; 44 + 45 + if (opts.container) { 46 + opts.container.appendChild(frame); 47 + } else { 48 + window.document.body.appendChild(frame); 49 + } 50 + } 51 + 52 + if (frame.contentWindow === null) { 53 + throw new Error("iframe does not have a contentWindow"); 54 + } 55 + 56 + const applet = await applets.connect<D>(frame.contentWindow).catch((err) => { 57 + console.error("Error connecting to " + src, err); 58 + throw err; 59 + }); 60 + 61 + if (opts.setHeight) { 62 + applet.onresize = () => { 63 + frame.height = `${applet.height}px`; 64 + frame.classList.add("has-loaded"); 65 + }; 66 + } else { 67 + if (frame.contentDocument?.readyState === "complete") { 68 + frame.classList.add("has-loaded"); 69 + } 70 + 71 + frame.addEventListener("load", () => { 72 + frame.classList.add("has-loaded"); 73 + }); 74 + } 75 + 76 + return applet; 77 + } 78 + 79 + //////////////////////////////////////////// 80 + // 🪟 Applet registration 81 + //////////////////////////////////////////// 82 + export function register<DataType = any>() { 83 + const id = `${location.host}${location.pathname}`; 84 + const scope = applets.register<DataType>(); 85 + 86 + let isMainInstance = true; 87 + let waitingForPong = true; 88 + 89 + // One instance to rule them all 90 + // 91 + // Ping other instances to see if there are any. 92 + // As long as there aren't any, it is considered the main instance. 93 + // 94 + // Actions are performed on the main instance, 95 + // and data is replicated from main to the other instances. 96 + const channel = new BroadcastChannel(id); 97 + 98 + channel.addEventListener("message", async (event) => { 99 + if (event.data === "PING") { 100 + channel.postMessage("PONG"); 101 + } else if (event.data?.type === "data") { 102 + scope.data = context.codec.decode(event.data.data); 103 + } else if (waitingForPong && event.data === "PONG") { 104 + waitingForPong = false; 105 + isMainInstance = false; 106 + } else if (isMainInstance && event.data?.type === "action" && event.data?.actionId) { 107 + const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); 108 + channel.postMessage({ 109 + type: "actioncomplete", 110 + id: event.data.id, 111 + result, 112 + }); 113 + } 114 + }); 115 + 116 + setTimeout(() => (waitingForPong = false), 1000); 117 + 118 + channel.postMessage("PING"); 119 + 120 + scope.ondata = (event) => { 121 + if (isMainInstance) { 122 + channel.postMessage({ 123 + type: "data", 124 + data: context.codec.encode(event.data), 125 + }); 126 + } 127 + }; 128 + 129 + const context = { 130 + scope, 131 + 132 + get id() { 133 + return id; 134 + }, 135 + 136 + get data() { 137 + return scope.data; 138 + }, 139 + 140 + set data(data: DataType) { 141 + scope.data = data; 142 + }, 143 + 144 + codec: { 145 + decode: (data: any) => data as DataType, 146 + encode: (data: DataType) => data as any, 147 + }, 148 + 149 + isMainInstance() { 150 + return isMainInstance; 151 + }, 152 + 153 + setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => { 154 + const handler = (...args: any) => { 155 + if (isMainInstance) { 156 + return actionHandler(...args); 157 + } 158 + 159 + const actionMessage = { 160 + id: crypto.randomUUID(), 161 + type: "action", 162 + actionId, 163 + arguments: args, 164 + }; 165 + 166 + return new Promise((resolve) => { 167 + const actionCallback = (event: MessageEvent) => { 168 + if (event.data?.type === "actioncomplete" && event.data?.id === actionMessage.id) { 169 + channel.removeEventListener("message", actionCallback); 170 + resolve(event.data.result); 171 + } 172 + }; 173 + 174 + channel.addEventListener("message", actionCallback); 175 + channel.postMessage(actionMessage); 176 + }); 177 + }; 178 + 179 + scope.setActionHandler(actionId, handler); 180 + }, 181 + }; 182 + 183 + return context; 184 + } 185 + 186 + //////////////////////////////////////////// 187 + // 🔮 Reactive state management 188 + //////////////////////////////////////////// 189 + export function reactive<D, T>( 190 + applet: Applet<D>, 191 + dataFn: (data: D) => T, 192 + effectFn: (t: T) => void, 193 + ) { 194 + const [getter, setter] = signal(dataFn(applet.data)); 195 + 196 + effect(() => { 197 + effectFn(getter()); 198 + return undefined; 199 + }); 200 + 201 + applet.addEventListener("data", (event: AppletEvent) => { 202 + setter(dataFn(event.data)); 203 + }); 204 + } 205 + 206 + //////////////////////////////////////////// 207 + // 🛠️ 208 + //////////////////////////////////////////// 209 + export function addScope<O extends object>(astroScope: string, object: O): O { 210 + return { 211 + ...object, 212 + attrs: { 213 + ...((object as any).attrs || {}), 214 + [`data-astro-cid-${astroScope}`]: "", 215 + }, 216 + }; 217 + } 218 + 219 + export function appletScopePort() { 220 + let port: MessagePort | undefined; 221 + 222 + function connection(event: AppletEvent) { 223 + if (event.data?.type === "appletconnect") { 224 + window.removeEventListener("message", connection); 225 + port = (event as any).ports[0]; 226 + } 227 + } 228 + 229 + window.addEventListener("message", connection); 230 + 231 + return () => port; 232 + } 233 + 234 + export function comparable(value: unknown) { 235 + return xxh32(JSON.stringify(value)); 236 + } 237 + 238 + export function hs( 239 + tag: string, 240 + astroScope: string, 241 + props?: Record<string, unknown> | Signal<Record<string, unknown>>, 242 + configure?: ElementConfigurator, 243 + ) { 244 + const propsWithScope = 245 + props && isSignal(props) 246 + ? () => addScope(astroScope, props()) 247 + : addScope(astroScope, props || {}); 248 + 249 + return h(tag, propsWithScope, configure); 250 + } 251 + 252 + export function isPrimitive(test: unknown) { 253 + return test !== Object(test); 254 + } 255 + 256 + export function waitUntilAppletData<A>( 257 + applet: Applet<A>, 258 + dataFn: (a: A | undefined) => boolean, 259 + ): Promise<void> { 260 + return new Promise((resolve) => { 261 + if (dataFn(applet.data) === true) { 262 + resolve(); 263 + return; 264 + } 265 + 266 + const callback = (event: AppletEvent) => { 267 + if (dataFn(event.data) === true) { 268 + applet.removeEventListener("data", callback); 269 + resolve(); 270 + } 271 + }; 272 + 273 + applet.addEventListener("data", callback); 274 + }); 275 + } 276 + 277 + export function waitUntilAppletIsReady(applet: Applet): Promise<void> { 278 + return waitUntilAppletData(applet, (data) => !!data?.ready); 279 + }
-158
src/scripts/theme.ts
··· 1 - import type { Applet, AppletEvent } from "@web-applets/sdk"; 2 - 3 - import { applets } from "@web-applets/sdk"; 4 - import { type ElementConfigurator, h } from "spellcaster/hyperscript.js"; 5 - import { effect, isSignal, Signal, signal } from "spellcaster/spellcaster.js"; 6 - import { xxh32 } from "xxh32"; 7 - 8 - //////////////////////////////////////////// 9 - // 🪟 Applet initialiser 10 - //////////////////////////////////////////// 11 - export async function applet<D>( 12 - src: string, 13 - opts: { 14 - addSlashSuffix?: boolean; 15 - context?: Window; 16 - container?: HTMLElement | Element; 17 - id?: string; 18 - setHeight?: boolean; 19 - } = {}, 20 - ): Promise<Applet<D>> { 21 - src = `${src}${ 22 - src.endsWith("/") 23 - ? "" 24 - : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true 25 - ? "/" 26 - : "" 27 - }`; 28 - 29 - const existingFrame: HTMLIFrameElement | null = (opts.context || window).document.querySelector( 30 - `[src="${src}"]`, 31 - ); 32 - 33 - let frame; 34 - 35 - if (existingFrame) { 36 - frame = existingFrame; 37 - } else { 38 - frame = document.createElement("iframe"); 39 - frame.src = src; 40 - if (opts.id) frame.id = opts.id; 41 - 42 - if (opts.container) { 43 - opts.container.appendChild(frame); 44 - } else { 45 - (opts.context || window).document.body.appendChild(frame); 46 - } 47 - } 48 - 49 - if (frame.contentWindow === null) { 50 - throw new Error("iframe does not have a contentWindow"); 51 - } 52 - 53 - const applet = await applets 54 - .connect<D>(frame.contentWindow, { 55 - context: opts.context, 56 - }) 57 - .catch((err) => { 58 - console.error("Error connecting to " + src, err); 59 - throw err; 60 - }); 61 - 62 - if (opts.setHeight) { 63 - applet.onresize = () => { 64 - frame.height = `${applet.height}px`; 65 - frame.classList.add("has-loaded"); 66 - }; 67 - } else { 68 - if (frame.contentDocument?.readyState === "complete") { 69 - frame.classList.add("has-loaded"); 70 - } 71 - 72 - frame.addEventListener("load", () => { 73 - frame.classList.add("has-loaded"); 74 - }); 75 - } 76 - 77 - return applet; 78 - } 79 - 80 - //////////////////////////////////////////// 81 - // 🔮 Reactive state management 82 - //////////////////////////////////////////// 83 - export function reactive<D, T>( 84 - applet: Applet<D>, 85 - dataFn: (data: D) => T, 86 - effectFn: (t: T) => void, 87 - ) { 88 - const [getter, setter] = signal(dataFn(applet.data)); 89 - 90 - effect(() => { 91 - effectFn(getter()); 92 - return undefined; 93 - }); 94 - 95 - applet.addEventListener("data", (event: AppletEvent) => { 96 - setter(dataFn(event.data)); 97 - }); 98 - } 99 - 100 - //////////////////////////////////////////// 101 - // 🛠️ 102 - //////////////////////////////////////////// 103 - export function addScope<O extends object>(astroScope: string, object: O): O { 104 - return { 105 - ...object, 106 - attrs: { 107 - ...((object as any).attrs || {}), 108 - [`data-astro-cid-${astroScope}`]: "", 109 - }, 110 - }; 111 - } 112 - 113 - export function comparable(value: unknown) { 114 - return xxh32(JSON.stringify(value)); 115 - } 116 - 117 - export function hs( 118 - tag: string, 119 - astroScope: string, 120 - props?: Record<string, unknown> | Signal<Record<string, unknown>>, 121 - configure?: ElementConfigurator, 122 - ) { 123 - const propsWithScope = 124 - props && isSignal(props) 125 - ? () => addScope(astroScope, props()) 126 - : addScope(astroScope, props || {}); 127 - 128 - return h(tag, propsWithScope, configure); 129 - } 130 - 131 - export function isPrimitive(test: unknown) { 132 - return test !== Object(test); 133 - } 134 - 135 - export function waitUntilAppletData<A>( 136 - applet: Applet<A>, 137 - dataFn: (a: A | undefined) => boolean, 138 - ): Promise<void> { 139 - return new Promise((resolve) => { 140 - if (dataFn(applet.data) === true) { 141 - resolve(); 142 - return; 143 - } 144 - 145 - const callback = (event: AppletEvent) => { 146 - if (dataFn(event.data) === true) { 147 - applet.removeEventListener("data", callback); 148 - resolve(); 149 - } 150 - }; 151 - 152 - applet.addEventListener("data", callback); 153 - }); 154 - } 155 - 156 - export function waitUntilAppletIsReady(applet: Applet): Promise<void> { 157 - return waitUntilAppletData(applet, (data) => !!data?.ready); 158 - }
+6 -8
src/scripts/themes/pilot/index.ts
··· 1 - import type { Output, Track } from "@applets/core/types.d.ts"; 2 - import { applet, reactive } from "../../theme.ts"; 1 + import { applet, reactive } from "@scripts/applets/common"; 3 2 4 3 //////////////////////////////////////////// 5 4 // 🎨 Styles ··· 14 13 15 14 import type * as AudioUI from "@applets/themes/pilot/ui/audio/types.d.ts"; 16 15 17 - const _configurator = { 18 - input: await applet("../../configurator/input"), 19 - output: await applet("../../configurator/output"), 20 - }; 16 + // TODO: Themes 21 17 22 18 const engine = { 23 19 audio: await applet<AudioEngine.State>("../../engine/audio"), ··· 25 21 }; 26 22 27 23 const _orchestrator = { 28 - input: await applet<Output>("../../orchestrator/input-cache"), 29 - output: await applet<Output>("../../orchestrator/output-management"), 24 + input: await applet("../../orchestrator/input-cache", { 25 + applets: { input: "todo" }, 26 + }), 27 + output: await applet("../../orchestrator/output-management"), 30 28 queue: await applet("../../orchestrator/single-queue"), 31 29 }; 32 30
+1 -1
src/scripts/themes/webamp/index.ts
··· 2 2 import { URLTrack } from "webamp"; 3 3 4 4 import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 5 - import { applet } from "../../theme.ts"; 5 + import { applet } from "@scripts/applets/common"; 6 6 7 7 //////////////////////////////////////////// 8 8 // 🎨 Styles