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

Configure Feed

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

feat: leader election for audio

+228 -30
+6 -1
_config.ts
··· 2 2 3 3 import esbuild from "lume/plugins/esbuild.ts"; 4 4 import postcss from "lume/plugins/postcss.ts"; 5 + import sourceMaps from "lume/plugins/source_maps.ts"; 5 6 6 7 import * as path from "@std/path"; 7 8 import { ensureDirSync } from "@std/fs/ensure-dir"; ··· 19 20 extensions: [".js"], 20 21 options: { 21 22 bundle: true, 22 - // minify: true, 23 + minify: false, 23 24 // outExtension: { ".js": ".min.js" }, 24 25 splitting: true, 25 26 }, ··· 37 38 site.add("/favicons"); 38 39 site.add("/fonts"); 39 40 site.add("/images"); 41 + 42 + // MISC 43 + 44 + site.use(sourceMaps()); 40 45 41 46 // SCRIPTS 42 47
+2
deno.jsonc
··· 8 8 "@mys/worker-fn": "jsr:@mys/worker-fn@^3.2.1", 9 9 "@okikio/transferables": "jsr:@okikio/transferables@^1.0.2", 10 10 "alien-signals": "npm:alien-signals@^3.0.0", 11 + "broadcast-channel": "npm:broadcast-channel@^7.1.0", 11 12 "morphdom": "npm:morphdom@^2.7.7/dist/morphdom.js", 13 + "tab-election": "npm:tab-election@^4.2.8", 12 14 "xxh32": "npm:xxh32@^2.0.5", 13 15 14 16 // Paths
+68
deno.lock
··· 3 3 "specifiers": { 4 4 "jsr:@deno/loader@0.3.6": "0.3.6", 5 5 "jsr:@fry69/deep-diff@~0.1.10": "0.1.10", 6 + "jsr:@mys/m-rpc@~0.12.2": "0.12.2", 7 + "jsr:@mys/worker-fn@^3.2.1": "3.2.1", 8 + "jsr:@okikio/transferables@^1.0.2": "1.0.2", 6 9 "jsr:@std/cli@1.0.22": "1.0.22", 7 10 "jsr:@std/cli@^1.0.21": "1.0.22", 8 11 "jsr:@std/collections@^1.1.3": "1.1.3", ··· 34 37 "npm:@types/node@*": "24.2.0", 35 38 "npm:alien-signals@3": "3.0.1", 36 39 "npm:autoprefixer@10.4.21": "10.4.21_postcss@8.5.6", 40 + "npm:broadcast-channel@^7.1.0": "7.1.0", 37 41 "npm:lightningcss-wasm@1.30.1": "1.30.1", 38 42 "npm:markdown-it-attrs@4.3.1": "4.3.1_markdown-it@14.1.0", 39 43 "npm:markdown-it-deflist@3.0.0": "3.0.0", ··· 41 45 "npm:morphdom@^2.7.7": "2.7.7", 42 46 "npm:postcss-import@16.1.1": "16.1.1_postcss@8.5.6", 43 47 "npm:postcss@8.5.6": "8.5.6", 48 + "npm:tab-election@^4.2.8": "4.2.8", 44 49 "npm:xxh32@^2.0.5": "2.0.5" 45 50 }, 46 51 "jsr": { ··· 50 55 "@fry69/deep-diff@0.1.10": { 51 56 "integrity": "cdd88fefaef1ac896a038a5f3c0895038d8c725e61bac50489c455156e0275f5" 52 57 }, 58 + "@mys/m-rpc@0.12.2": { 59 + "integrity": "36599d3d4708db9f5c0f7da35a17b7e7da1fafddb69de6cfcdc6afe94cd4f084", 60 + "dependencies": [ 61 + "jsr:@okikio/transferables" 62 + ] 63 + }, 64 + "@mys/worker-fn@3.2.1": { 65 + "integrity": "330960f21041edd20fa9c5f78b136f62e3781e35797ac635534f003545be76cd", 66 + "dependencies": [ 67 + "jsr:@mys/m-rpc" 68 + ] 69 + }, 70 + "@okikio/transferables@1.0.2": { 71 + "integrity": "46a80015a1c4672b0b246e38838b3ea1e2edc6c775a235184a2f8eb49a8314f7" 72 + }, 53 73 "@std/cli@1.0.22": { 54 74 "integrity": "50d1e4f87887cb8a8afa29b88505ab5081188f5cad3985460c3b471fa49ff21a" 55 75 }, ··· 137 157 } 138 158 }, 139 159 "npm": { 160 + "@babel/runtime@7.27.0": { 161 + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", 162 + "dependencies": [ 163 + "regenerator-runtime" 164 + ] 165 + }, 140 166 "@types/node@24.2.0": { 141 167 "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", 142 168 "dependencies": [ ··· 166 192 "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", 167 193 "bin": true 168 194 }, 195 + "broadcast-channel@7.1.0": { 196 + "integrity": "sha512-InJljddsYWbEL8LBnopnCg+qMQp9KcowvYWOt4YWrjD5HmxzDYKdVbDS1w/ji5rFZdRD58V5UxJPtBdpEbEJYw==", 197 + "dependencies": [ 198 + "@babel/runtime", 199 + "oblivious-set", 200 + "p-queue", 201 + "unload" 202 + ] 203 + }, 169 204 "browserslist@4.26.3": { 170 205 "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", 171 206 "dependencies": [ ··· 188 223 }, 189 224 "escalade@3.2.0": { 190 225 "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" 226 + }, 227 + "eventemitter3@4.0.7": { 228 + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" 191 229 }, 192 230 "fraction.js@4.3.7": { 193 231 "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==" ··· 253 291 "normalize-range@0.1.2": { 254 292 "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==" 255 293 }, 294 + "oblivious-set@1.4.0": { 295 + "integrity": "sha512-szyd0ou0T8nsAqHtprRcP3WidfsN1TnAR5yWXf2mFCEr5ek3LEOkT6EZ/92Xfs74HIdyhG5WkGxIssMU0jBaeg==" 296 + }, 297 + "p-finally@1.0.0": { 298 + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==" 299 + }, 300 + "p-queue@6.6.2": { 301 + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", 302 + "dependencies": [ 303 + "eventemitter3", 304 + "p-timeout" 305 + ] 306 + }, 307 + "p-timeout@3.2.0": { 308 + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", 309 + "dependencies": [ 310 + "p-finally" 311 + ] 312 + }, 256 313 "path-parse@1.0.7": { 257 314 "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" 258 315 }, ··· 291 348 "pify" 292 349 ] 293 350 }, 351 + "regenerator-runtime@0.14.1": { 352 + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" 353 + }, 294 354 "resolve@1.22.10": { 295 355 "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", 296 356 "dependencies": [ ··· 306 366 "supports-preserve-symlinks-flag@1.0.0": { 307 367 "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" 308 368 }, 369 + "tab-election@4.2.8": { 370 + "integrity": "sha512-qHmh4jCMh1KKppqhIz3VWQPXGicCWaq2xtx9a37ZQroqbcXmCVUmmaf2rvFb0WThoJr9iaimK0PyHTxPkXcrDQ==" 371 + }, 309 372 "uc.micro@2.1.0": { 310 373 "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" 311 374 }, 312 375 "undici-types@7.10.0": { 313 376 "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" 377 + }, 378 + "unload@2.4.1": { 379 + "integrity": "sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==" 314 380 }, 315 381 "update-browserslist-db@1.1.3_browserslist@4.26.3": { 316 382 "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", ··· 588 654 "jsr:@std/fs@^1.0.19", 589 655 "jsr:@std/path@^1.1.2", 590 656 "npm:alien-signals@3", 657 + "npm:broadcast-channel@^7.1.0", 591 658 "npm:morphdom@^2.7.7", 659 + "npm:tab-election@^4.2.8", 592 660 "npm:xxh32@^2.0.5" 593 661 ] 594 662 }
+11
src/common/lock.js
··· 1 + /** 2 + * @returns {PromiseWithResolvers<void> & { status: PromiseWithResolvers<"acquired" | "waiting"> }} 3 + */ 4 + export function lock() { 5 + const w = Promise.withResolvers(); 6 + 7 + return { 8 + ...w, 9 + status: Promise.withResolvers(), 10 + }; 11 + }
+1 -1
src/common/worker.js
··· 4 4 import { xxh32 } from "xxh32"; 5 5 6 6 /** 7 - * @import {NodeWorkerOrNodeMessagePort, WorkerGlobalScope} from "@mys/m-rpc"; 7 + * @import {WorkerGlobalScope} from "@mys/m-rpc"; 8 8 * @import {Announcement} from "./worker.d.ts" 9 9 */ 10 10
+111 -17
src/component/engine/audio/element.js
··· 1 1 import DiffuseElement from "@common/element.js"; 2 2 import { signal } from "@common/signal.js"; 3 + import { define, use } from "@common/worker.js"; 4 + import { lock } from "@common/lock.js"; 3 5 4 6 /** 5 7 * @import {Actions, Audio, AudioState, Signals, State} from "./types.d.ts" ··· 21 23 * @implements {Signals} 22 24 */ 23 25 class AudioEngine extends DiffuseElement { 24 - static observedAttributes = ["is-playing", "volume"]; 26 + // TODO: 27 + // static observedAttributes = ["volume"]; 25 28 26 29 constructor() { 27 30 super(); 28 31 32 + // Group 33 + const group = this.getAttribute("group") || crypto.randomUUID(); 34 + const isShared = this.hasAttribute("group"); 35 + 36 + // Setup leader election if shared 37 + if (isShared) { 38 + const name = `diffuse/engine/audio/${group}`; 39 + 40 + const channel = new BroadcastChannel(name); 41 + const msg = new MessageChannel(); 42 + 43 + channel.onmessage = (event) => msg.port1.postMessage(event.data); 44 + msg.port1.addEventListener( 45 + "message", 46 + (event) => channel.postMessage(event.data), 47 + ); 48 + 49 + msg.port1.start(); 50 + msg.port2.start(); 51 + 52 + // Port 1 = Incoming, from channel. 53 + // Port 2 = Outgoing, to channel. 54 + 55 + this.lock = lock(); 56 + 57 + define("pause", this.#pause.bind(this), msg.port2); 58 + define("play", this.#play.bind(this), msg.port2); 59 + define("reload", this.#reload.bind(this), msg.port2); 60 + define("seek", this.#seek.bind(this), msg.port2); 61 + define("supply", this.#supply.bind(this), msg.port2); 62 + 63 + /** 64 + * @param {string} method 65 + * @param {Function} fn 66 + */ 67 + const u = (method, fn) => { 68 + /** @param {any[]} args */ 69 + return async (...args) => { 70 + const status = await this.lock?.status.promise; 71 + return status === "waiting" 72 + ? use(method, msg.port2)(...args) 73 + : fn.call(this, ...args); 74 + }; 75 + }; 76 + 77 + this.pause = u("pause", this.#pause); 78 + this.play = u("play", this.#play); 79 + this.reload = u("reload", this.#reload); 80 + this.seek = u("seek", this.#seek); 81 + this.supply = u("supply", this.#supply); 82 + } else { 83 + this.pause = this.#pause; 84 + this.play = this.#play; 85 + this.reload = this.#reload; 86 + this.seek = this.#seek; 87 + this.supply = this.#supply; 88 + } 89 + 29 90 // TODO: Get volume from previous session if possible 30 91 // const VOLUME_KEY = `@elements/engine/audio/${this.groupId}/volume`; 31 92 // const vol = localStorage.getItem(VOLUME_KEY); ··· 35 96 36 97 volume = signal(0.5); 37 98 isPlaying = signal(false); 38 - items = signal(/** @type {Audio[]} */ ([])); 99 + #items = signal(/** @type {Audio[]} */ ([])); 39 100 40 101 // STATE 41 102 ··· 45 106 get state() { 46 107 return { 47 108 isPlaying: this.isPlaying, 48 - items: this.items, 109 + items: this.#items, 49 110 volume: this.volume, 50 111 }; 51 112 } ··· 58 119 connectedCallback() { 59 120 super.connectedCallback(); 60 121 122 + // Monitor volume 123 + // NOTE: Support different volume levels for audio elements? 61 124 this.effect(() => { 62 - // NOTE: Support different volume levels for audio elements? 63 - 64 125 Array.from(this.querySelectorAll("de-audio-item audio")).forEach( 65 126 (node) => { 66 127 const audio = /** @type {HTMLAudioElement} */ (node); ··· 69 130 }, 70 131 ); 71 132 }); 133 + 134 + // Setup leader election if shared 135 + const isShared = this.hasAttribute("group"); 136 + const elementLock = this.lock; 137 + 138 + if (isShared && elementLock) { 139 + navigator.locks.request( 140 + `${name}/lock`, 141 + { ifAvailable: true }, 142 + (lock) => { 143 + elementLock.status.resolve(lock ? "acquired" : "waiting"); 144 + if (lock) return elementLock.promise; 145 + }, 146 + ); 147 + 148 + elementLock.status.promise.then((status) => { 149 + const name = `diffuse/engine/audio/${ 150 + this.getAttribute("group") || "main" 151 + }`; 152 + 153 + if (status === "acquired") { 154 + console.log(`🧙 Elected leader for: ${name}`); 155 + } else { 156 + console.log(`🔮 Watching leader: ${name}`); 157 + } 158 + }); 159 + } 72 160 } 73 161 74 - // ACTIONS 162 + /** 163 + * @override 164 + */ 165 + disconnectedCallback() { 166 + super.disconnectedCallback(); 167 + if (this.lock) this.lock.resolve(); 168 + } 169 + 170 + // ACTIONS (PRIVATE) 75 171 76 172 /** 77 173 * @type {Actions["pause"]} 78 174 */ 79 - pause({ audioId }) { 175 + #pause({ audioId }) { 80 176 this.withAudioNode(audioId, (audio) => audio.pause()); 81 177 } 82 178 83 179 /** 84 180 * @type {Actions["play"]} 85 181 */ 86 - play({ audioId, volume }) { 182 + #play({ audioId, volume }) { 87 183 this.withAudioNode(audioId, (audio, item) => { 88 184 audio.volume = volume ?? this.state.volume(); 89 185 audio.muted = false; ··· 109 205 /** 110 206 * @type {Actions["reload"]} 111 207 */ 112 - reload(args) { 208 + #reload(args) { 113 209 this.withAudioNode(args.audioId, (audio, item) => { 114 210 if (audio.readyState === 0 || audio.error?.code === 2) { 115 211 audio.load(); ··· 122 218 } 123 219 124 220 if (args.play) { 125 - this.play({ audioId: args.audioId, volume: audio.volume }); 221 + this.#play({ audioId: args.audioId, volume: audio.volume }); 126 222 } 127 223 } 128 224 }); ··· 131 227 /** 132 228 * @type {Actions["seek"]} 133 229 */ 134 - seek({ audioId, percentage }) { 230 + #seek({ audioId, percentage }) { 135 231 this.withAudioNode(audioId, (audio) => { 136 232 if (!isNaN(audio.duration)) { 137 233 audio.currentTime = audio.duration * percentage; ··· 140 236 } 141 237 142 238 /** 143 - * @type {Actions["yield"]} 239 + * @type {Actions["supply"]} 144 240 */ 145 - yield(args) { 146 - this.items(args.audio); 147 - if (args.play) this.play(args.play); 241 + #supply(args) { 242 + this.#items(args.audio); 243 + if (args.play) this.#play(args.play); 148 244 } 149 245 150 246 // RENDER ··· 153 249 * @param {RenderArg<State>} _ 154 250 */ 155 251 render({ html, state }) { 156 - console.log("Render"); 157 - 158 252 const nodes = state.items().map((audio) => { 159 253 const ip = audio.progress === undefined 160 254 ? "0"
+4 -3
src/component/engine/audio/types.d.ts
··· 5 5 play: (_: { audioId: string; volume?: number }) => void; 6 6 reload: (_: { audioId: string; play: boolean; progress?: number }) => void; 7 7 seek: (_: { audioId: string; percentage: number }) => void; 8 - yield: ( 8 + supply: ( 9 9 _: { audio: Audio[]; play?: { audioId: string; volume?: number } }, 10 10 ) => void; 11 11 } ··· 38 38 39 39 export interface Signals { 40 40 isPlaying: Signal<boolean>; 41 - items: Signal<Audio[]>; 42 41 volume: Signal<number>; 43 42 } 44 43 45 - export type State = Signals; 44 + export type State = Signals & { 45 + items: Signal<Audio[]>; 46 + };
+4 -2
src/component/engine/queue/element.js
··· 18 18 constructor() { 19 19 super(); 20 20 21 - // Setup worker 21 + // Group 22 22 const group = this.getAttribute("group") || crypto.randomUUID(); 23 - const isShared = this.hasAttribute("shared"); 23 + const isShared = this.hasAttribute("group"); 24 + 25 + // Setup worker 24 26 const name = `diffuse/engine/queue/${group}`; 25 27 const url = new URL("./worker.js", import.meta.url); 26 28
+21 -6
src/theme/blur/index.vto
··· 4 4 <link rel="stylesheet" href="../../styles/theme/blur/index.css" /> 5 5 </head> 6 6 <body> 7 - <de-queue group="deck-a" shared></de-queue> 7 + <de-audio group="deck-a"></de-audio> 8 + <de-queue group="deck-a"></de-queue> 8 9 9 10 <script type="module"> 11 + import * as Audio from "../../component/engine/audio/element.js"; 10 12 import * as Queue from "../../component/engine/queue/element.js"; 11 13 import { effect } from "../../common/signal.js" 12 14 15 + const audio = document.querySelector(Audio.NAME) 13 16 const queue = document.querySelector(Queue.NAME) 14 17 15 - effect(() => { 16 - console.log("Future:", queue.future()) 18 + // https://archive.org/download/deathofsalesmans00mill/01_Side_1_Death_of_a_salesman_-_Introduction_Act_1__Part_1.mp3 19 + 20 + await audio.supply({ 21 + audio: [ 22 + { 23 + id: "test", 24 + isPreload: false, 25 + url: "https://archive.org/download/deathofsalesmans00mill/01_Side_1_Death_of_a_salesman_-_Introduction_Act_1__Part_1.mp3" 26 + } 27 + ] 17 28 }) 18 29 19 - effect(() => { 20 - console.log("Now:", queue.now()) 21 - }) 30 + // effect(() => { 31 + // console.log("Future:", queue.future()) 32 + // }) 33 + 34 + // effect(() => { 35 + // console.log("Now:", queue.now()) 36 + // }) 22 37 </script> 23 38 </body> 24 39 </html>