A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

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

feat: audio engine

+487 -39
+5 -3
_backup/pages/engine/audio/types.d.ts src/elements/engine/audio/types.d.ts
··· 1 + import { Signal } from "@common/signal.d.ts"; 2 + 1 3 export interface State { 2 4 isPlaying: boolean; 3 - items: Record<string, AudioState>; 5 + items: Signal<Audio[]>; 4 6 volume: { default: number }; 5 7 } 6 8 ··· 21 23 | "loading" 22 24 | "loaded" 23 25 | { 24 - error: { code: number }; 25 - }; 26 + error: { code: number }; 27 + }; 26 28 isPlaying: boolean; 27 29 isPreload: boolean; 28 30 mimeType?: string;
+17
src/common/element.d.ts
··· 3 3 ...values: unknown[] 4 4 ) => string; 5 5 6 + type MorphOptions = { 7 + getNodeKey?: (node: Node) => unknown; 8 + onBeforeNodeAdded?: (node: Node) => false | Node; 9 + onNodeAdded?: (node: Node) => void; 10 + onBeforeElUpdated?: (fromEl: HTMLElement, toEl: HTMLElement) => boolean; 11 + onElUpdated?: (el: HTMLElement) => void; 12 + onBeforeNodeDiscarded?: (node: Node) => boolean; 13 + onNodeDiscarded?: (node: Node) => void; 14 + onBeforeElChildrenUpdated?: ( 15 + fromEl: HTMLElement, 16 + toEl: HTMLElement, 17 + ) => boolean; 18 + skipFromChildren?: (fromEl: HTMLElement) => boolean; 19 + addChild?: (parent: HTMLElement, child: HTMLElement) => void; 20 + childrenOnly?: boolean; 21 + }; 22 + 6 23 export type RenderArg<State> = { html: HtmlTagFunction; state: State };
+36 -23
src/common/element.js
··· 1 1 import morphdom from "morphdom/dist/morphdom.js"; 2 - import { effect } from "@common/signals.js"; 2 + import { effect } from "@common/signal.js"; 3 3 4 4 /** 5 - * @import {HtmlTagFunction} from "./element.d.ts" 5 + * @import {HtmlTagFunction, MorphOptions} from "./element.d.ts" 6 6 */ 7 7 8 8 export default class DiffuseElement extends HTMLElement { ··· 17 17 this.morphedRender = this.morphedRender.bind(this); 18 18 } 19 19 20 + /** 21 + * @param {string} _name 22 + * @param {string} oldValue 23 + * @param {string} newValue 24 + */ 25 + attributeChangedCallback(_name, oldValue, newValue) { 26 + if (oldValue !== newValue) this.morphedRender(); 27 + } 28 + 29 + /** 30 + * Effect helper that automatically disposes it 31 + * when this element is removed from the DOM. 32 + * 33 + * @param {() => void} fn 34 + */ 35 + effect(fn) { 36 + this.#disposables.push(effect(fn)); 37 + } 38 + 39 + /** 40 + * @type {HtmlTagFunction} 41 + */ 42 + html(strings, ...values) { 43 + return String.raw({ raw: strings }, ...values); 44 + } 45 + 46 + /** 47 + * Avoid replacing the whole subtree, 48 + * morph the existing DOM into the new given tree. 49 + */ 20 50 morphedRender() { 21 51 if (!("render" in this && typeof this.render === "function")) return; 22 52 if (!("state" in this)) return; ··· 34 64 root, 35 65 updated, 36 66 { 67 + ...this.morphOptions, 37 68 childrenOnly: true, 38 69 }, 39 70 ); 40 71 } 41 72 42 - /** 43 - * @param {string} _name 44 - * @param {string} oldValue 45 - * @param {string} newValue 46 - */ 47 - attributeChangedCallback(_name, oldValue, newValue) { 48 - if (oldValue !== newValue) this.morphedRender(); 49 - } 73 + // MORPH STUFF 50 74 51 - /** 52 - * @param {() => void} fn 53 - */ 54 - effect(fn) { 55 - this.#disposables.push(effect(fn)); 56 - } 57 - 58 - /** 59 - * @type {HtmlTagFunction} 60 - */ 61 - html(strings, ...values) { 62 - return String.raw({ raw: strings }, ...values); 63 - } 75 + /** @type {MorphOptions} */ 76 + morphOptions = {}; 64 77 65 78 // LIFECYCLE 66 79
src/common/signals.d.ts src/common/signal.d.ts
src/common/signals.js src/common/signal.js
+14 -11
src/elements/constituent/blur/browser-list/index.js
··· 1 1 import DiffuseElement from "@common/element.js"; 2 - import { signal } from "@common/signals.js"; 2 + import { signal } from "@common/signal.js"; 3 3 4 4 /** 5 5 * @import {RenderArg} from "@common/element.d.ts" 6 + * @import {State} from "./types.d.ts" 6 7 * @import {Track} from "@elements/core/types.d.ts" 7 - * 8 - * @import {State} from "./types.d.ts" 9 8 */ 10 9 11 10 //////////////////////////////////////////// ··· 43 42 render({ html, state }) { 44 43 console.log("Rendering", state.tracks()); 45 44 46 - const list = state.tracks().map((t, idx) => 47 - html` 48 - <div id="track-${idx}">${t}</div> 49 - ` 50 - ); 45 + const list = state.tracks().map( 46 + /** 47 + * @param {Track} t 48 + * @param {number} idx 49 + */ 50 + (t, idx) => 51 + html` 52 + <div id="track-${idx}">${t}</div> 53 + `, 54 + ).join(""); 51 55 52 56 return html` 53 57 <style> ··· 55 59 color: blue; 56 60 } 57 61 </style> 58 - <section>${list.join("")}</section> 62 + <section>${list}</section> 59 63 `; 60 64 } 61 65 } 62 66 63 - export { BrowserList as ConstituentBlurBrowserList }; 64 67 export default BrowserList; 65 68 66 69 //////////////////////////////////////////// 67 70 // REGISTER 68 71 //////////////////////////////////////////// 69 72 70 - customElements.define("constituent-blur-browser-list", BrowserList); 73 + customElements.define("dcb-browser-list", BrowserList);
+413
src/elements/engine/audio/index.js
··· 1 + import DiffuseElement from "@common/element.js"; 2 + import { effect, signal } from "@common/signal.js"; 3 + 4 + /** 5 + * @import {Audio, AudioState, State} from "./types.d.ts" 6 + * @import {RenderArg} from "@common/element.d.ts" 7 + * @import {Signal} from "@common/signal.d.ts" 8 + */ 9 + 10 + //////////////////////////////////////////// 11 + // CONSTANTS 12 + //////////////////////////////////////////// 13 + const SILENT_MP3 = 14 + "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV"; 15 + 16 + //////////////////////////////////////////// 17 + // ELEMENT 18 + //////////////////////////////////////////// 19 + 20 + class AudioEngine extends DiffuseElement { 21 + static observedAttributes = ["is-playing", "volume"]; 22 + 23 + constructor() { 24 + super(); 25 + 26 + // TODO: Get volume from previous session if possible 27 + // const VOLUME_KEY = `@elements/engine/audio/${this.groupId}/volume`; 28 + // const vol = localStorage.getItem(VOLUME_KEY); 29 + } 30 + 31 + // SIGNALS 32 + 33 + defaultVolume = signal(0.5); 34 + isPlaying = signal(false); 35 + items = signal(/** @type {Audio[]} */ ([])); 36 + 37 + // STATE 38 + 39 + get state() { 40 + return { 41 + isPlaying: this.isPlaying, 42 + items: this.items, 43 + volume: { default: this.defaultVolume() }, 44 + }; 45 + } 46 + 47 + // ACTIONS 48 + 49 + /** 50 + * @param {{ audioId: string }} _ 51 + */ 52 + pause({ audioId }) { 53 + this.withAudioNode(audioId, (audio) => audio.pause()); 54 + } 55 + 56 + /** 57 + * @param {{ audioId: string; volume?: number }} _ 58 + */ 59 + play({ audioId, volume }) { 60 + this.withAudioNode(audioId, (audio, item) => { 61 + audio.volume = volume ?? this.state.volume.default; 62 + audio.muted = false; 63 + 64 + if (audio.readyState === 0) audio.load(); 65 + if (!audio.isConnected) return; 66 + 67 + const promise = audio.play() || Promise.resolve(); 68 + item.state = { isPlaying: true }; 69 + 70 + promise.catch((e) => { 71 + if (!audio.isConnected) { 72 + return; /* The node was removed from the DOM, we can ignore this error */ 73 + } 74 + const err = 75 + "Couldn't play audio automatically. Please resume playback manually."; 76 + console.error(err, e); 77 + item.state = { isPlaying: false }; 78 + }); 79 + }); 80 + } 81 + 82 + /** 83 + * @param {{ audioId: string; play: boolean; progress?: number }} args 84 + */ 85 + reload(args) { 86 + this.withAudioNode(args.audioId, (audio, item) => { 87 + if (audio.readyState === 0 || audio.error?.code === 2) { 88 + audio.load(); 89 + 90 + if (args.progress !== undefined) { 91 + item.setAttribute( 92 + "initial-progress", 93 + JSON.stringify(args.progress), 94 + ); 95 + } 96 + 97 + if (args.play) { 98 + this.play({ audioId: args.audioId, volume: audio.volume }); 99 + } 100 + } 101 + }); 102 + } 103 + 104 + /** 105 + * @param {{ audioId: string; percentage: number }} _ 106 + */ 107 + seek({ audioId, percentage }) { 108 + this.withAudioNode(audioId, (audio) => { 109 + if (!isNaN(audio.duration)) { 110 + audio.currentTime = audio.duration * percentage; 111 + } 112 + }); 113 + } 114 + 115 + /** 116 + * @param {{ audioId?: string; volume: number }} args 117 + */ 118 + volume(args) { 119 + // TODO: 120 + // if (!args.audioId) update({ volume: { default: args.volume } }); 121 + 122 + Array.from(this.querySelectorAll("de-audio-item audio")).forEach((node) => { 123 + const audio = /** @type {HTMLAudioElement} */ (node); 124 + if (audio.hasAttribute("preload")) return; 125 + if (args.audioId === undefined || args.audioId === audio.id) { 126 + audio.volume = args.volume; 127 + } 128 + }); 129 + } 130 + 131 + /** 132 + * @param {{ audio: Audio[]; play?: { audioId: string; volume?: number } }} args 133 + */ 134 + yield(args) { 135 + this.items(args.audio); 136 + if (args.play) this.play(args.play); 137 + } 138 + 139 + // RENDER 140 + 141 + /** 142 + * @param {RenderArg<State>} _ 143 + */ 144 + render({ html, state }) { 145 + console.log("Render"); 146 + 147 + const nodes = state.items().map((audio) => { 148 + const ip = audio.progress === undefined 149 + ? "0" 150 + : JSON.stringify(audio.progress); 151 + 152 + return html` 153 + <de-audio-item 154 + id="${audio.id}" 155 + initial-progress="${ip}" 156 + url="${audio.url}" 157 + ${audio.isPreload ? "preload" : ""} 158 + ${audio.mimeType ? 'mime-type="' + audio.mimeType + '"' : ""} 159 + > 160 + <audio 161 + crossorigin="anonymous" 162 + muted="true" 163 + preload="auto" 164 + > 165 + <source 166 + src="${audio.url}" 167 + ${audio.mimeType ? 'type="' + audio.mimeType + '"' : ""} 168 + /> 169 + </audio> 170 + </de-audio-item> 171 + `; 172 + }); 173 + 174 + return html` 175 + <section id="audio-nodes"> 176 + ${nodes.join("")} 177 + </section> 178 + `; 179 + } 180 + 181 + // 🛠️ 182 + 183 + /** 184 + * @param {string} audioId 185 + * @param {(audio: HTMLAudioElement, item: AudioEngineItem) => void} fn 186 + */ 187 + withAudioNode(audioId, fn) { 188 + const node = this.querySelector( 189 + `de-audio-item[id="${audioId}"]:not([preload])`, 190 + ); 191 + 192 + if (node) { 193 + const item = /** @type {AudioEngineItem} */ (node); 194 + fn(item.audio, item); 195 + } 196 + } 197 + } 198 + 199 + export default AudioEngine; 200 + 201 + //////////////////////////////////////////// 202 + // ITEM ELEMENT 203 + //////////////////////////////////////////// 204 + 205 + export class AudioEngineItem extends HTMLElement { 206 + /** 207 + * @type {AudioState} 208 + */ 209 + #state; 210 + 211 + constructor() { 212 + super(); 213 + 214 + const ip = this.getAttribute("initial-progress"); 215 + 216 + this.#state = { 217 + duration: 0, 218 + hasEnded: false, 219 + id: this.id, 220 + isPlaying: true, 221 + isPreload: this.hasAttribute("preload"), 222 + loadingState: "loading", 223 + mimeType: this.getAttribute("mime-type") ?? undefined, 224 + progress: ip ? parseFloat(ip) : 0, 225 + url: this.getAttribute("url") ?? "", 226 + }; 227 + 228 + const audio = this.audio; 229 + 230 + audio.addEventListener("canplay", this.canplayEvent); 231 + audio.addEventListener("durationchange", this.durationchangeEvent); 232 + audio.addEventListener("ended", this.endedEvent); 233 + audio.addEventListener("error", this.errorEvent); 234 + audio.addEventListener("pause", this.pauseEvent); 235 + audio.addEventListener("play", this.playEvent); 236 + audio.addEventListener("suspend", this.suspendEvent); 237 + audio.addEventListener("timeupdate", this.timeupdateEvent); 238 + audio.addEventListener("waiting", this.waitingEvent); 239 + } 240 + 241 + // RELATED ELEMENTS 242 + 243 + get audio() { 244 + const el = this.querySelector("audio"); 245 + if (el) return /** @type {HTMLAudioElement} */ (el); 246 + else throw new Error("Cannot find child audio element"); 247 + } 248 + 249 + get engine() { 250 + const el = this.closest("de-audio"); 251 + if (el) return /** @type {AudioEngine} */ (el); 252 + else throw new Error("Cannot find parent de-audio element"); 253 + } 254 + 255 + // STATE 256 + 257 + get state() { 258 + return { ...this.#state }; 259 + } 260 + 261 + /** 262 + * @param {Partial<AudioState>} s 263 + */ 264 + set state(s) { 265 + this.#state = { ...this.#state, ...s }; 266 + } 267 + 268 + // EVENTS 269 + 270 + /** 271 + * @param {Event} event 272 + */ 273 + canplayEvent(event) { 274 + const audio = /** @type {HTMLAudioElement} */ (event.target); 275 + const item = engineItem(audio); 276 + 277 + if ( 278 + item.hasAttribute("initial-progress") && 279 + audio.duration && 280 + !isNaN(audio.duration) 281 + ) { 282 + const progress = JSON.parse( 283 + item.getAttribute("initial-progress") ?? "0", 284 + ); 285 + audio.currentTime = audio.duration * progress; 286 + item.removeAttribute("initial-progress"); 287 + } 288 + 289 + finishedLoading(event); 290 + } 291 + 292 + /** 293 + * @param {Event} event 294 + */ 295 + durationchangeEvent(event) { 296 + const audio = /** @type {HTMLAudioElement} */ (event.target); 297 + 298 + if (!isNaN(audio.duration)) { 299 + engineItem(audio).state = { duration: audio.duration }; 300 + } 301 + } 302 + 303 + /** 304 + * @param {Event} event 305 + */ 306 + endedEvent(event) { 307 + const audio = /** @type {HTMLAudioElement} */ (event.target); 308 + audio.currentTime = 0; 309 + 310 + engineItem(audio).state = { hasEnded: true }; 311 + } 312 + 313 + /** 314 + * @param {Event} event 315 + */ 316 + errorEvent(event) { 317 + const audio = /** @type {HTMLAudioElement} */ (event.target); 318 + const code = audio.error?.code || 0; 319 + 320 + engineItem(audio).state = { loadingState: { error: { code } } }; 321 + } 322 + 323 + /** 324 + * @param {Event} event 325 + */ 326 + pauseEvent(event) { 327 + const audio = /** @type {HTMLAudioElement} */ (event.target); 328 + 329 + const item = engineItem(audio).state; 330 + const ended = item ? item.hasEnded || item.progress === 1 : false; 331 + 332 + engineItem(audio).state = { isPlaying: false }; 333 + engineItem(audio).engine.isPlaying(ended); 334 + } 335 + 336 + /** 337 + * @param {Event} event 338 + */ 339 + playEvent(event) { 340 + const audio = /** @type {HTMLAudioElement} */ (event.target); 341 + 342 + engineItem(audio).state = { isPlaying: true }; 343 + engineItem(audio).engine.isPlaying(true); 344 + 345 + // In case audio was preloaded: 346 + if (audio.readyState === 4) finishedLoading(event); 347 + } 348 + 349 + /** 350 + * @param {Event} event 351 + */ 352 + suspendEvent(event) { 353 + finishedLoading(event); 354 + } 355 + 356 + /** 357 + * @param {Event} event 358 + */ 359 + timeupdateEvent(event) { 360 + const audio = /** @type {HTMLAudioElement} */ (event.target); 361 + 362 + engineItem(audio).state = { 363 + progress: isNaN(audio.duration) || audio.duration === 0 364 + ? 0 365 + : audio.currentTime / audio.duration, 366 + }; 367 + } 368 + 369 + /** 370 + * @param {Event} event 371 + */ 372 + waitingEvent(event) { 373 + initiateLoading(event); 374 + } 375 + } 376 + 377 + //////////////////////////////////////////// 378 + // 🛠️ 379 + //////////////////////////////////////////// 380 + 381 + /** 382 + * @param {HTMLAudioElement} audio 383 + */ 384 + function engineItem(audio) { 385 + const c = audio.closest("de-audio-item"); 386 + if (c) return /** @type {AudioEngineItem} */ (c); 387 + else throw new Error("Cannot find parent de-audio-item element"); 388 + } 389 + 390 + /** 391 + * @param {Event} event 392 + */ 393 + function finishedLoading(event) { 394 + const audio = /** @type {HTMLAudioElement} */ (event.target); 395 + engineItem(audio).state = { loadingState: "loaded" }; 396 + } 397 + 398 + /** 399 + * @param {Event} event 400 + */ 401 + function initiateLoading(event) { 402 + const audio = /** @type {HTMLAudioElement} */ (event.target); 403 + if (audio.readyState < 4) { 404 + engineItem(audio).state = { loadingState: "loading" }; 405 + } 406 + } 407 + 408 + //////////////////////////////////////////// 409 + // REGISTER 410 + //////////////////////////////////////////// 411 + 412 + customElements.define("de-audio", AudioEngine); 413 + customElements.define("de-audio-item", AudioEngineItem);
+2 -2
src/theme/blur/index.vto
··· 4 4 <link rel="stylesheet" href="../../styles/theme/blur/index.css" /> 5 5 6 6 <script type="module"> 7 - import "../../elements/constituent/blur/browser-list/index.js"; 7 + import "../../elements/engine/audio/index.js"; 8 8 </script> 9 9 </head> 10 10 <body> 11 - <constituent-blur-browser-list></constituent-blur-browser-list> 11 + <de-audio></de-audio> 12 12 </body> 13 13 </html>