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