Experiment to rebuild Diffuse using web applets.

feat: Implement a full-fledged audio engine

+481 -80
+2 -1
deno.json
··· 3 3 "@picocss/pico": "npm:@picocss/pico@^2.0.6", 4 4 "@web-applets/sdk": "npm:@web-applets/sdk@^0.2.6", 5 5 "astro": "npm:astro@^5.4.1", 6 - "spellcaster": "npm:spellcaster@^5.0.2" 6 + "spellcaster": "npm:spellcaster@^5.0.2", 7 + "throttle-debounce": "npm:throttle-debounce@^5.0.2" 7 8 }, 8 9 "tasks": { 9 10 "astro": "astro",
+7 -2
deno.lock
··· 8 8 "npm:astro@5.4.2": "5.4.2_vite@6.2.1_zod@3.24.2", 9 9 "npm:astro@^5.4.1": "5.4.2_vite@6.2.1_zod@3.24.2", 10 10 "npm:create-astro@latest": "4.11.1", 11 - "npm:spellcaster@^5.0.2": "5.0.2" 11 + "npm:spellcaster@^5.0.2": "5.0.2", 12 + "npm:throttle-debounce@^5.0.2": "5.0.2" 12 13 }, 13 14 "npm": { 14 15 "@astrojs/cli-kit@0.4.1": { ··· 2078 2079 "yallist" 2079 2080 ] 2080 2081 }, 2082 + "throttle-debounce@5.0.2": { 2083 + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==" 2084 + }, 2081 2085 "tinyexec@0.3.2": { 2082 2086 "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==" 2083 2087 }, ··· 2325 2329 "npm:@picocss/pico@^2.0.6", 2326 2330 "npm:@web-applets/sdk@~0.2.6", 2327 2331 "npm:astro@^5.4.1", 2328 - "npm:spellcaster@^5.0.2" 2332 + "npm:spellcaster@^5.0.2", 2333 + "npm:throttle-debounce@^5.0.2" 2329 2334 ] 2330 2335 } 2331 2336 }
+279 -46
src/applets/engine/audio/applet.astro
··· 1 - <div id="container"> 2 - <audio 3 - crossorigin="anonymous" 4 - src="https://archive.org/download/lp_moonlight-sonata_ludwig-van-beethoven-frdric-chopin-alexand/disc1%2F01.02.%20Moonlight%20Sonata%20Op.%2027%2C%20No.%202%20In%20C%20Sharp%20Minor%3A%20Allegretto.mp3?tunnel=1" 5 - preload="auto"></audio> 6 - </div> 1 + <div id="container"></div> 7 2 8 3 <script> 9 4 import { applets } from "@web-applets/sdk"; 5 + import { State, Track, TrackState } from "./types"; 10 6 11 - interface State { 12 - isPlaying: boolean; 13 - progress: number; 14 - } 7 + //////////////////////////////////////////// 8 + // CONSTANTS 9 + //////////////////////////////////////////// 10 + const SILENT_MP3 = 11 + "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV"; 15 12 13 + //////////////////////////////////////////// 14 + // SETUP 15 + //////////////////////////////////////////// 16 16 const context = applets.register<State>(); 17 17 const container = document.querySelector("#container"); 18 - const audio = document.querySelector("audio"); 19 18 20 - //////////////////////////////////////////// 21 19 // Initial state 22 - //////////////////////////////////////////// 23 20 context.data = { 24 - isPlaying: false, 25 - progress: 0, 21 + nowPlaying: {}, 26 22 }; 23 + 24 + // State helpers 25 + function update(partial: Partial<State>): void { 26 + context.data = { ...context.data, ...partial }; 27 + } 28 + 29 + function updateNowPlaying(trackId: string, partial: Partial<TrackState>): void { 30 + update({ 31 + ...context.data, 32 + nowPlaying: { 33 + ...context.data.nowPlaying, 34 + [trackId]: { ...context.data.nowPlaying[trackId], ...partial }, 35 + }, 36 + }); 37 + } 27 38 28 39 //////////////////////////////////////////// 29 - // Audio events 40 + // ACTIONS 41 + //////////////////////////////////////////// 42 + context.setActionHandler( 43 + "render", 44 + async (args: { play?: { trackId: string; volume: number }; tracks: Track[] }) => { 45 + await render(args.tracks); 46 + if (args.play) play({ trackId: args.play.trackId, volume: args.play.volume }); 47 + }, 48 + ); 49 + 50 + context.setActionHandler("pause", pause); 51 + context.setActionHandler("play", play); 52 + context.setActionHandler("reload", reload); 53 + context.setActionHandler("seek", seek); 54 + context.setActionHandler("volume", volume); 55 + 56 + function pause({ trackId }: { trackId: string }) { 57 + withAudioNode(trackId, (audio) => audio.pause()); 58 + } 59 + 60 + function play({ trackId, volume }: { trackId: string; volume: number }) { 61 + withAudioNode(trackId, (audio) => { 62 + audio.volume = volume; 63 + audio.muted = false; 64 + 65 + if (audio.readyState === 0) audio.load(); 66 + if (!audio.isConnected) return; 67 + 68 + const promise = audio.play() || Promise.resolve(); 69 + const didPreload = audio.getAttribute("data-did-preload") === "true"; 70 + const isPreload = audio.getAttribute("data-is-preload") === "true"; 71 + 72 + if (didPreload && !isPreload) { 73 + audio.removeAttribute("data-did-preload"); 74 + } 75 + 76 + updateNowPlaying(audio.id, { isPlaying: true }); 77 + 78 + promise.catch((e) => { 79 + if (!audio.isConnected) 80 + return; /* The node was removed from the DOM, we can ignore this error */ 81 + const err = "Couldn't play audio automatically. Please resume playback manually."; 82 + console.error(err, e); 83 + updateNowPlaying(trackId, { isPlaying: false }); 84 + }); 85 + }); 86 + } 87 + 88 + function reload(args: { play: boolean; progress?: number; trackId: string }) { 89 + withAudioNode(args.trackId, (audio) => { 90 + if (audio.readyState === 0 || audio.error?.code === 2) { 91 + audio.load(); 92 + 93 + if (args.progress !== undefined) { 94 + audio.setAttribute("data-initial-progress", JSON.stringify(args.progress)); 95 + } 96 + 97 + if (args.play) { 98 + play({ trackId: args.trackId, volume: audio.volume }); 99 + } 100 + } 101 + }); 102 + } 103 + 104 + function seek({ percentage, trackId }: { percentage: number; trackId: string }) { 105 + withAudioNode(trackId, (audio) => { 106 + if (!isNaN(audio.duration)) { 107 + audio.currentTime = audio.duration * percentage; 108 + } 109 + }); 110 + } 111 + 112 + function volume(args: { trackId?: string; volume: number }) { 113 + Array.from(container.querySelectorAll('audio[data-is-preload="false"]')).forEach((node) => { 114 + const audio = node as HTMLAudioElement; 115 + if (args.trackId === undefined || args.trackId === audio.id) { 116 + audio.volume = args.volume; 117 + } 118 + }); 119 + } 120 + 121 + //////////////////////////////////////////// 122 + // RENDER 123 + //////////////////////////////////////////// 124 + async function render(tracks: Array<Track>) { 125 + const ids = tracks.map((e) => e.id); 126 + const existingNodes = {}; 127 + 128 + // Manage existing nodes 129 + Array.from(container.querySelectorAll("audio")).map((node: HTMLAudioElement) => { 130 + if (ids.includes(node.id)) { 131 + existingNodes[node.id] = node; 132 + } else { 133 + node.src = SILENT_MP3; 134 + container?.removeChild(node); 135 + } 136 + }); 137 + 138 + // Adjust existing and add new 139 + await tracks.reduce(async (acc: Promise<void>, track: Track) => { 140 + await acc; 141 + 142 + const existingNode = existingNodes[track.id]; 143 + 144 + if (existingNode) { 145 + const isPreload = existingNode.getAttribute("data-is-preload"); 146 + if (isPreload === "true") existingNode.setAttribute("data-did-preload", "true"); 147 + 148 + existingNode.setAttribute("data-is-preload", track.isPreload ? "true" : "false"); 149 + } else { 150 + await createElement(track); 151 + } 152 + }, Promise.resolve()); 153 + 154 + // Now playing state 155 + const nowPlaying = tracks.reduce((acc, track) => { 156 + return { 157 + ...acc, 158 + [track.id]: context.data.nowPlaying[track.id] || { 159 + duration: 0, 160 + id: track.id, 161 + loadingState: "loading", 162 + isPlaying: true, 163 + isPreload: track.isPreload, 164 + progress: track.progress ?? 0, 165 + }, 166 + }; 167 + }, {}); 168 + 169 + update({ nowPlaying }); 170 + } 171 + 172 + export async function createElement(track: Track) { 173 + const source = document.createElement("source"); 174 + if (track.mimeType) source.setAttribute("type", track.mimeType); 175 + source.setAttribute("src", track.url); 176 + 177 + // Audio node 178 + const audio = new Audio(); 179 + audio.setAttribute("id", track.id); 180 + audio.setAttribute("crossorigin", "anonymous"); 181 + audio.setAttribute("data-is-preload", track.isPreload ? "true" : "false"); 182 + audio.setAttribute("muted", "true"); 183 + audio.setAttribute("preload", "auto"); 184 + 185 + if (track.progress !== undefined) { 186 + audio.setAttribute("data-initial-progress", JSON.stringify(track.progress)); 187 + } 188 + 189 + audio.appendChild(source); 190 + 191 + audio.addEventListener("canplay", canplayEvent); 192 + audio.addEventListener("durationchange", durationchangeEvent); 193 + audio.addEventListener("ended", endedEvent); 194 + audio.addEventListener("error", errorEvent); 195 + audio.addEventListener("pause", pauseEvent); 196 + audio.addEventListener("play", playEvent); 197 + audio.addEventListener("suspend", suspendEvent); 198 + audio.addEventListener("timeupdate", timeupdateEvent); 199 + audio.addEventListener("waiting", waitingEvent); 200 + 201 + container?.appendChild(audio); 202 + } 203 + 30 204 //////////////////////////////////////////// 31 - audio.ontimeupdate = (event) => { 32 - const progress = 33 - isNaN(audio.duration) || audio.duration === 0 ? 0 : audio.currentTime / audio.duration; 34 - context.data = { ...context.data, progress }; 35 - }; 205 + // AUDIO EVENTS 206 + //////////////////////////////////////////// 36 207 37 - audio.onpause = () => (context.data = { ...context.data, isPlaying: false }); 38 - audio.onplay = () => (context.data = { ...context.data, isPlaying: true }); 208 + function canplayEvent(event: Event) { 209 + const target = event.target as HTMLAudioElement; 39 210 40 - // TODO: 41 - // audio.oncanplay = (event) => console.log("canplay", event); 42 - // audio.onemptied = (event) => console.log("emptied", event); 43 - // audio.onended = (event) => console.log("ended", event); 44 - // audio.onloadeddata = (event) => console.log("loadeddata", event); 45 - // audio.onloadedmetadata = (event) => console.log("loadedmetadata", event); 46 - // audio.onloadstart = (event) => console.log("loadstart", event); 47 - // audio.onplaying = (event) => console.log("playing", event); 48 - // audio.onstalled = (event) => console.log("stalled", event); 49 - // audio.onsuspend = (event) => console.log("suspend", event); 50 - // audio.onwaiting = (event) => console.log("waiting", event); 211 + if ( 212 + target.hasAttribute("data-initial-progress") && 213 + target.duration && 214 + !isNaN(target.duration) 215 + ) { 216 + const progress = JSON.parse(target.getAttribute("data-initial-progress") as string); 217 + target.currentTime = target.duration * progress; 218 + target.removeAttribute("data-initial-progress"); 219 + } 51 220 52 - audio.onerror = (err) => console.error(err); 221 + finishedLoading(event); 222 + } 223 + 224 + function durationchangeEvent(event: Event) { 225 + const audio = event.target as HTMLAudioElement; 226 + 227 + if (!isNaN(audio.duration)) { 228 + updateNowPlaying(audio.id, { duration: audio.duration }); 229 + } 230 + } 231 + 232 + function endedEvent(event: Event) { 233 + const audio = event.target as HTMLAudioElement; 234 + audio.currentTime = 0; 235 + // TODO 236 + } 237 + 238 + function errorEvent(event: Event) { 239 + const audio = event.target as HTMLAudioElement; 240 + const code = audio.error?.code || 0; 241 + updateNowPlaying(audio.id, { loadingState: { error: { code } } }); 242 + } 243 + 244 + function pauseEvent(event: Event) { 245 + const audio = event.target as HTMLAudioElement; 246 + updateNowPlaying(audio.id, { isPlaying: false }); 247 + } 248 + 249 + function playEvent(event: Event) { 250 + const audio = event.target as HTMLAudioElement; 251 + updateNowPlaying(audio.id, { isPlaying: true }); 252 + 253 + // In case audio was preloaded: 254 + if (audio.readyState === 4) finishedLoading(event); 255 + } 256 + 257 + function suspendEvent(event: Event) { 258 + finishedLoading(event); 259 + } 260 + 261 + function timeupdateEvent(event: Event) { 262 + const audio = event.target as HTMLAudioElement; 263 + 264 + updateNowPlaying(audio.id, { 265 + progress: 266 + isNaN(audio.duration) || audio.duration === 0 ? 0 : audio.currentTime / audio.duration, 267 + }); 268 + } 269 + 270 + function waitingEvent(event: Event) { 271 + initiateLoading(event); 272 + } 53 273 54 274 //////////////////////////////////////////// 55 - // Actions 275 + // 🛠️ 56 276 //////////////////////////////////////////// 57 - context.setActionHandler("load", (src: string) => { 58 - audio.src = src; 59 - audio.load(); 60 - }); 61 277 62 - context.setActionHandler("play", () => { 63 - audio.play(); 64 - }); 278 + function finishedLoading(event: Event) { 279 + const audio = event.target as HTMLAudioElement; 280 + updateNowPlaying(audio.id, { loadingState: "loaded" }); 281 + } 65 282 66 - context.setActionHandler("pause", () => { 67 - audio.pause(); 68 - }); 283 + function initiateLoading(event: Event) { 284 + const audio = event.target as HTMLAudioElement; 285 + if (audio.readyState < 4) updateNowPlaying(audio.id, { loadingState: "loaded" }); 286 + } 287 + 288 + function withActiveAudioNode(fn: (node: HTMLAudioElement) => void): void { 289 + const nonPreloadNodes: HTMLAudioElement[] = Array.from( 290 + container.querySelectorAll(`audio[data-is-preload="false"]`), 291 + ); 292 + 293 + const playingNodes = nonPreloadNodes.filter((n) => n.paused === false); 294 + const node = playingNodes.length ? playingNodes[0] : nonPreloadNodes[0]; 295 + if (node) fn(node); 296 + } 297 + 298 + function withAudioNode(trackId: string, fn: (node: HTMLAudioElement) => void): void { 299 + const node = container.querySelector(`audio[id="${trackId}"][data-is-preload="false"]`); 300 + if (node) fn(node as HTMLAudioElement); 301 + } 69 302 </script>
+104 -12
src/applets/engine/audio/manifest.json
··· 1 1 { 2 2 "name": "diffuse/engine/audio", 3 + "title": "Diffuse Audio", 3 4 "entrypoint": "index.html", 4 5 "actions": { 5 - "load": { 6 - "title": "Load", 7 - "description": "Load a given audio src", 8 - "params_schema": { 9 - "type": "string", 10 - "description": "String to be used as the audio `src`" 11 - } 12 - }, 13 6 "pause": { 14 7 "title": "Pause", 15 - "description": "Indicate the active audio should be paused", 8 + "description": "Pause a track", 16 9 "params_schema": { 17 - "type": "null" 10 + "type": "object", 11 + "properties": { 12 + "trackId": { 13 + "type": "string" 14 + } 15 + }, 16 + "required": ["trackId"] 18 17 } 19 18 }, 20 19 "play": { 21 20 "title": "Play", 22 - "description": "Indicate the active audio should be playing", 21 + "description": "Play a track", 23 22 "params_schema": { 24 - "type": "null" 23 + "type": "object", 24 + "properties": { 25 + "trackId": { 26 + "type": "string" 27 + }, 28 + "volume": { 29 + "type": "number" 30 + } 31 + }, 32 + "required": ["trackId", "volume"] 33 + } 34 + }, 35 + "render": { 36 + "title": "Render", 37 + "description": "Determine the active set of audio elements", 38 + "params_schema": { 39 + "type": "object", 40 + "properties": { 41 + "play": { 42 + "type": "object" 43 + }, 44 + "tracks": { 45 + "type": "array", 46 + "items": { 47 + "anyOf": [ 48 + { 49 + "type": "object", 50 + "properties": { 51 + "id": { "type": "string" }, 52 + "isPreload": { "type": "boolean" }, 53 + "mimeType": { "type": "string" }, 54 + "progress": { "type": "number" }, 55 + "url": { "type": "string" } 56 + }, 57 + "required": ["id", "isPreload", "url"] 58 + } 59 + ] 60 + } 61 + } 62 + }, 63 + "required": ["tracks"] 64 + } 65 + }, 66 + "reload": { 67 + "title": "Reload", 68 + "description": "Make sure the audio node with the given track id is loading properly. This should be used when for example, the internet connection comes back and the loading of the track depended on said internet connection.", 69 + "params_schema": { 70 + "type": "object", 71 + "properties": { 72 + "play": { 73 + "type": "boolean" 74 + }, 75 + "progress": { 76 + "type": "number" 77 + }, 78 + "trackId": { 79 + "type": "string" 80 + } 81 + }, 82 + "required": ["percentage", "trackId"] 83 + } 84 + }, 85 + "seek": { 86 + "title": "Seek", 87 + "description": "Seek a track to a given position", 88 + "params_schema": { 89 + "type": "object", 90 + "properties": { 91 + "percentage": { 92 + "type": "number", 93 + "description": "A number between 0 and 1 that determines the new current position in the audio" 94 + }, 95 + "trackId": { 96 + "type": "string" 97 + } 98 + }, 99 + "required": ["percentage", "trackId"] 100 + } 101 + }, 102 + "volume": { 103 + "title": "Volume", 104 + "description": "Set the volume of all tracks, or a specific track.", 105 + "params_schema": { 106 + "type": "object", 107 + "properties": { 108 + "trackId": { 109 + "type": "string" 110 + }, 111 + "volume": { 112 + "type": "number", 113 + "description": "A number between 0 and 1 that determines the new volume of all audio elements" 114 + } 115 + }, 116 + "required": ["volume"] 25 117 } 26 118 } 27 119 }
+22
src/applets/engine/audio/types.ts
··· 1 + export interface State { 2 + nowPlaying: Record<string, TrackState>; 3 + } 4 + 5 + export interface Track { 6 + id: string; 7 + isPreload: boolean; 8 + mimeType?: string; 9 + progress?: number; 10 + url: string; 11 + } 12 + 13 + export interface TrackState { 14 + duration: number; 15 + id: string; 16 + loadingState: "initialisation" | "loading" | "loaded" | { 17 + error: { code: number }; 18 + }; 19 + isPlaying: boolean; 20 + isPreload: boolean; 21 + progress: number; 22 + }
+11 -7
src/applets/themes/pilot/ui/audio/applet.astro
··· 48 48 flex: 1; 49 49 flex-direction: column; 50 50 max-width: var(--container-lg); 51 - padding: var(--space-sm) var(--space-md); 51 + margin-top: var(--space-2xs); 52 + padding: var(--space-2xs) var(--space-md); 52 53 } 53 54 54 55 .controls { 55 56 align-items: center; 56 57 display: flex; 57 58 justify-content: center; 58 - margin-bottom: var(--space-2xs); 59 59 60 60 & .controls__playpause { 61 61 font-size: var(--fs-lg); ··· 65 65 .time { 66 66 align-self: stretch; 67 67 display: flex; 68 + padding: var(--space-2xs) 0; 68 69 } 69 70 70 71 progress { ··· 91 92 92 93 <script> 93 94 import { applets } from "@web-applets/sdk"; 94 - 95 - interface State { 96 - isPlaying: boolean; 97 - } 95 + import { State } from "./types"; 98 96 99 97 const context = applets.register<State>(); 100 98 ··· 122 120 // DOM 123 121 //////////////////////////////////////////// 124 122 document.body.querySelector(".controls__playpause").addEventListener("click", () => { 125 - context.data = { isPlaying: !(context.data?.isPlaying ?? false) }; 123 + context.data = { ...context.data, isPlaying: !(context.data?.isPlaying ?? false) }; 124 + }); 125 + 126 + document.body.querySelector(".time").addEventListener("click", (event: Event) => { 127 + const mouseEvent = event as MouseEvent; 128 + const seekPosition = mouseEvent.offsetX / (event.target as HTMLProgressElement).clientWidth; 129 + context.data = { ...context.data, seekPosition }; 126 130 }); 127 131 128 132 function render() {
+4
src/applets/themes/pilot/ui/audio/types.ts
··· 1 + export interface State { 2 + isPlaying: boolean; 3 + seekPosition?: number; 4 + }
+1 -1
src/pages/[...applet].astro
··· 30 30 31 31 return { 32 32 applet: path.join("/"), 33 - title: manifest.default.name, 33 + title: manifest.default.title || manifest.default.name, 34 34 Component: Applet.default, 35 35 }; 36 36 }
+51 -11
src/scripts/themes/pilot/index.ts
··· 1 1 import { type Applet, type AppletEvent, applets } from "@web-applets/sdk"; 2 2 import { effect, signal } from "spellcaster/spellcaster.js"; 3 3 4 + import type * as AudioEngine from "../../../applets/engine/audio/types.ts"; 5 + import type * as AudioUI from "../../../applets/themes/pilot/ui/audio/types.ts"; 6 + 4 7 import "../../../styles/themes/pilot/index.css"; 5 8 6 9 //////////////////////////////////////////// 7 10 // 🗂️ Applets 8 11 //////////////////////////////////////////// 9 12 const engine = { 10 - audio: await applet("../../engine/audio"), 13 + audio: await applet<AudioEngine.State>("../../engine/audio"), 11 14 }; 12 15 13 16 const ui = { ··· 19 22 //////////////////////////////////////////// 20 23 reactive( 21 24 ui.audio, 22 - "isPlaying", 23 - (isPlaying: boolean) => 24 - engine.audio.sendAction(isPlaying ? "play" : "pause", null), 25 + (data: AudioUI.State) => data.isPlaying, 26 + (isPlaying: boolean) => { 27 + if (isPlaying) { 28 + // TODO: Replace with an actual queue system (shift queue) 29 + engine.audio.sendAction("render", { 30 + tracks: [{ 31 + id: "TODO", 32 + isPreload: false, 33 + url: 34 + "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", 35 + }], 36 + play: { 37 + trackId: "TODO", 38 + volume: 0.5, 39 + }, 40 + }); 41 + } else { 42 + engine.audio.sendAction("pause", { trackId: "TODO" }); 43 + } 44 + }, 45 + ); 46 + 47 + reactive( 48 + ui.audio, 49 + (data: AudioUI.State) => data.seekPosition, 50 + (seekPosition: undefined | number) => { 51 + if (seekPosition) { 52 + engine.audio.sendAction("seek", { 53 + percentage: seekPosition, 54 + trackId: "TODO", 55 + }); 56 + } 57 + }, 25 58 ); 26 59 27 60 reactive( 28 61 engine.audio, 29 - "isPlaying", 62 + (data: AudioEngine.State) => 63 + // TODO: Simplify using queue engine 64 + Object.values(data.nowPlaying).some((s) => s.isPlaying), 30 65 (isPlaying: boolean) => ui.audio.sendAction("set_is_playing", isPlaying), 31 66 ); 32 67 33 68 reactive( 34 69 engine.audio, 35 - "progress", 70 + (data: AudioEngine.State) => { 71 + // TODO: Simplify using queue engine 72 + return Object.values(data.nowPlaying).filter((s) => !s.isPreload)[0] 73 + ?.progress ?? 74 + 0; 75 + }, 36 76 (progress: number) => ui.audio.sendAction("set_progress", progress), 37 77 ); 38 78 39 79 //////////////////////////////////////////// 40 80 // 🪟 Applet initialiser 41 81 //////////////////////////////////////////// 42 - async function applet(src: string, opts: { setHeight?: boolean } = {}) { 82 + async function applet<T>(src: string, opts: { setHeight?: boolean } = {}) { 43 83 const frame: HTMLIFrameElement | null = document.querySelector( 44 84 `[src="${src}${src.endsWith("/") ? "" : "/"}"]`, 45 85 ); ··· 49 89 throw new Error("iframe does not have a contentWindow"); 50 90 } 51 91 52 - const applet = await applets.connect(frame.contentWindow); 92 + const applet: Applet = await applets.connect<T>(frame.contentWindow); 53 93 54 94 if (opts.setHeight) { 55 95 applet.onresize = () => { ··· 75 115 // TODO: Applet should have a subtype 76 116 function reactive<T>( 77 117 applet: Applet, 78 - property: string, 118 + dataFn: (data: any /* TODO: Proper types pls */) => T, 79 119 effectFn: (t: T) => void, 80 120 ) { 81 121 const [getter, setter] = signal( 82 - (applet.data as any)[property] as T, 122 + dataFn(applet.data as any), 83 123 ); 84 124 85 125 effect(() => effectFn(getter())); 86 126 87 127 applet.addEventListener("data", (event: AppletEvent) => { 88 - setter(event.data[property]); 128 + setter(dataFn(event.data)); 89 129 }); 90 130 }