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.

chore: webamp and scrobble stuff

+585 -68
+2
deno.jsonc
··· 50 50 "temporal-polyfill": "npm:temporal-polyfill@^0.3.2", 51 51 "throttle-debounce": "npm:throttle-debounce@^5.0.2", 52 52 "xxh32": "npm:xxh32@^2.0.5", 53 + "butterchurn": "npm:butterchurn@3.0.0-beta.5", 54 + "butterchurn-presets": "npm:butterchurn-presets@3.0.0-beta.4", 53 55 "webamp": "npm:webamp@^2.2.0", 54 56 55 57 // music-metadata
+2 -2
src/components/orchestrator/scrobble-audio/element.js
··· 168 168 169 169 const durationSec = this.audio?.state(id)?.duration() ?? 0; 170 170 171 - // last.fm: track must be at least 30 seconds 171 + // Track must be at least 30 seconds 172 172 if (durationSec < 30) return; 173 173 174 - // last.fm: must have listened to min(half the track, 4 minutes) 174 + // Must have listened to at least half the track or 4 minutes 175 175 const listenedSec = this.#totalListenedMs() / 1000; 176 176 if (listenedSec < Math.min(durationSec / 2, 240)) return; 177 177
-1
src/facets/misc/scrobble/last.fm/index.inline.js
··· 34 34 } 35 35 36 36 const configurator = await foundation.configurator.scrobbles(); 37 - const orchestrator = await foundation.orchestrator.scrobbleAudio(); 38 37 39 38 /** @type {import("~/components/supplement/last.fm/element.js").CLASS | null} */ 40 39 let lastFm = configurator.querySelector("ds-lastfm-scrobbler");
+3 -2
src/index.vto
··· 52 52 <p style="margin: var(--space-lg) 0"> 53 53 <a class="button button--bg-twist-4" href="dashboard/">Open Diffuse</a> 54 54 </p> 55 + <div style=""> 56 + Like using Diffuse, or its components? <a href="https://ko-fi.com/toko">Support with a donation!</a> 57 + </div> 55 58 <p> 56 59 <small style="line-height: var(--leading-relaxed)"> 57 60 Built with <a href="elements/">Diffuse elements</a><br /> ··· 61 64 </div> 62 65 <div class="dither-mask filler filler--bg-twist-4"></div> 63 66 </header> 64 - <main> 65 - </main>
+154 -63
src/themes/winamp/facet/index.inline.js
··· 1 1 import foundation from "~/common/foundation.js"; 2 - import { effect, untracked } from "~/common/signal.js"; 2 + import { effect } from "~/common/signal.js"; 3 3 4 4 import WindowManager from "~/themes/winamp/window-manager/element.js"; 5 5 import WebampElement from "~/themes/winamp/webamp/element.js"; 6 + import { setAudioEngine, setCurrentTrackIdResolver } from "~/themes/winamp/webamp/media.js"; 6 7 7 8 // Set doc title 8 9 document.title = "Winamp | Diffuse"; ··· 17 18 */ 18 19 19 20 const input = await foundation.configurator.input(); 21 + const audio = await foundation.engine.audio(); 20 22 const queue = await foundation.engine.queue(); 23 + const repeatShuffle = await foundation.engine.repeatShuffle(); 21 24 const scopedTracks = await foundation.orchestrator.scopedTracks(); 22 25 const search = await foundation.processor.search(); 23 26 24 27 await foundation.orchestrator.sources(); 25 28 await foundation.orchestrator.processTracks({ disableWhenReady: true }); 29 + await foundation.orchestrator.queueAudio(); 26 30 27 31 await import("~/themes/winamp/browser/element.js"); 28 32 await import("~/themes/winamp/window/element.js"); ··· 34 38 globalThis.queue = queue; 35 39 globalThis.output = output; 36 40 37 - //////////////////////////////////////////// 38 - // 📡 39 - //////////////////////////////////////////// 40 - 41 - /** @type {Record<string, number>} */ 42 - const index = {}; 43 - 44 - /** @type {boolean} */ 45 - let initiatedPlaylist = false; 41 + // Provide refs to the media class 42 + setAudioEngine(audio); 43 + setCurrentTrackIdResolver(() => { 44 + const idx = amp.store.getState().playlist?.currentTrack; 45 + return idx != null ? webampIndexToTrackId.get(idx) : undefined; 46 + }); 46 47 47 48 //////////////////////////////////////////// 48 49 // ⚡️ ··· 55 56 56 57 const amp = ampElement.amp; 57 58 58 - // Override track loader 59 - const loadFromUrl = amp.media.loadFromUrl.bind(amp.media); 59 + //////////////////////////////////////////// 60 + // 📡 61 + //////////////////////////////////////////// 60 62 61 - /** 62 - * @param {string} uri 63 - * @param {boolean} autoPlay 64 - */ 65 - async function loadOverride(uri, autoPlay) { 66 - if (uri.startsWith("blob:")) { 67 - return await loadFromUrl(uri, autoPlay); 68 - } 63 + // Sync audio engine volume → webamp 64 + effect(() => { 65 + amp.store.dispatch({ type: "SET_VOLUME", volume: Math.round(audio.volume() * 100) }); 66 + }); 69 67 70 - const resp = await input.resolve({ method: "GET", uri }); 71 - if (!resp) throw new Error("Failed to resolve URI"); 72 - if (resp && "stream" in resp) { 73 - throw new Error("Webamp does not support playing streams."); 68 + // Sync Diffuse repeat → webamp 69 + effect(() => { 70 + const repeat = repeatShuffle.repeat(); 71 + if (amp.store.getState().media.repeat !== repeat) { 72 + amp.store.dispatch({ type: "TOGGLE_REPEAT" }); 74 73 } 74 + }); 75 75 76 - return await loadFromUrl(resp.url, autoPlay); 77 - } 76 + /** Maps webamp playlist index → Diffuse track ID */ 77 + /** @type {Map<number, string>} */ 78 + const webampIndexToTrackId = new Map(); 78 79 79 - amp.media.loadFromUrl = loadOverride.bind(amp.media); 80 - 81 - //////////////////////////////////////////// 82 - // 📡 83 - //////////////////////////////////////////// 80 + /** 81 + * Set when we are pushing changes to webamp so the store subscriber 82 + * doesn't try to sync the queue back in response. 83 + */ 84 + let isWebampUpdate = false; 84 85 85 86 /** 86 - * Whenever the queue changes update the playlist. 87 + * Rebuild the webamp playlist to exactly match the Diffuse queue. 87 88 */ 88 89 effect(() => { 89 - const past = untracked(() => queue.past()); 90 - const now = untracked(() => queue.now()); 90 + const past = queue.past(); 91 + const now = queue.now(); 91 92 const future = queue.future(); 92 93 const list = [...past, ...(now ? [now] : []), ...future]; 93 94 94 - /** @type {Record<string, number>} */ 95 - const newIdx = {}; 95 + const tracksCol = output.tracks.collection(); 96 + if (tracksCol.state !== "loaded") return; 97 + const tracksList = tracksCol.data; 98 + 99 + const existing = amp.getPlaylistTracks(); 96 100 97 - list.forEach((item) => { 98 - newIdx[item.id] = (newIdx[item.id] ?? 0) + 1; 99 - }); 101 + isWebampUpdate = true; 100 102 101 - /** @type {Track[]} */ 102 - const tracksToAdd = []; 103 + amp.store.dispatch( 104 + /** 105 + * @param {any} dispatch 106 + */ 107 + (dispatch) => { 108 + // Remove all current tracks 109 + if (existing.length > 0) { 110 + dispatch({ type: "REMOVE_TRACKS", ids: existing.map((t) => t.id) }); 111 + } 112 + 113 + webampIndexToTrackId.clear(); 114 + 115 + // Re-add every queue item in order 116 + list.forEach((item, idx) => { 117 + const track = tracksList.find((t) => t.id === item.id); 118 + if (!track) return; 119 + 120 + dispatch({ 121 + type: "ADD_TRACK_FROM_URL", 122 + url: track.uri, 123 + duration: track.stats?.duration != null 124 + ? track.stats.duration / 1000 125 + : undefined, 126 + defaultName: undefined, 127 + id: idx, 128 + atIndex: idx, 129 + }); 103 130 104 - const tracksCol = output.tracks.collection(); 105 - const tracksList = tracksCol.state === "loaded" ? tracksCol.data : []; 131 + dispatch({ 132 + type: "SET_MEDIA_DURATION", 133 + duration: track.stats?.duration != null 134 + ? track.stats.duration / 1000 135 + : undefined, 136 + id: idx, 137 + }); 106 138 107 - Object.entries(newIdx).forEach(([id, n]) => { 108 - const x = index[id] ?? 0; 109 - if (n > x) { 110 - const track = tracksList.find((t) => t.id === id); 111 - if (track) tracksToAdd.push(track); 112 - index[id] = x + 1; 113 - } 114 - }); 139 + dispatch({ 140 + type: "SET_MEDIA_TAGS", 141 + artist: track.tags?.artist, 142 + title: track.tags?.title, 143 + album: track.tags?.album, 144 + sampleRate: track.stats?.sampleRate ?? 44000, 145 + bitrate: track.stats?.bitrate ?? 192000, 146 + numberOfChannels: 2, 147 + id: idx, 148 + }); 115 149 116 - tracksToAdd.forEach((t) => ampElement.addTrack(t)); 150 + webampIndexToTrackId.set(idx, item.id); 151 + }); 117 152 118 - if (!initiatedPlaylist && tracksToAdd.length) { 119 - initiatedPlaylist = true; 120 - amp.store.dispatch({ type: "BUFFER_TRACK", id: 0 }); 121 - } 122 - }); 153 + // Point webamp at the current queue item 154 + const nowIdx = now ? list.findIndex((item) => item.id === now.id) : -1; 155 + if (nowIdx !== -1) { 156 + dispatch({ type: "BUFFER_TRACK", id: nowIdx }); 157 + } 158 + }, 159 + ); 123 160 124 - /** 125 - * Fill queue supply with available tracks. 126 - */ 127 - effect(() => { 128 - const tracks = scopedTracks.tracks(); 129 - queue.supply({ trackIds: tracks.map((t) => t.id) }); 161 + isWebampUpdate = false; 130 162 }); 131 163 132 164 /** ··· 145 177 if (fingerprintQueue === undefined) return; 146 178 147 179 tracksPromise.resolve("loaded"); 180 + }); 181 + 182 + /** 183 + * Keep Diffuse's queue in sync when webamp's active track changes. 184 + */ 185 + let prevWebampTrack = /** @type {number | null} */ (null); 186 + let prevWebampRepeat = amp.store.getState().media.repeat; 187 + let milkdropRandomized = false; 188 + 189 + amp.store.subscribe(() => { 190 + // Pick a random milkdrop preset the first time presets load 191 + if (!milkdropRandomized) { 192 + const presets = amp.store.getState().milkdrop?.presets; 193 + if (presets?.length > 0) { 194 + milkdropRandomized = true; 195 + amp.store.dispatch({ 196 + type: "SELECT_PRESET_AT_INDEX", 197 + index: Math.floor(Math.random() * presets.length), 198 + transitionType: 0, 199 + }); 200 + } 201 + } 202 + 203 + if (isWebampUpdate) return; 204 + 205 + // Sync webamp repeat → Diffuse 206 + const currentRepeat = amp.store.getState().media.repeat; 207 + if (currentRepeat !== prevWebampRepeat) { 208 + prevWebampRepeat = currentRepeat; 209 + repeatShuffle.setRepeat(currentRepeat); 210 + } 211 + 212 + const currentTrack = amp.store.getState().playlist?.currentTrack; 213 + if (currentTrack === prevWebampTrack) return; 214 + prevWebampTrack = currentTrack; 215 + if (currentTrack == null) return; 216 + 217 + const targetId = webampIndexToTrackId.get(currentTrack); 218 + if (!targetId || queue.now()?.id === targetId) return; 219 + 220 + const past = queue.past(); 221 + const future = queue.future(); 222 + 223 + const pastIdx = past.findLastIndex((item) => item.id === targetId); 224 + if (pastIdx !== -1) { 225 + const steps = past.length - pastIdx; 226 + for (let i = 0; i < steps; i++) queue.unshift(); 227 + return; 228 + } 229 + 230 + const futureIdx = future.findIndex((item) => item.id === targetId); 231 + if (futureIdx !== -1) { 232 + for (let i = 0; i <= futureIdx; i++) queue.shift(); 233 + // return; 234 + } 235 + 236 + // Track not in queue navigation — insert at front and make it current 237 + // queue.add({ trackIds: [targetId], inFront: true }); 238 + // queue.shift(); 148 239 }); 149 240 150 241 ////////////////////////////////////////////
+8
src/themes/winamp/index.css
··· 17 17 isolation: isolate; 18 18 } 19 19 20 + #webamp #eject { 21 + pointer-events: none; 22 + } 23 + 24 + #webamp-context-menu { 25 + z-index: 10000 !important; 26 + } 27 + 20 28 main > section { 21 29 inset: 0; 22 30 position: absolute;
+43
src/themes/winamp/webamp/element.js
··· 1 1 import Webamp from "webamp/lazy"; 2 + import DiffuseMedia from "./media.js"; 2 3 3 4 /** 4 5 * @import {Track} from "~/definitions/types.d.ts" ··· 14 15 enableMediaSession: true, 15 16 initialTracks: [], 16 17 zIndex: 99, 18 + __customMediaClass: DiffuseMedia, 19 + __butterchurnOptions: { 20 + importButterchurn: () => import("butterchurn"), 21 + async getPresets() { 22 + const { default: presets } = await import( 23 + "butterchurn-presets/dist/all" 24 + ); 25 + 26 + return Object.entries(presets).map(([name, preset]) => { 27 + // Some presets have shapes/waves with null baseVals which 28 + // causes butterchurn's overrideDefaultVars to throw. 29 + const p = /** @type {any} */ (preset); 30 + const fix = (arr) => 31 + (arr ?? []).map((e) => ({ 32 + ...e, 33 + baseVals: e.baseVals ?? {}, 34 + })); 35 + return { 36 + name, 37 + butterchurnPresetObject: { 38 + ...p, 39 + baseVals: p.baseVals ?? {}, 40 + shapes: fix(p.shapes), 41 + waves: fix(p.waves), 42 + }, 43 + }; 44 + }); 45 + }, 46 + butterchurnOpen: false, 47 + }, 48 + windowLayout: { 49 + main: { position: { top: 0, left: 0 } }, 50 + equalizer: { position: { top: 116, left: 0 } }, 51 + playlist: { 52 + position: { top: 232, left: 0 }, 53 + size: { extraHeight: 4, extraWidth: 0 }, 54 + }, 55 + milkdrop: { 56 + position: { top: 0, left: 275 }, 57 + size: { extraHeight: 4, extraWidth: 0 }, 58 + }, 59 + }, 17 60 18 61 /** */ 19 62 handleLoadListEvent: async () => {
+373
src/themes/winamp/webamp/media.js
··· 1 + /** 2 + * @import AudioEngine from "~/components/engine/audio/element.js" 3 + */ 4 + 5 + //////////////////////////////////////////// 6 + // REFS 7 + //////////////////////////////////////////// 8 + 9 + /** @type {AudioEngine | null} */ 10 + let audioEngineRef = null; 11 + 12 + /** 13 + * Resolves the Diffuse track ID for whatever track webamp is currently 14 + * buffering. Provided by the facet so it can close over `amp.store` and 15 + * `webampIndexToTrackId` without those references living inside this module. 16 + * 17 + * @type {(() => string | undefined) | null} 18 + */ 19 + let currentTrackIdResolverRef = null; 20 + 21 + /** 22 + * @param {AudioEngine} audio 23 + */ 24 + export function setAudioEngine(audio) { 25 + audioEngineRef = audio; 26 + } 27 + 28 + /** 29 + * @param {() => string | undefined} resolver 30 + */ 31 + export function setCurrentTrackIdResolver(resolver) { 32 + currentTrackIdResolverRef = resolver; 33 + } 34 + 35 + //////////////////////////////////////////// 36 + // EMITTER 37 + //////////////////////////////////////////// 38 + 39 + class Emitter { 40 + constructor() { 41 + /** @type {Record<string, Array<(...args: any[]) => void>>} */ 42 + this._listeners = {}; 43 + } 44 + 45 + /** 46 + * @param {string} event 47 + * @param {(...args: any[]) => void} callback 48 + */ 49 + on(event, callback) { 50 + if (!this._listeners[event]) this._listeners[event] = []; 51 + this._listeners[event].push(callback); 52 + 53 + return () => { 54 + this._listeners[event] = this._listeners[event].filter((cb) => 55 + cb !== callback 56 + ); 57 + }; 58 + } 59 + 60 + /** 61 + * @param {string} event 62 + * @param {...any} args 63 + */ 64 + trigger(event, ...args) { 65 + (this._listeners[event] ?? []).forEach((cb) => cb(...args)); 66 + } 67 + 68 + dispose() { 69 + this._listeners = {}; 70 + } 71 + } 72 + 73 + //////////////////////////////////////////// 74 + // MEDIA 75 + //////////////////////////////////////////// 76 + 77 + /** 78 + * IMedia implementation backed by Diffuse's AudioEngine. 79 + * 80 + * URI resolution is handled by the Diffuse input component. 81 + * Playback state is polled from the AudioEngine's de-audio-item element so 82 + * that webamp's UI stays in sync without a separate audio pipeline. 83 + */ 84 + class DiffuseMedia { 85 + constructor() { 86 + this._emitter = new Emitter(); 87 + 88 + /** @type {string | null} */ 89 + this._currentId = null; 90 + 91 + this._disposed = false; 92 + 93 + // Previous-state cache for edge detection in the poll loop 94 + this._prevTime = -1; 95 + this._prevPlaying = false; 96 + this._prevEnded = false; 97 + this._prevLoadingState = /** @type {any} */ (null); 98 + 99 + // Seek target held until the signal catches up, so timeElapsed() and the 100 + // poll loop both return the correct position during the stale window. 101 + /** @type {number | null} */ 102 + this._seekTarget = null; 103 + 104 + // Set when loadFromUrl is called with autoPlay=true; cleared once 105 + // the audio item reports "loaded" and we issue the play command. 106 + this._autoPlayPending = false; 107 + 108 + // AudioContext + EQ chain + analyser for webamp's spectrum visualizer. 109 + // Chain: source → preamp → eq[0..9] → analyser → destination 110 + this._context = new AudioContext(); 111 + 112 + // Preamp gain node (linear gain, default 1.0 = 0 dB) 113 + this._preampNode = this._context.createGain(); 114 + 115 + // 10-band peaking EQ at standard Winamp frequencies 116 + // webamp passes the frequency itself as the `band` argument to setEqBand 117 + this._eqFreqs = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000]; 118 + this._eqNodes = this._eqFreqs.map((freq) => { 119 + const f = this._context.createBiquadFilter(); 120 + f.type = "peaking"; 121 + f.frequency.value = freq; 122 + f.Q.value = 1.0; 123 + f.gain.value = 0; 124 + return f; 125 + }); 126 + this._eqEnabled = true; 127 + /** @type {number[]} stored band values in webamp's 0-100 scale (50 = flat) */ 128 + this._eqValues = new Array(10).fill(50); 129 + this._preampValue = 50; 130 + 131 + this._analyser = this._context.createAnalyser(); 132 + 133 + // Wire the chain 134 + this._preampNode.connect(this._eqNodes[0]); 135 + for (let i = 0; i < this._eqNodes.length - 1; i++) { 136 + this._eqNodes[i].connect(this._eqNodes[i + 1]); 137 + } 138 + this._eqNodes[this._eqNodes.length - 1].connect(this._analyser); 139 + this._analyser.connect(this._context.destination); 140 + 141 + /** @type {WeakSet<HTMLAudioElement>} */ 142 + this._connectedAudio = new WeakSet(); 143 + 144 + this._poll(); 145 + } 146 + 147 + // HELPERS 148 + 149 + /** @returns {any | null} */ 150 + _item() { 151 + const id = this._currentId; 152 + if (!id || !audioEngineRef) return null; 153 + return audioEngineRef.querySelector( 154 + `de-audio-item[id="${id}"]:not([preload])`, 155 + ); 156 + } 157 + 158 + // POLL LOOP 159 + 160 + _poll() { 161 + if (this._disposed) return; 162 + 163 + const id = this._currentId; 164 + 165 + if (id && audioEngineRef) { 166 + const item = /** @type {any} */ ( 167 + audioEngineRef.querySelector(`de-audio-item[id="${id}"]:not([preload])`) 168 + ); 169 + 170 + if (item) { 171 + const state = item.state; 172 + 173 + const rawTime = state.currentTime(); 174 + 175 + // Clear seek target once the signal has caught up 176 + if (this._seekTarget !== null && Math.abs(rawTime - this._seekTarget) < 0.5) { 177 + this._seekTarget = null; 178 + } 179 + 180 + const currentTime = this._seekTarget ?? rawTime; 181 + const isPlaying = state.isPlaying(); 182 + const hasEnded = state.hasEnded(); 183 + const loadingState = state.loadingState(); 184 + 185 + if (currentTime !== this._prevTime) { 186 + this._prevTime = currentTime; 187 + this._emitter.trigger("timeupdate"); 188 + } 189 + 190 + if (isPlaying && !this._prevPlaying) { 191 + this._emitter.trigger("playing"); 192 + } 193 + this._prevPlaying = isPlaying; 194 + 195 + if (hasEnded && !this._prevEnded) { 196 + this._emitter.trigger("ended"); 197 + } 198 + this._prevEnded = hasEnded; 199 + 200 + if (loadingState !== this._prevLoadingState) { 201 + if (loadingState === "loaded") { 202 + this._emitter.trigger("stopWaiting"); 203 + this._emitter.trigger("fileLoaded"); 204 + if (this._autoPlayPending) { 205 + this._autoPlayPending = false; 206 + if (id && audioEngineRef) audioEngineRef.play({ audioId: id }); 207 + } 208 + } else if (loadingState === "loading") { 209 + this._emitter.trigger("waiting"); 210 + } 211 + this._prevLoadingState = loadingState; 212 + } 213 + 214 + // Tap the <audio> element into our AudioContext once per track. 215 + // Direct element access is required here for the Web Audio API. 216 + const audioEl = /** @type {HTMLAudioElement | null} */ ( 217 + item.querySelector("audio") 218 + ); 219 + 220 + if (audioEl && !this._connectedAudio.has(audioEl)) { 221 + try { 222 + this._context.createMediaElementSource(audioEl).connect( 223 + this._preampNode, 224 + ); 225 + this._connectedAudio.add(audioEl); 226 + } catch { 227 + // Already owned by another context, or CORS restriction — ignore 228 + } 229 + } 230 + } 231 + } 232 + 233 + requestAnimationFrame(() => this._poll()); 234 + } 235 + 236 + // IMedia 237 + 238 + /** 239 + * @param {number} volume 0–100 240 + */ 241 + setVolume(volume) { 242 + audioEngineRef?.adjustVolume({ volume: volume / 100 }); 243 + } 244 + 245 + /** @param {number} _balance */ 246 + setBalance(_balance) {} 247 + 248 + /** 249 + * @param {string} event 250 + * @param {(...args: any[]) => void} callback 251 + */ 252 + on(event, callback) { 253 + return this._emitter.on(event, callback); 254 + } 255 + 256 + timeElapsed() { 257 + if (this._seekTarget !== null) return this._seekTarget; 258 + return this._item()?.state.currentTime() ?? 0; 259 + } 260 + 261 + duration() { 262 + const d = this._item()?.state.duration(); 263 + return !d || isNaN(d) ? 0 : d; 264 + } 265 + 266 + async play() { 267 + const id = this._currentId; 268 + if (!id || !audioEngineRef) return; 269 + if (this._context.state === "suspended") await this._context.resume(); 270 + audioEngineRef.play({ audioId: id }); 271 + } 272 + 273 + pause() { 274 + const id = this._currentId; 275 + if (id && audioEngineRef) audioEngineRef.pause({ audioId: id }); 276 + } 277 + 278 + stop() { 279 + const id = this._currentId; 280 + if (!id || !audioEngineRef) return; 281 + audioEngineRef.pause({ audioId: id }); 282 + audioEngineRef.seek({ audioId: id, currentTime: 0 }); 283 + } 284 + 285 + /** 286 + * @param {number} percent 0–100 287 + */ 288 + seekToPercentComplete(percent) { 289 + const id = this._currentId; 290 + if (id && audioEngineRef) { 291 + audioEngineRef.seek({ audioId: id, percentage: percent / 100 }); 292 + const dur = this._item()?.state.duration() ?? 0; 293 + this._seekTarget = dur * (percent / 100); 294 + } 295 + } 296 + 297 + /** 298 + * @param {string} _uri 299 + * @param {boolean} autoPlay 300 + */ 301 + async loadFromUrl(_uri, autoPlay) { 302 + // Audio node creation and playback are handled by the queueAudio 303 + // orchestrator. This method only needs to point the poll loop at the 304 + // correct Diffuse track and record whether autoPlay was requested so 305 + // we can trigger play once the audio item becomes ready. 306 + const id = currentTrackIdResolverRef?.() ?? null; 307 + if (!id) return; 308 + 309 + this._currentId = id; 310 + this._autoPlayPending = autoPlay; 311 + 312 + // Reset poll state for the new track 313 + this._prevTime = -1; 314 + this._prevPlaying = false; 315 + this._prevEnded = false; 316 + this._prevLoadingState = null; 317 + this._seekTarget = null; 318 + 319 + this._emitter.trigger("waiting"); 320 + if (this._context.state === "suspended") await this._context.resume(); 321 + } 322 + 323 + /** @param {number} _value */ 324 + setPreamp(_value) { 325 + this._preampValue = _value; 326 + if (this._eqEnabled) { 327 + const dB = (_value - 50) / 50 * 12; 328 + this._preampNode.gain.value = Math.pow(10, dB / 20); 329 + } 330 + } 331 + 332 + /** 333 + * @param {number} band frequency in Hz (60 | 170 | 310 | 600 | 1000 | 3000 | 6000 | 12000 | 14000 | 16000) 334 + * @param {number} value 0–100 (50 = flat, 0 = −12 dB, 100 = +12 dB) 335 + */ 336 + setEqBand(band, value) { 337 + const idx = this._eqFreqs.indexOf(band); 338 + if (idx === -1) return; 339 + this._eqValues[idx] = value; 340 + if (this._eqEnabled) { 341 + this._eqNodes[idx].gain.value = (value - 50) / 50 * 12; 342 + } 343 + } 344 + 345 + enableEq() { 346 + this._eqEnabled = true; 347 + this._eqValues.forEach((value, i) => { 348 + this._eqNodes[i].gain.value = (value - 50) / 50 * 12; 349 + }); 350 + const dB = (this._preampValue - 50) / 50 * 12; 351 + this._preampNode.gain.value = Math.pow(10, dB / 20); 352 + } 353 + 354 + disableEq() { 355 + this._eqEnabled = false; 356 + this._eqNodes.forEach((node) => { node.gain.value = 0; }); 357 + this._preampNode.gain.value = 1; 358 + } 359 + 360 + getAnalyser() { 361 + return this._analyser; 362 + } 363 + 364 + dispose() { 365 + this._disposed = true; 366 + this._emitter.dispose(); 367 + this._preampNode.disconnect(); 368 + this._eqNodes.forEach((n) => n.disconnect()); 369 + this._context.close(); 370 + } 371 + } 372 + 373 + export default DiffuseMedia;