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