Experiment to rebuild Diffuse using web applets.
1<script>
2 import { effect, signal } from "spellcaster";
3
4 import type { State, Audio, AudioState } from "./types";
5 import { register } from "@scripts/applet/common";
6
7 ////////////////////////////////////////////
8 // CONSTANTS
9 ////////////////////////////////////////////
10 const SILENT_MP3 =
11 "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV";
12
13 ////////////////////////////////////////////
14 // SETUP
15 ////////////////////////////////////////////
16 const context = register<State>();
17 const groupId = context.groupId ?? "main";
18
19 // Audio elements container
20 const container = document.createElement("div");
21 container.id = "container";
22 document.body.appendChild(container);
23
24 // Default volume
25 const VOLUME_KEY = `@applets/engine/audio/${groupId}/volume`;
26 const vol = localStorage.getItem(VOLUME_KEY);
27
28 // Initial state
29 if (context.isMainInstance())
30 context.data = {
31 isPlaying: false,
32 items: {},
33 volume: {
34 default: vol ? parseFloat(vol) : 0.5,
35 },
36 };
37
38 // State helpers
39 function update(partial: Partial<State>): void {
40 context.data = { ...context.data, ...partial };
41 }
42
43 function updateItems(audioId: string, partial: Partial<AudioState>): void {
44 update({
45 ...context.data,
46 items: {
47 ...(context.data?.items || {}),
48 [audioId]: { ...(context.data?.items?.[audioId] || {}), ...partial },
49 },
50 });
51 }
52
53 // Effects
54 const [defaultVolume, setDefaultVolume] = signal<number | undefined>(undefined);
55 context.scope.ondata = (event: any) => setDefaultVolume(event.data.volume.default);
56
57 effect(() => {
58 if (context.isMainInstance()) {
59 const volume = defaultVolume();
60 if (volume === undefined) return;
61 localStorage.setItem(VOLUME_KEY, volume.toString());
62 }
63 });
64
65 // Unload
66 context.unloadHandler = async () => {
67 await context.settled();
68 hydrateItems();
69 };
70
71 function hydrateItems() {
72 const playingItem = context.data.isPlaying
73 ? Object.values(context.data.items).find((item) => item.isPlaying)
74 : undefined;
75
76 render({
77 audio: Object.values(context.data.items).map((item: AudioState) => {
78 return {
79 id: item.id,
80 isPreload: item.isPreload,
81 mimeType: item.mimeType,
82 progress: item.progress,
83 url: item.url,
84 };
85 }),
86 play: playingItem ? { audioId: playingItem.id } : undefined,
87 });
88 }
89
90 ////////////////////////////////////////////
91 // ACTIONS
92 ////////////////////////////////////////////
93 context.setActionHandler("pause", pause);
94 context.setActionHandler("play", play);
95 context.setActionHandler("reload", reload);
96 context.setActionHandler("render", render);
97 context.setActionHandler("seek", seek);
98 context.setActionHandler("volume", volume);
99
100 function pause({ audioId }: { audioId: string }) {
101 withAudioNode(audioId, (audio) => audio.pause());
102 }
103
104 function play({ audioId, volume }: { audioId: string; volume?: number }) {
105 withAudioNode(audioId, (audio) => {
106 audio.volume = volume ?? context.data.volume.default;
107 audio.muted = false;
108
109 if (audio.readyState === 0) audio.load();
110 if (!audio.isConnected) return;
111
112 const promise = audio.play() || Promise.resolve();
113 const didPreload = audio.getAttribute("data-did-preload") === "true";
114 const isPreload = audio.getAttribute("data-is-preload") === "true";
115
116 if (didPreload && !isPreload) {
117 audio.removeAttribute("data-did-preload");
118 }
119
120 updateItems(audio.id, { isPlaying: true });
121
122 promise.catch((e) => {
123 if (!audio.isConnected)
124 return; /* The node was removed from the DOM, we can ignore this error */
125 const err = "Couldn't play audio automatically. Please resume playback manually.";
126 console.error(err, e);
127 updateItems(audioId, { isPlaying: false });
128 });
129 });
130 }
131
132 function reload(args: { play: boolean; progress?: number; audioId: string }) {
133 withAudioNode(args.audioId, (audio) => {
134 if (audio.readyState === 0 || audio.error?.code === 2) {
135 audio.load();
136
137 if (args.progress !== undefined) {
138 audio.setAttribute("data-initial-progress", JSON.stringify(args.progress));
139 }
140
141 if (args.play) {
142 play({ audioId: args.audioId, volume: audio.volume });
143 }
144 }
145 });
146 }
147
148 async function render(args: { play?: { audioId: string; volume?: number }; audio: Audio[] }) {
149 await renderAudio(args.audio);
150 if (args.play) play({ audioId: args.play.audioId, volume: args.play.volume });
151 }
152
153 function seek({ percentage, audioId }: { percentage: number; audioId: string }) {
154 withAudioNode(audioId, (audio) => {
155 if (!isNaN(audio.duration)) {
156 audio.currentTime = audio.duration * percentage;
157 }
158 });
159 }
160
161 function volume(args: { audioId?: string; volume: number }) {
162 if (!args.audioId) update({ volume: { default: args.volume } });
163
164 Array.from(container.querySelectorAll("audio")).forEach((node) => {
165 const audio = node as HTMLAudioElement;
166 if (audio.getAttribute("data-is-preload") === "true") return;
167 if (args.audioId === undefined || args.audioId === audio.id) {
168 audio.volume = args.volume;
169 }
170 });
171 }
172
173 ////////////////////////////////////////////
174 // RENDER
175 ////////////////////////////////////////////
176 async function renderAudio(audio: Array<Audio>) {
177 const ids = audio.map((a) => a.id);
178 const existingNodes: Record<string, HTMLAudioElement> = {};
179
180 // Manage existing nodes
181 Array.from(container.querySelectorAll("audio")).map((node: HTMLAudioElement) => {
182 if (ids.includes(node.id)) {
183 existingNodes[node.id] = node;
184 } else {
185 node.src = SILENT_MP3;
186 container?.removeChild(node);
187 }
188 });
189
190 // Adjust existing and add new
191 await audio.reduce(async (acc: Promise<void>, item: Audio) => {
192 await acc;
193
194 const existingNode = existingNodes[item.id];
195
196 if (existingNode) {
197 const isPreload = existingNode.getAttribute("data-is-preload");
198 if (isPreload === "true") existingNode.setAttribute("data-did-preload", "true");
199
200 existingNode.setAttribute("data-is-preload", item.isPreload ? "true" : "false");
201 } else {
202 await createElement(item);
203 }
204 }, Promise.resolve());
205
206 // Now playing state
207 const items = audio.reduce((acc, item) => {
208 return {
209 ...acc,
210 [item.id]: context.data?.items?.[item.id] || {
211 duration: 0,
212 id: item.id,
213 loadingState: "loading",
214 isPlaying: true,
215 isPreload: item.isPreload ?? false,
216 mimeType: item.mimeType,
217 progress: item.progress ?? 0,
218 url: item.url,
219 },
220 };
221 }, {});
222
223 update({ items });
224 }
225
226 export async function createElement(audio: Audio) {
227 const source = document.createElement("source");
228 if (audio.mimeType) source.setAttribute("type", audio.mimeType);
229 source.setAttribute("src", audio.url);
230
231 // Audio node
232 const node = new Audio();
233 node.setAttribute("id", audio.id);
234 node.setAttribute("crossorigin", "anonymous");
235 node.setAttribute("data-is-preload", audio.isPreload ? "true" : "false");
236 node.setAttribute("muted", "true");
237 node.setAttribute("preload", "auto");
238
239 if (audio.progress !== undefined) {
240 node.setAttribute("data-initial-progress", JSON.stringify(audio.progress));
241 }
242
243 node.appendChild(source);
244
245 node.addEventListener("canplay", canplayEvent);
246 node.addEventListener("durationchange", durationchangeEvent);
247 node.addEventListener("ended", endedEvent);
248 node.addEventListener("error", errorEvent);
249 node.addEventListener("pause", pauseEvent);
250 node.addEventListener("play", playEvent);
251 node.addEventListener("suspend", suspendEvent);
252 node.addEventListener("timeupdate", timeupdateEvent);
253 node.addEventListener("waiting", waitingEvent);
254
255 container?.appendChild(node);
256 }
257
258 ////////////////////////////////////////////
259 // AUDIO EVENTS
260 ////////////////////////////////////////////
261
262 function canplayEvent(event: Event) {
263 const target = event.target as HTMLAudioElement;
264
265 if (
266 target.hasAttribute("data-initial-progress") &&
267 target.duration &&
268 !isNaN(target.duration)
269 ) {
270 const progress = JSON.parse(target.getAttribute("data-initial-progress") as string);
271 target.currentTime = target.duration * progress;
272 target.removeAttribute("data-initial-progress");
273 }
274
275 finishedLoading(event);
276 }
277
278 function durationchangeEvent(event: Event) {
279 const audio = event.target as HTMLAudioElement;
280
281 if (!isNaN(audio.duration)) {
282 updateItems(audio.id, { duration: audio.duration });
283 }
284 }
285
286 function endedEvent(event: Event) {
287 const audio = event.target as HTMLAudioElement;
288 audio.currentTime = 0;
289 updateItems(audio.id, { hasEnded: true });
290 }
291
292 function errorEvent(event: Event) {
293 const audio = event.target as HTMLAudioElement;
294 const code = audio.error?.code || 0;
295 updateItems(audio.id, { loadingState: { error: { code } } });
296 }
297
298 function pauseEvent(event: Event) {
299 const audio = event.target as HTMLAudioElement;
300 const item = context.data.items[audio.id];
301 const ended = item ? item.hasEnded || item.progress === 1 : false;
302 updateItems(audio.id, { isPlaying: false });
303 update({ isPlaying: ended });
304 }
305
306 function playEvent(event: Event) {
307 const audio = event.target as HTMLAudioElement;
308 updateItems(audio.id, { isPlaying: true });
309 update({ isPlaying: true });
310
311 // In case audio was preloaded:
312 if (audio.readyState === 4) finishedLoading(event);
313 }
314
315 function suspendEvent(event: Event) {
316 finishedLoading(event);
317 }
318
319 function timeupdateEvent(event: Event) {
320 const audio = event.target as HTMLAudioElement;
321
322 updateItems(audio.id, {
323 progress:
324 isNaN(audio.duration) || audio.duration === 0 ? 0 : audio.currentTime / audio.duration,
325 });
326 }
327
328 function waitingEvent(event: Event) {
329 initiateLoading(event);
330 }
331
332 ////////////////////////////////////////////
333 // 🛠️
334 ////////////////////////////////////////////
335
336 function finishedLoading(event: Event) {
337 const audio = event.target as HTMLAudioElement;
338 updateItems(audio.id, { loadingState: "loaded" });
339 }
340
341 function initiateLoading(event: Event) {
342 const audio = event.target as HTMLAudioElement;
343 if (audio.readyState < 4) updateItems(audio.id, { loadingState: "loading" });
344 }
345
346 function withActiveAudioNodes(fn: (node: HTMLAudioElement) => void): void {
347 const nonPreloadNodes: HTMLAudioElement[] = Array.from(
348 container.querySelectorAll(`audio[data-is-preload="false"]`),
349 );
350
351 const playingNodes = nonPreloadNodes.filter((n) => n.paused === false);
352 const node = playingNodes.length ? playingNodes[0] : nonPreloadNodes[0];
353 if (node) fn(node);
354 }
355
356 function withAudioNode(audioId: string, fn: (node: HTMLAudioElement) => void): void {
357 const node = container.querySelector(`audio[id="${audioId}"][data-is-preload="false"]`);
358 if (node) fn(node as HTMLAudioElement);
359 }
360</script>