Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

feat: Add a simple queue

+230 -63
+18 -18
src/applets/engine/audio/applet.astro
··· 18 18 19 19 // Initial state 20 20 context.data = { 21 - nowPlaying: {}, 21 + items: {}, 22 22 }; 23 23 24 24 // State helpers ··· 26 26 context.data = { ...context.data, ...partial }; 27 27 } 28 28 29 - function updateNowPlaying(trackId: string, partial: Partial<TrackState>): void { 29 + function updateItems(trackId: string, partial: Partial<TrackState>): void { 30 30 update({ 31 31 ...context.data, 32 - nowPlaying: { 33 - ...context.data.nowPlaying, 34 - [trackId]: { ...context.data.nowPlaying[trackId], ...partial }, 32 + items: { 33 + ...context.data.items, 34 + [trackId]: { ...context.data.items[trackId], ...partial }, 35 35 }, 36 36 }); 37 37 } ··· 73 73 audio.removeAttribute("data-did-preload"); 74 74 } 75 75 76 - updateNowPlaying(audio.id, { isPlaying: true }); 76 + updateItems(audio.id, { isPlaying: true }); 77 77 78 78 promise.catch((e) => { 79 79 if (!audio.isConnected) 80 80 return; /* The node was removed from the DOM, we can ignore this error */ 81 81 const err = "Couldn't play audio automatically. Please resume playback manually."; 82 82 console.error(err, e); 83 - updateNowPlaying(trackId, { isPlaying: false }); 83 + updateItems(trackId, { isPlaying: false }); 84 84 }); 85 85 }); 86 86 } ··· 152 152 }, Promise.resolve()); 153 153 154 154 // Now playing state 155 - const nowPlaying = tracks.reduce((acc, track) => { 155 + const items = tracks.reduce((acc, track) => { 156 156 return { 157 157 ...acc, 158 - [track.id]: context.data.nowPlaying[track.id] || { 158 + [track.id]: context.data.items[track.id] || { 159 159 duration: 0, 160 160 id: track.id, 161 161 loadingState: "loading", ··· 166 166 }; 167 167 }, {}); 168 168 169 - update({ nowPlaying }); 169 + update({ items }); 170 170 } 171 171 172 172 export async function createElement(track: Track) { ··· 225 225 const audio = event.target as HTMLAudioElement; 226 226 227 227 if (!isNaN(audio.duration)) { 228 - updateNowPlaying(audio.id, { duration: audio.duration }); 228 + updateItems(audio.id, { duration: audio.duration }); 229 229 } 230 230 } 231 231 232 232 function endedEvent(event: Event) { 233 233 const audio = event.target as HTMLAudioElement; 234 234 audio.currentTime = 0; 235 - // TODO 235 + updateItems(audio.id, { hasEnded: true, isPlaying: false }); 236 236 } 237 237 238 238 function errorEvent(event: Event) { 239 239 const audio = event.target as HTMLAudioElement; 240 240 const code = audio.error?.code || 0; 241 - updateNowPlaying(audio.id, { loadingState: { error: { code } } }); 241 + updateItems(audio.id, { loadingState: { error: { code } } }); 242 242 } 243 243 244 244 function pauseEvent(event: Event) { 245 245 const audio = event.target as HTMLAudioElement; 246 - updateNowPlaying(audio.id, { isPlaying: false }); 246 + updateItems(audio.id, { isPlaying: false }); 247 247 } 248 248 249 249 function playEvent(event: Event) { 250 250 const audio = event.target as HTMLAudioElement; 251 - updateNowPlaying(audio.id, { isPlaying: true }); 251 + updateItems(audio.id, { isPlaying: true }); 252 252 253 253 // In case audio was preloaded: 254 254 if (audio.readyState === 4) finishedLoading(event); ··· 261 261 function timeupdateEvent(event: Event) { 262 262 const audio = event.target as HTMLAudioElement; 263 263 264 - updateNowPlaying(audio.id, { 264 + updateItems(audio.id, { 265 265 progress: 266 266 isNaN(audio.duration) || audio.duration === 0 ? 0 : audio.currentTime / audio.duration, 267 267 }); ··· 277 277 278 278 function finishedLoading(event: Event) { 279 279 const audio = event.target as HTMLAudioElement; 280 - updateNowPlaying(audio.id, { loadingState: "loaded" }); 280 + updateItems(audio.id, { loadingState: "loaded" }); 281 281 } 282 282 283 283 function initiateLoading(event: Event) { 284 284 const audio = event.target as HTMLAudioElement; 285 - if (audio.readyState < 4) updateNowPlaying(audio.id, { loadingState: "loading" }); 285 + if (audio.readyState < 4) updateItems(audio.id, { loadingState: "loading" }); 286 286 } 287 287 288 288 function withActiveAudioNode(fn: (node: HTMLAudioElement) => void): void {
+2 -1
src/applets/engine/audio/types.ts
··· 1 1 export interface State { 2 - nowPlaying: Record<string, TrackState>; 2 + items: Record<string, TrackState>; 3 3 } 4 4 5 5 export interface Track { ··· 13 13 export interface TrackState { 14 14 duration: number; 15 15 id: string; 16 + hasEnded: boolean; 16 17 loadingState: "initialisation" | "loading" | "loaded" | { 17 18 error: { code: number }; 18 19 };
+41
src/applets/engine/queue/applet.astro
··· 1 + <div id="container"></div> 2 + 3 + <script> 4 + import { applets } from "@web-applets/sdk"; 5 + import { QueueItem, State } from "./types"; 6 + 7 + //////////////////////////////////////////// 8 + // SETUP 9 + //////////////////////////////////////////// 10 + const context = applets.register<State>(); 11 + 12 + // Initial state 13 + context.data = { 14 + past: [], 15 + now: null, 16 + future: [], 17 + }; 18 + 19 + // State helpers 20 + function update(partial: Partial<State>): void { 21 + context.data = { ...context.data, ...partial }; 22 + } 23 + 24 + //////////////////////////////////////////// 25 + // ACTIONS 26 + //////////////////////////////////////////// 27 + context.setActionHandler("add", add); 28 + context.setActionHandler("shift", shift); 29 + 30 + function add(items: QueueItem[]) { 31 + update({ future: [...context.data.future, ...items] }); 32 + } 33 + 34 + function shift() { 35 + const now = context.data.future[0] || null; 36 + const future = context.data.future.slice(1); 37 + const past = context.data.now ? [...context.data.past, context.data.now] : context.data.past; 38 + 39 + update({ past, now, future }); 40 + } 41 + </script>
+31
src/applets/engine/queue/manifest.json
··· 1 + { 2 + "name": "diffuse/engine/queue", 3 + "title": "Diffuse Queue", 4 + "entrypoint": "index.html", 5 + "actions": { 6 + "add": { 7 + "title": "Add", 8 + "description": "Add items to the queue.", 9 + "params_schema": { 10 + "type": "array", 11 + "items": { 12 + "anyOf": [ 13 + { 14 + "type": "object", 15 + "properties": { 16 + "expiresAt": { "type": "number" }, 17 + "id": { "type": "string" }, 18 + "url": { "type": "string" } 19 + }, 20 + "required": ["expiresAt", "id", "url"] 21 + } 22 + ] 23 + } 24 + } 25 + }, 26 + "shift": { 27 + "title": "Shift", 28 + "description": "Shift the queue, picking the first item from the up next array and putting the currently playing item into the history list." 29 + } 30 + } 31 + }
+11
src/applets/engine/queue/types.ts
··· 1 + export interface QueueItem { 2 + expiresAt: number; 3 + id: string; 4 + url: string; 5 + } 6 + 7 + export interface State { 8 + past: QueueItem[]; 9 + now: QueueItem | null; 10 + future: QueueItem[]; 11 + }
+2 -2
src/applets/themes/pilot/ui/audio/applet.astro
··· 106 106 //////////////////////////////////////////// 107 107 // Actions 108 108 //////////////////////////////////////////// 109 - context.setActionHandler("set_is_playing", (isPlaying: boolean) => { 109 + context.setActionHandler("modifyIsPlaying", (isPlaying: boolean) => { 110 110 context.data.isPlaying = isPlaying; 111 111 render(); 112 112 }); 113 113 114 - context.setActionHandler("set_progress", (progress: number) => { 114 + context.setActionHandler("modifyProgress", (progress: number) => { 115 115 const p = isNaN(progress) || !isFinite(progress) ? 0 : Math.min(Math.max(progress, 0), 1); 116 116 document.body.querySelector("progress").value = p * 100; 117 117 });
+2 -2
src/applets/themes/pilot/ui/audio/manifest.json
··· 2 2 "name": "diffuse/ui/audio", 3 3 "entrypoint": "index.html", 4 4 "actions": { 5 - "set_is_playing": { 5 + "modifyIsPlaying": { 6 6 "title": "Set is-playing state", 7 7 "description": "Indicate if audio is playing or not.", 8 8 "params_schema": { 9 9 "type": "boolean" 10 10 } 11 11 }, 12 - "set_progress": { 12 + "modifyProgress": { 13 13 "title": "Set progress", 14 14 "description": "Indicate how far the audio has progressed.", 15 15 "params_schema": {
+3 -3
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/" frameborder="0"></iframe> 12 + <iframe id="applet__ui__audio" src="ui/audio/"></iframe> 13 13 14 14 <!-- Other applets --> 15 - <iframe id="applet__engine__audio" src="../../engine/audio/" frameborder="0" height="1" width="1" 16 - ></iframe> 15 + <iframe id="applet__engine__audio" src="../../engine/audio/"></iframe> 16 + <iframe id="applet__engine__queue" src="../../engine/queue/"></iframe> 17 17 </Page>
+104 -37
src/scripts/themes/pilot/index.ts
··· 10 10 //////////////////////////////////////////// 11 11 import type * as AudioEngine from "../../../applets/engine/audio/types.ts"; 12 12 import type * as AudioUI from "../../../applets/themes/pilot/ui/audio/types.ts"; 13 + import type * as QueueEngine from "../../../applets/engine/queue/types.ts"; 13 14 14 15 const engine = { 15 16 audio: await applet<AudioEngine.State>("../../engine/audio"), 17 + queue: await applet<AudioEngine.State>("../../engine/queue"), 16 18 }; 17 19 18 20 const ui = { 19 21 audio: await applet<AudioUI.State>("ui/audio", { setHeight: true }), 20 22 }; 21 23 24 + // NOTE: 25 + // Themes are just limited imaginations. 26 + // 27 + // For example, this theme limits the currently playing audio to one item. 28 + // But you might as well create a DJ "theme" that plays multiple items at 29 + // the same time. With that in mind, you could abstract things here that are 30 + // reused across multiple themes. But it might also be good to keep the code 31 + // repetition because there are some defaults hidden in here. 32 + 22 33 //////////////////////////////////////////// 23 - // ▒▒ [Connections ⚡] 24 - // ▒▒ Audio UI → Audio Engine 34 + // ⚙️ [Connections → Engines] 35 + // 🔉 AUDIO 36 + //////////////////////////////////////////// 37 + 38 + // NOTE: 39 + // These could probably be optimised, but it works. 40 + // 41 + 42 + reactive( 43 + engine.audio, 44 + (data: AudioEngine.State) => 45 + data.items[engine.queue.data.now?.id]?.isPlaying ?? false, 46 + (isPlaying) => ui.audio.sendAction("modifyIsPlaying", isPlaying), 47 + ); 48 + 49 + reactive( 50 + engine.audio, 51 + (data: AudioEngine.State) => 52 + data.items[engine.queue.data.now?.id]?.hasEnded ?? false, 53 + (hasEnded) => { 54 + if (hasEnded) engine.queue.sendAction("shift"); 55 + }, 56 + ); 57 + 58 + reactive( 59 + engine.audio, 60 + (data: AudioEngine.State) => 61 + data.items[engine.queue.data.now?.id]?.progress ?? 0, 62 + (progress: number) => ui.audio.sendAction("modifyProgress", progress), 63 + ); 64 + 65 + //////////////////////////////////////////// 66 + // ⚙️ [Connections → Engines] 67 + // 🚏 QUEUE 68 + //////////////////////////////////////////// 69 + reactive( 70 + engine.queue, 71 + (data: QueueEngine.State) => data.now, 72 + (playingNow) => { 73 + const volume = 0.5; // TODO 74 + 75 + if (!playingNow) { 76 + // NOTE: This probably isn't correct, keep preloads? 77 + engine.audio.sendAction("render", { tracks: [] }); 78 + return; 79 + } 80 + 81 + engine.audio.sendAction("render", { 82 + tracks: [{ 83 + id: playingNow.id, 84 + isPreload: false, 85 + url: playingNow.url, 86 + }], 87 + play: { 88 + trackId: playingNow.id, 89 + volume, 90 + }, 91 + }); 92 + }, 93 + ); 94 + 95 + //////////////////////////////////////////// 96 + // 🌅 [Connections → UI] 97 + // 🔉 AUDIO 25 98 //////////////////////////////////////////// 26 99 reactive( 27 100 ui.audio, 28 - // TODO: Shouldn't need to pass in the type here 29 101 (data: AudioUI.State) => data.isPlaying, 30 102 (isPlaying) => { 103 + const trackId = engine.queue.data.now?.id; 104 + const volume = 0.5; // TODO 105 + 106 + // Automatically start playing something if nothing is playing yet. 107 + if (!trackId) { 108 + if (isPlaying) engine.queue.sendAction("shift"); 109 + return; 110 + } 111 + 112 + // Otherwise just control the audio 31 113 if (isPlaying) { 32 - // TODO: Replace with an actual queue system (shift queue) 33 - engine.audio.sendAction("render", { 34 - tracks: [{ 35 - id: "TODO", 36 - isPreload: false, 37 - url: 38 - "https://archive.org/download/78_lollipop_the-chordettes-j-dixon-b-ross-archie-bleyer_gbia0068558a/Lollipop%20-%20The%20Chordettes%20-%20J.%20Dixon%20-%20B.%20Ross.mp3", 39 - }], 40 - play: { 41 - trackId: "TODO", 42 - volume: 0.5, 43 - }, 44 - }); 114 + engine.audio.sendAction("play", { trackId, volume }); 45 115 } else { 46 - engine.audio.sendAction("pause", { trackId: "TODO" }); 116 + engine.audio.sendAction("pause", { trackId }); 47 117 } 48 118 }, 49 119 ); ··· 52 122 ui.audio, 53 123 (data: AudioUI.State) => data.seekPosition, 54 124 (seekPosition) => { 55 - if (seekPosition) { 125 + if (seekPosition !== undefined && engine.queue.data.now?.id) { 56 126 engine.audio.sendAction("seek", { 57 127 percentage: seekPosition, 58 - trackId: "TODO", 128 + trackId: engine.queue.data.now.id, 59 129 }); 60 130 } 61 131 }, 62 132 ); 63 133 64 134 //////////////////////////////////////////// 65 - // ▒▒ [Connections ⚡] 66 - // ▒▒ Audio Engine → Audio UI 135 + // 🚀 67 136 //////////////////////////////////////////// 68 - reactive( 69 - engine.audio, 70 - (data: AudioEngine.State) => 71 - // TODO: Simplify using queue engine 72 - Object.values(data.nowPlaying).some((s) => s.isPlaying), 73 - (isPlaying) => ui.audio.sendAction("set_is_playing", isPlaying), 74 - ); 75 137 76 - reactive( 77 - engine.audio, 78 - (data: AudioEngine.State) => { 79 - // TODO: Simplify using queue engine 80 - return Object.values(data.nowPlaying).filter((s) => !s.isPreload)[0] 81 - ?.progress ?? 82 - 0; 138 + // TODO: Replace with an actual music collection 139 + await engine.queue.sendAction("add", [ 140 + { 141 + id: "Yours Truly, Johnny Dollar", 142 + expiresAt: Infinity, 143 + url: 144 + "https://archive.org/download/SUSPENSE_Radio_Digitally_Restored_Collection/%2040-07-22%20The%20Lodger%20%28audition%29%20%28Herbert%20Marshall%2C%20Alfred%20Hitchcock%2C%20Edmund%20Gwenn%29.mp3", 145 + }, 146 + { 147 + id: "Dimension X", 148 + expiresAt: Infinity, 149 + url: 150 + "https://archive.org/download/OTRR_Dimension_X_Singles/Dimension_X_1950-04-08__01_OuterLimit.mp3", 83 151 }, 84 - (progress: number) => ui.audio.sendAction("set_progress", progress), 85 - ); 152 + ]);
+16
src/styles/themes/pilot/index.css
··· 27 27 height: 100dvh; 28 28 } 29 29 30 + iframe { 31 + border: 0; 32 + } 33 + 30 34 /*********************************** 31 35 * Applets (UI) 32 36 ***********************************/ ··· 46 50 /*********************************** 47 51 * Applets (No UI) 48 52 ***********************************/ 53 + iframe[src*="/engine/"] { 54 + height: 0; 55 + left: 110vw; 56 + opacity: 0; 57 + overflow: hidden; 58 + pointer-events: none; 59 + position: absolute; 60 + top: 110vh; 61 + width: 0; 62 + } 49 63 50 64 /* Audio is special case, iframe needs to be "visible" in order to play the audio. */ 51 65 #applet__engine__audio { 66 + height: 1px; 52 67 left: 0; 53 68 opacity: 0; 54 69 pointer-events: none; 55 70 position: absolute; 56 71 top: 0; 72 + width: 1px; 57 73 }