Experiment to rebuild Diffuse using web applets.
0

Configure Feed

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

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>