Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

at s3 303 lines 9.9 kB view raw
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>