Experiment to rebuild Diffuse using web applets.
0

Configure Feed

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

feat: constituents + fix initial data loading race conditions

+233 -122
+16 -12
src/pages/configurator/output/_applet.astro
··· 115 115 (async () => { 116 116 const conn = await connection(method); 117 117 dataHandler(conn.data); 118 - conn.addEventListener("data", (event: AppletEvent) => dataHandler(event.data)); 118 + conn.addEventListener("data", dateEventHandler); 119 119 })(); 120 120 }); 121 + 122 + function dateEventHandler(event: AppletEvent) { 123 + return dataHandler(event.data); 124 + } 121 125 122 126 function dataHandler(data: ManagedOutput) { 123 127 context.data = data; ··· 153 157 154 158 async function unmountStorageMethod(method: Method) { 155 159 const conn = await connection(method); 156 - conn.removeEventListener("data", dataEventHandler); 160 + conn.removeEventListener("data", dateEventHandler); 157 161 await conn.sendAction("unmount"); 158 162 } 163 + 164 + //////////////////////////////////////////// 165 + // ACTIONS 166 + //////////////////////////////////////////// 167 + const tracks = async (...args: unknown[]) => { 168 + const conn = await connection(active()); 169 + await conn.sendAction("tracks", ...args); 170 + }; 171 + 172 + context.setActionHandler("tracks", tracks); 159 173 160 174 //////////////////////////////////////////// 161 175 // UI / LIST ··· 349 363 350 364 // Add to DOM 351 365 document.querySelector("main")?.appendChild(Modal()); 352 - 353 - //////////////////////////////////////////// 354 - // ACTIONS 355 - //////////////////////////////////////////// 356 - const tracks = async (...args: unknown[]) => { 357 - const conn = await connection(active()); 358 - await conn.sendAction("tracks", ...args); 359 - }; 360 - 361 - context.setActionHandler("tracks", tracks); 362 366 </script>
+2
src/pages/engine/queue/_applet.astro
··· 35 35 const past = context.data.now ? [...context.data.past, context.data.now] : context.data.past; 36 36 37 37 update({ past, now, future }); 38 + 39 + return now; 38 40 } 39 41 </script>
+41 -22
src/pages/index.astro
··· 16 16 17 17 // Themes 18 18 const themes = [ 19 - { url: "themes/pilot/", title: "Pilot" }, 19 + { url: "themes/desktop/", title: "(WIP) Desktop" }, 20 + { url: "themes/pilot/", title: "(WIP) Pilot" }, 20 21 { url: "themes/webamp/", title: "Webamp" }, 21 22 ]; 22 23 23 24 // Abstractions 24 25 // TODO 26 + 27 + // Constituents 28 + const constituents = [{ url: "constituents/desktop/", title: "(WIP) Desktop" }]; 25 29 26 30 // Applets 27 31 const configurators = [ ··· 82 86 </p> 83 87 </header> 84 88 <main> 85 - <!-- THEMES --> 86 - <section> 87 - <h2 id="themes">Themes</h2> 89 + <div class="columns"> 90 + <!-- THEMES --> 91 + <section> 92 + <h2 id="themes">Themes</h2> 93 + 94 + <p> 95 + Themes are “applet compositions” and provide a traditional browser web application way of 96 + using them. Each theme is unique, not just a skin (eg. not like winamp skins). 97 + </p> 98 + 99 + <p> 100 + For example, most themes here will limit the currently playing audio tracks to one item, 101 + but you might as well create a DJ theme that can play multiple items at the same time. 102 + </p> 103 + 104 + <List items={themes} /> 105 + </section> 88 106 89 - <p> 90 - Themes are “applet compositions” and provide a traditional browser web application way of 91 - using them. Each theme is unique, not just a skin (eg. not like winamp skins). 92 - </p> 107 + <!-- ABSTRACTIONS --> 108 + <section> 109 + <h2 id="abstractions">Abstractions</h2> 110 + 111 + <p> 112 + These are applet configurations that enable certain use cases outside the traditional web 113 + app experience. Just like themes, these include various assumptions of how certain parts 114 + of the system should interact. 115 + </p> 93 116 94 - <p> 95 - For example, most themes here will limit the currently playing audio tracks to one item, but 96 - you might as well create a DJ theme that can play multiple items at the same time. 97 - </p> 117 + <p><em>TODO: Enable intelligent user (ai) agent use-case.</em></p> 98 118 99 - <List items={themes} /> 100 - </section> 119 + <List items={[]} /> 120 + </section> 121 + </div> 101 122 102 - <!-- ABSTRACTIONS --> 123 + <!-- CONSTITUENTS --> 103 124 <section> 104 - <h2 id="abstractions">Abstractions</h2> 125 + <h2 id="constituents">Constituents</h2> 105 126 106 127 <p> 107 - These are applet configurations that enable certain use cases outside the traditional web 108 - app experience. Just like themes, these include various assumptions of how certain parts of 109 - the system should interact. 128 + Constituents are UI applets that are used in themes and abstractions. These are organised 129 + per theme or abstraction, but that doesn't mean they are restricted to that theme or 130 + abstraction, you can mix and match as you like. 110 131 </p> 111 132 112 - <p><em>TODO: Enable intelligent user (ai) agent use-case.</em></p> 113 - 114 - <List items={[]} /> 133 + <List items={constituents} /> 115 134 </section> 116 135 117 136 <!-- APPLETS -->
+10 -9
src/pages/orchestrator/single-queue/_applet.astro
··· 1 1 <script> 2 2 import type { ManagedOutput, ResolvedUri, Track } from "@applets/core/types.d.ts"; 3 - import { applet, reactive, register } from "@scripts/applets/common"; 3 + import { applet, reactive, register, waitUntilAppletData } from "@scripts/applets/common"; 4 4 5 5 //////////////////////////////////////////// 6 6 // SETUP ··· 73 73 // ⚙️ [Connections → Engines] 74 74 // 🚏 QUEUE 75 75 //////////////////////////////////////////// 76 - 77 76 reactive( 78 77 engine.queue, 79 78 (data) => data.now?.id, ··· 110 109 // 🎻 [Connections → Configurators] 111 110 // 📦 OUTPUT 112 111 //////////////////////////////////////////// 113 - reactive( 114 - configurator.output, 115 - (data) => data.tracks.cacheId, 116 - () => { 117 - fill(configurator.output.data.tracks.collection); 118 - }, 119 - ); 112 + waitUntilAppletData(configurator.output, (d) => d?.tracks.state === "loaded").then(() => { 113 + reactive( 114 + configurator.output, 115 + (data) => data.tracks.cacheId, 116 + () => { 117 + fill(configurator.output.data.tracks.collection); 118 + }, 119 + ); 120 + }); 120 121 </script>
+18 -29
src/pages/output/indexed-db/_applet.astro
··· 3 3 4 4 import type { ManagedOutput, Track } from "@applets/core/types.d.ts"; 5 5 import { jsonDecode, jsonEncode, register } from "@scripts/applets/common"; 6 - import { INITIAL_MANAGED_OUTPUT } from "@scripts/output/common"; 6 + import { INITIAL_MANAGED_OUTPUT, outputManager } from "@scripts/output/common"; 7 7 8 8 //////////////////////////////////////////// 9 9 // SETUP ··· 13 13 const context = register<ManagedOutput>(); 14 14 context.data = INITIAL_MANAGED_OUTPUT; 15 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 - }); 16 + // Output manager 17 + const manager = outputManager({ 18 + context, 19 + tracks: { 20 + async get() { 21 + const encoded = await get({ name: "tracks.json" }); 22 + if (!encoded) return []; 23 + return jsonDecode<Track[]>(encoded); 24 + }, 25 + 26 + async put(tracks: Track[]) { 27 + const data = jsonEncode(tracks); 28 + await put({ name: "tracks.json", data }); 29 + }, 30 + }, 31 + }); 29 32 30 33 //////////////////////////////////////////// 31 34 // ACTIONS 32 35 //////////////////////////////////////////// 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 - } 46 - 47 36 async function mount() {} 48 37 async function unmount() {} 49 38 50 - context.setActionHandler("tracks", tracks); 39 + context.setActionHandler("tracks", manager.tracks); 51 40 52 41 context.setActionHandler("mount", mount); 53 42 context.setActionHandler("unmount", unmount);
+23 -32
src/pages/output/native-fs/_applet.astro
··· 4 4 5 5 import type { ManagedOutput, Track } from "@applets/core/types"; 6 6 import { jsonDecode, jsonEncode, register } from "@scripts/applets/common"; 7 - import { INITIAL_MANAGED_OUTPUT } from "@scripts/output/common"; 7 + import { INITIAL_MANAGED_OUTPUT, outputManager } from "@scripts/output/common"; 8 8 9 9 //////////////////////////////////////////// 10 10 // SETUP ··· 15 15 const context = register<ManagedOutput>(); 16 16 context.data = INITIAL_MANAGED_OUTPUT; 17 17 18 - // Load initial data 19 - if (context.isMainInstance() && (await IDB.get(IDB_DEVICE_KEY))) loadInitialData(); 18 + // Output manager 19 + const manager = outputManager({ 20 + context, 20 21 21 - async function loadInitialData() { 22 - return tracks().then((collection) => { 23 - context.data = { 24 - ...context.data, 25 - tracks: { 26 - ...context.data.tracks, 27 - cacheId: crypto.randomUUID(), 28 - state: "loaded", 29 - collection, 30 - }, 31 - }; 32 - }); 33 - } 22 + async init() { 23 + return !!(await IDB.get(IDB_DEVICE_KEY)); 24 + }, 25 + 26 + tracks: { 27 + async get() { 28 + const encoded = await get({ name: "tracks.json" }); 29 + if (!encoded) return []; 30 + return jsonDecode<Track[]>(encoded); 31 + }, 32 + 33 + async put(tracks: Track[]) { 34 + const data = jsonEncode(tracks); 35 + await put({ name: "tracks.json", data }); 36 + }, 37 + }, 38 + }); 34 39 35 40 //////////////////////////////////////////// 36 41 // ACTIONS 37 42 //////////////////////////////////////////// 38 - async function tracks(): Promise<Track[]>; 39 - async function tracks(tracks: Track[]): Promise<void>; 40 - async function tracks(tracks?: Track[]): Promise<Track[] | void> { 41 - if (tracks) { 42 - const data = jsonEncode(tracks); 43 - await put({ name: "tracks.json", data }); 44 - return; 45 - } else { 46 - const encoded = await get({ name: "tracks.json" }); 47 - if (!encoded) return []; 48 - return jsonDecode<Track[]>(encoded); 49 - } 50 - } 51 - 52 43 async function mount() { 53 44 if (!("showDirectoryPicker" in self)) { 54 45 throw new Error("[user] The File System Access API is not supported on this platform."); ··· 59 50 const directoryHandle = await self.showDirectoryPicker(); 60 51 await IDB.set(IDB_DEVICE_KEY, directoryHandle); 61 52 await directoryHandle.requestPermission({ mode: "readwrite" }); 62 - loadInitialData(); 53 + await manager.load(); 63 54 } 64 55 } 65 56 ··· 69 60 } catch (err) {} 70 61 } 71 62 72 - context.setActionHandler("tracks", tracks); 63 + context.setActionHandler("tracks", manager.tracks); 73 64 74 65 context.setActionHandler("mount", mount); 75 66 context.setActionHandler("unmount", unmount);
+1 -1
src/pages/themes/pilot/index.astro
··· 9 9 <div class="filler" style="flex: 1;"></div> 10 10 11 11 <!-- Theme applets --> 12 - <iframe id="applet__ui__audio" src="ui/audio/"></iframe> 12 + <iframe id="applet__ui__audio" src="../../constituents/pilot/audio/"></iframe> 13 13 </Page>
+1
src/pages/themes/pilot/ui/audio/_applet.astro src/pages/constituents/pilot/audio/_applet.astro
··· 110 110 // Actions 111 111 //////////////////////////////////////////// 112 112 context.setActionHandler("modifyIsPlaying", (isPlaying: boolean) => { 113 + // NOTE: Doesn't trigger a `data` event 113 114 context.data.isPlaying = isPlaying; 114 115 render(); 115 116 });
+1 -1
src/pages/themes/pilot/ui/audio/_manifest.json src/pages/constituents/pilot/audio/_manifest.json
··· 1 1 { 2 - "name": "diffuse/themes/pilot/ui/audio", 2 + "name": "diffuse/constituents/pilot/audio", 3 3 "title": "", 4 4 "entrypoint": "index.html", 5 5 "actions": {
src/pages/themes/pilot/ui/audio/index.astro src/pages/constituents/pilot/audio/index.astro
src/pages/themes/pilot/ui/audio/types.d.ts src/pages/constituents/pilot/audio/types.d.ts
+47 -8
src/scripts/applets/common.ts
··· 1 - import type { Applet, AppletEvent } from "@web-applets/sdk"; 1 + import type { Applet, AppletEvent, AppletScope } from "@web-applets/sdk"; 2 2 3 3 import QS from "query-string"; 4 4 import { applets } from "@web-applets/sdk"; ··· 80 80 //////////////////////////////////////////// 81 81 // 🪟 Applet registration 82 82 //////////////////////////////////////////// 83 + export type BroadcastedApplet<T> = { 84 + scope: AppletScope<T>; 85 + 86 + settled(): Promise<void>; 87 + 88 + get id(): string; 89 + set data(data: T); 90 + 91 + codec: { 92 + decode(data: any): T; 93 + encode(data: T): any; 94 + }; 95 + 96 + isMainInstance(): boolean; 97 + setActionHandler<H extends Function>(actionId: string, actionHandler: H): void; 98 + }; 99 + 83 100 export function register<DataType = any>() { 84 101 const id = `${location.host}${location.pathname}`; 85 102 const scope = applets.register<DataType>(); 86 103 87 104 let isMainInstance = true; 88 - let waitingForPong = true; 89 105 90 106 // One instance to rule them all 91 107 // ··· 101 117 channel.postMessage("PONG"); 102 118 } else if (event.data?.type === "data") { 103 119 scope.data = context.codec.decode(event.data.data); 104 - } else if (waitingForPong && event.data === "PONG") { 105 - waitingForPong = false; 120 + } else if (event.data === "PONG") { 106 121 isMainInstance = false; 107 122 } else if (isMainInstance && event.data?.type === "action" && event.data?.actionId) { 108 123 const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); ··· 114 129 } 115 130 }); 116 131 117 - setTimeout(() => (waitingForPong = false), 1000); 132 + // Promise that fullfills whenever it figures out its the main instance or not. 133 + const promise = new Promise<void>((resolve) => { 134 + const id = setTimeout(() => { 135 + channel.removeEventListener("message", handler); 136 + resolve(undefined); 137 + }, 1000); 138 + 139 + const handler = (event: MessageEvent) => { 140 + if (event.data === "pong" || event.data === "ping") { 141 + clearTimeout(id); 142 + channel.removeEventListener("message", handler); 143 + resolve(undefined); 144 + } 145 + }; 146 + 147 + channel.addEventListener("message", handler); 148 + }); 118 149 150 + // Send out ping 119 151 channel.postMessage("PING"); 120 152 153 + // If the data on the main instance changes, 154 + // pass it on to other instances. 121 155 scope.ondata = (event) => { 122 156 if (isMainInstance) { 123 157 channel.postMessage({ ··· 127 161 } 128 162 }; 129 163 130 - const context = { 164 + // Context 165 + const context: BroadcastedApplet<DataType> = { 131 166 scope, 167 + 168 + settled() { 169 + return promise; 170 + }, 132 171 133 172 get id() { 134 173 return id; ··· 190 229 export function reactive<D, T>( 191 230 applet: Applet<D>, 192 231 dataFn: (data: D) => T, 193 - effectFn: (t: T) => void, 232 + effectFn: (t: T, setter: (t: T) => void) => void, 194 233 ) { 195 234 const [getter, setter] = signal(dataFn(applet.data)); 196 235 197 236 effect(() => { 198 - effectFn(getter()); 237 + effectFn(getter(), setter); 199 238 return undefined; 200 239 }); 201 240
+61 -1
src/scripts/output/common.ts
··· 1 - import type { ManagedOutput } from "@applets/core/types"; 1 + import type { ManagedOutput, Track } from "@applets/core/types"; 2 + import { BroadcastedApplet } from "@scripts/applets/common"; 2 3 3 4 export const INITIAL_MANAGED_OUTPUT: ManagedOutput = { 4 5 tracks: { ··· 7 8 collection: [], 8 9 }, 9 10 }; 11 + 12 + export function outputManager<DataType>(args: { 13 + context: BroadcastedApplet<DataType>; 14 + /* Indicate if the initial data loader may proceed. */ 15 + init?: () => Promise<boolean>; 16 + tracks: { 17 + get(): Promise<Track[]>; 18 + put(tracks: Track[]): Promise<void>; 19 + }; 20 + }) { 21 + const { context } = args; 22 + 23 + // Initial data loader 24 + async function load() { 25 + await context.settled(); 26 + 27 + if (!context.isMainInstance()) return; 28 + if (args.init && (await args.init()) === false) return; 29 + 30 + const collection = await tracks(); 31 + 32 + context.data = { 33 + ...context.data, 34 + tracks: { 35 + cacheId: crypto.randomUUID(), 36 + state: "loaded", 37 + collection, 38 + }, 39 + }; 40 + } 41 + 42 + load(); 43 + 44 + async function tracks(): Promise<Track[]>; 45 + async function tracks(tracks: Track[]): Promise<void>; 46 + async function tracks(tracks?: Track[]): Promise<Track[] | void> { 47 + if (tracks) { 48 + // PUT 49 + context.data = { 50 + ...context.data, 51 + tracks: { 52 + cacheId: crypto.randomUUID(), 53 + state: "loaded", 54 + collection: tracks, 55 + }, 56 + }; 57 + 58 + await args.tracks.put(tracks); 59 + } else { 60 + // GET 61 + return await args.tracks.get(); 62 + } 63 + } 64 + 65 + return { 66 + load, 67 + tracks, 68 + }; 69 + }
+12 -7
src/scripts/themes/pilot/index.ts
··· 11 11 import type * as AudioEngine from "@applets/engine/audio/types.d.ts"; 12 12 import type * as QueueEngine from "@applets/engine/queue/types.d.ts"; 13 13 14 - import type * as AudioUI from "@applets/themes/pilot/ui/audio/types.d.ts"; 14 + import type * as AudioUI from "@applets/constituents/pilot/audio/types.d.ts"; 15 15 16 16 // TODO: Themes 17 17 ··· 21 21 }; 22 22 23 23 const _orchestrator = { 24 - input: await applet("../../orchestrator/input-cache", { 25 - applets: { input: "todo" }, 26 - }), 24 + input: await applet("../../orchestrator/input-cache"), 27 25 queue: await applet("../../orchestrator/single-queue"), 28 26 }; 29 27 30 28 const ui = { 31 - audio: await applet<AudioUI.State>("ui/audio", { setHeight: true }), 29 + audio: await applet<AudioUI.State>("../../constituents/pilot/audio/", { setHeight: true }), 32 30 }; 33 31 34 32 //////////////////////////////////////////// ··· 59 57 reactive( 60 58 ui.audio, 61 59 (data) => data.isPlaying, 62 - (isPlaying) => { 60 + async (isPlaying, setter) => { 63 61 const trackId = engine.queue.data.now?.id; 64 62 const volume = 0.5; // TODO 65 63 66 64 // Automatically start playing something if nothing is playing yet. 67 65 if (!trackId) { 68 - if (isPlaying) engine.queue.sendAction("shift"); 66 + if (isPlaying) { 67 + const now = await engine.queue.sendAction("shift"); 68 + if (!now) { 69 + console.warn("No tracks available yet, try again later."); 70 + await ui.audio.sendAction("modifyIsPlaying", false); 71 + setter(false); 72 + } 73 + } 69 74 return; 70 75 } 71 76