Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

at main 360 lines 12 kB view raw
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>