A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108import { keyed } from "lit-html/directives/keyed.js";
import { BroadcastableDiffuseElement, defineElement, nothing,} from "~/common/element.js";import { computed, signal, untracked } from "~/common/signal.js";
/** * @import {Actions, AudioUrl, AudioState, AudioStateReadOnly, LoadingState} from "@specs/components/engine/audio/types.d.ts" * @import {RenderArg} from "~/common/element.d.ts" * @import {SignalReader} from "~/common/signal.d.ts" */
////////////////////////////////////////////// CONSTANTS/////////////////////////////////////////////** * Mobile Safari caps the number of live media elements and behaves poorly * when fresh <audio> nodes are created on every track change. On iOS we * therefore render a single <audio> element and reuse its DOM node across * track switches instead of keying one per item id. */const IS_IOS = /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
/** * Module-internal Web Audio routing hooks. Accessed only by {@link AudioEngine} * and {@link AudioEngineItem} (both live in this file), so these stay out of * the public surface — external consumers use `engine.webAudio` instead. * * @type {unique symbol} */const ROUTE_AUDIO = Symbol("routeAudio");/** @type {unique symbol} */const UNROUTE_AUDIO = Symbol("unrouteAudio");/** @type {unique symbol} */const CANCEL_PRELOAD = Symbol("cancelPreload");
/** * Automatic retry policy for transient media errors (network / CORS-handshake * failures). The delay doubles per attempt from {@link RETRY_BASE_DELAY_MS} * up to {@link RETRY_MAX_DELAY_MS}, then stays at the ceiling and keeps * retrying until playback succeeds, the user pauses, or the item is dropped. */const RETRY_BASE_DELAY_MS = 500;const RETRY_MAX_DELAY_MS = 30_000;
/** * How long a load may go without ANY data (`progress` events) before the load * watchdog treats it as hung and reloads. Doubles per trip up to * {@link RETRY_MAX_DELAY_MS}, then keeps checking at that ceiling. */const LOAD_WATCHDOG_MS = 3_000;
/** * How many consecutive data-less watchdog windows a still-fetching * (`NETWORK_LOADING`) load may wait through before the request is considered * hung and reloaded. A server that accepts the connection but never sends a * byte — a half-open connection after a network change or laptop sleep — keeps * the element in `NETWORK_LOADING` indefinitely, so without this bound the load * would wait forever. At the base window this is ~30s of patience. */const LOAD_WAIT_LIMIT = 10;
/** * How many consecutive data-less watchdog reloads an element may attempt * before recovery is declared futile. When the cause is not the element but * what it shares with everything else — the browser's connection pool to that * host (one wedged HTTP/2 connection carries all of them; on HTTP/1.1 six * hung sockets block the rest) or a server that accepts connections but never * answers — every fresh request pends forever too. Continuing the cycle * cannot recover and only occupies more of the connections recovery needs, * while piling up "pending" entries. Past the limit the element stops * reloading and surfaces a terminal error; the counter resets on data, * playback, a seek, refocus or `online`, each of which grants one fresh * bounded budget. */const DATALESS_RELOAD_LIMIT = 5;
////////////////////////////////////////////// ELEMENT////////////////////////////////////////////
/** * @implements {Actions} */class AudioEngine extends BroadcastableDiffuseElement { static NAME = "diffuse/engine/audio";
constructor() { super();
this.state = this.state.bind(this); }
/** @type {Map<string, string>} MediaSource object URLs created from streams, keyed by item ID */ #mediaSourceUrls = new Map();
/** @type {Map<string, ReadableStream>} Streams pending MediaSource setup */ #streams = new Map();
/** Aborts in-flight MediaSource setup when the element is disconnected. */ #streamAbort = new AbortController();
// WEB AUDIO // // Every <audio> element is routed through a shared AudioContext so consumers // (themes, equalizer / visualizer plugins, …) can tap into the signal. All // sources land on a single post-volume `input` node that is connected to the // destination by default; a consumer replaces that edge with its own chain // (e.g. `input → biquadFilter → destination`) to apply DSP. // // Chain (unless a consumer splices its nodes in): // mediaElementSource -> input -> destination
/** @type {AudioContext | undefined} Lazily created, shared by all items. */ #audioContext = undefined; /** @type {GainNode | undefined} Post-volume tap point that all sources feed. */ #input = undefined; /** @type {Map<HTMLAudioElement, MediaElementAudioSourceNode>} */ #sourceNodes = new Map();
/** * Output device id last applied to the shared context. An empty string is * the browser/system default, so a freshly created graph only calls * `setSinkId` when this group actually has a stored preference. * @type {string} */ #appliedSink = "";
// SIGNALS
#items = signal(/** @type {AudioUrl[]} */ ([])); #volume = signal(0.75);
// STATE
items = this.#items.get; volume = this.#volume.get;
isPlaying = computed(() => { const item = this.items()?.[0]; if (!item) return false;
const state = this.state(item.id); if (!state) return false;
return state.isPlaying() || state.hasEnded() || (state.duration() > 0 && state.currentTime() === state.duration()); });
// LIFECYCLE
/** * @override */ connectedCallback() { // Reset teardown signal in case the element is reconnected (moved in DOM). this.#streamAbort = new AbortController();
// Setup broadcasting if part of group if (this.hasAttribute("group")) { const actions = this.broadcast( this.identifier, { adjustVolume: { strategy: "replicate", fn: this.adjustVolume }, pause: { strategy: "leaderOnly", fn: this.pause }, play: { strategy: "leaderOnly", fn: this.play }, seek: { strategy: "leaderOnly", fn: this.seek }, supply: { strategy: "replicate", fn: this.supply },
// State items: { strategy: "leaderOnly", fn: this.items }, }, );
if (!actions) return;
this.adjustVolume = actions.adjustVolume; this.pause = actions.pause; this.play = actions.play; this.seek = actions.seek; this.supply = actions.supply;
// Sync items with leader if needed this.broadcastingStatus().then(async (status) => { if (status.leader) return; this.#items.value = await actions.items(); }); }
// Super super.connectedCallback();
// Get volume from previous session if possible const VOLUME_KEY = `${this.constructor.prototype.constructor.NAME}/${this.group}/volume`; const volume = localStorage.getItem(VOLUME_KEY);
if (volume != undefined) { this.#volume.set(parseFloat(volume)); }
// Monitor volume signal this.effect(() => { // Master volume is applied through the Web Audio graph's input gain node. // When the graph hasn't been created yet (e.g. no AudioContext support) we // fall back to setting the volume directly on each element. if (this.#input) { this.#input.gain.value = this.#volume.value; }
Array.from(this.querySelectorAll("de-audio-item")).forEach( (node) => { const item = /** @type {AudioEngineItem} */ (node); if (item.hasAttribute("preload")) return; const audio = item.querySelector("audio"); if (audio && !this.#sourceNodes.has(audio)) { audio.volume = this.#volume.value; } }, );
localStorage.setItem(VOLUME_KEY, this.#volume.value.toString()); });
// Follow audio-output changes made elsewhere (e.g. the Audio Output facet // running in another frame/tab). `storage` fires in every other same-origin // document, so each group's engine picks up its own preference live. The // key is namespaced by group, so this only reacts to this group's setting. this.effect(() => { const key = this.#sinkStorageKey();
/** @param {StorageEvent} event */ const onStorage = (event) => { if (event.key !== null && event.key !== key) return; this.#applySink(); };
globalThis.addEventListener("storage", onStorage); return () => globalThis.removeEventListener("storage", onStorage); });
// iOS: resume playback that silently failed to start while the page is // hidden (mobile Safari suspends media loading & the audio session in // the background). If playback was requested but the element is paused, // replay it now that the page is visible — reloading first if nothing // was buffered yet. if (IS_IOS) { const onVisible = () => { if (document.hidden) return;
this.items().forEach((item) => { if (item.isPreload) return;
const el = this.#itemElement(item.id); if (!el?.intendsToPlay) return;
const audio = el.audio; if (!audio.paused) return;
if (audio.readyState >= 2) { this.play({ audioId: item.id }); } else { audio.load(); audio.addEventListener("canplay", () => { if (el.intendsToPlay) this.play({ audioId: item.id }); }, { once: true }); } }); };
this.effect(() => { document.addEventListener("visibilitychange", onVisible); return () => document.removeEventListener("visibilitychange", onVisible); }); }
// Only broadcasting stuff from here on out if (!this.broadcasted) return;
// Manage playback across tabs if needed this.effect(async () => { const status = await this.broadcastingStatus(); untracked(() => { if (!(status.leader && status.initialLeader === false)) return;
console.log("🧙 Leadership acquired"); this.items().forEach((item) => { const el = this.#itemElement(item.id); if (!el) return;
el.removeAttribute("initial-progress");
if (!el.audio) return;
const currentTime = el.$state.currentTime.value; const canPlay = () => { this.seek({ audioId: item.id, currentTime: currentTime, });
if (el.$state.isPlaying.value) this.play({ audioId: item.id }); };
el.audio.addEventListener("canplay", canPlay, { once: true });
if (el.audio.readyState === 0) el.audio.load(); else canPlay(); }); }); }); }
/** * @override */ disconnectedCallback() { // Abort in-flight MediaSource setup so #resolveStream can wind down even // while it's awaiting `sourceopen` (which may otherwise never fire once // the object URL is revoked / the element is detached). this.#streamAbort.abort();
// Revoke every MediaSource object URL. WebKit refcounts these and only // frees the buffered data on revokeObjectURL — dropping the map without // revoking leaks the decoded track bytes until the tab's process dies. for (const objectUrl of this.#mediaSourceUrls.values()) { URL.revokeObjectURL(objectUrl); } this.#mediaSourceUrls.clear();
// Cancel pending (not-yet-resolved) streams so their underlying sources // (e.g. fetches) release immediately instead of draining forever. for (const stream of this.#streams.values()) { stream.cancel().catch(() => {}); } this.#streams.clear();
// Stop and unload any live audio nodes before they're dropped so detached // media doesn't keep playing / holding the audio session. this.querySelectorAll("de-audio-item").forEach((node) => { const item = /** @type {AudioEngineItem} */ (node); let audio; try { audio = item.audio; // throws when there's no child <audio> } catch { return; } audio.pause(); audio.removeAttribute("src"); item.querySelectorAll("source").forEach((s) => s.removeAttribute("src")); audio.load(); });
// NOTE: The shared Web Audio graph is intentionally left untouched here. // `createMediaElementSource` may only be called once per element, so // detaching the source nodes or closing the AudioContext while the // engine's elements still exist would permanently mute them if the engine // is ever re-connected (the nodes cannot be re-created). The graph lives // for the page's lifetime instead; re-connected items simply keep feeding // it through their existing nodes. super.disconnectedCallback(); }
// ACTIONS
/** * @type {Actions["adjustVolume"]} */ adjustVolume(args) { if (args.audioId) { this.#withAudioNode(args.audioId, (audio) => { audio.volume = args.volume; }); } else { this.#volume.value = args.volume; } }
/** * @type {Actions["pause"]} */ pause({ audioId }) { this.#withAudioNode(audioId, (audio, item) => { item.cancelMediaRetry(); audio.pause(); item.intendsToPlay = false; // Set `isPlaying` to false optimistically, mirroring `play()`. The // `pause` event would normally do this via `pauseEvent`, but when // `play()` was called before the audio had buffered enough to start // (e.g. readyState < HAVE_FUTURE_DATA) the browser may never fire a // `pause` event — leaving `isPlaying` stuck on the optimistic `true` // that `play()` set. It also prevents `canplayEvent`'s retry-on-ready // logic from restarting playback after an explicit pause. item.$state.isPlaying.set(false); }); }
/** * @type {Actions["play"]} */ play({ audioId, volume }) { this.#resumeContext();
this.#withAudioNode(audioId, (audio, item) => { // Routed elements get master volume from the graph's gain node, so keep // their own volume at unity unless a per-item override is given. In the // no-Web-Audio fallback the element's volume carries the master level. const routed = this.#sourceNodes.has(audio); audio.volume = volume ?? (routed ? 1 : this.volume()); audio.muted = false;
// An errored element can't be restarted with play() alone — re-run // resource selection first so both manual retries and the automatic // recovery in `errorEvent` attempt a fresh fetch. if (audio.error) audio.load();
// TODO: Might need this for `data-initial-progress` // Does seem to cause trouble when broadcasting // (open multiple sessions and play the next audio) // if (audio.readyState === 0) audio.load(); if (!audio.isConnected) return;
const promise = audio.play() || Promise.resolve(); item.intendsToPlay = true;
// On iOS a backgrounded play() can resolve without playback ever // starting — mobile Safari suspends the load (and the audio session) // until the page is visible again. Don't claim it's playing in that // case: `playEvent`/`playingEvent` set the state if playback truly // starts, and the media session won't show a phantom progress. The // visibilitychange handler (see connectedCallback) resumes playback // on refocus using `intendsToPlay`. if (!(IS_IOS && document.hidden)) { item.$state.isPlaying.set(true); }
promise.catch((e) => { if (!audio.isConnected) { /* The node was removed from the DOM, we can ignore this error */ return; }
// Interrupted by a subsequent load() or pause() — benign. Crucially // the isPlaying intent must survive so stall recovery (see // `waitingEvent`) can resume playback on the next canplay. if (e?.name === "AbortError") return;
// Failure of an attempt driven by the automatic retry loop (see // `errorEvent`): the loop owns recovery and keeps retrying, so don't // clear the intent or surface a "resume manually" error. if (item.autoRetrying) return;
const err = "Couldn't play audio automatically. Please resume playback manually."; console.error(err, e);
// A real media error leaves the element dead: keep the intent so the // automatic retry (`errorEvent`) reloads and replays it. Only clear // the intent when the element itself is still usable. if (!audio.error) item.intendsToPlay = false; item.$state.isPlaying.set(false); }); }); }
/** * Use this function to reload the audio after an error occurred. * * @type {Actions["reload"]} */ reload(args) { this.#withAudioNode(args.audioId, (audio, item) => { if (audio.readyState === 0 || audio.error?.code === 2) { audio.load();
if (args.progress !== undefined) { item.setAttribute( "initial-progress", JSON.stringify(args.progress), ); }
if (args.play) { this.play({ audioId: args.audioId, volume: audio.volume }); } } }); }
/** * @type {Actions["seek"]} */ seek({ audioId, currentTime, percentage }) { this.#withAudioNode(audioId, (audio, item) => { if (currentTime != undefined) { audio.currentTime = currentTime; } else if ( percentage != undefined && !isNaN(audio.duration) && audio.duration !== Infinity ) { audio.currentTime = percentage * audio.duration; }
// A seek starts a fresh load at the new position. If playback was // expected, surface the rebuffer as "loading", re-assert the play // intent (the retry loop needs it if the range fetch errors — the // pending `play()` promise rejection would otherwise clear it before // `errorEvent` sees it), and arm the load watchdog (a range fetch that // stalls produces no `waiting`/`error` while `audio.seeking` is true). if (item.intendsToPlay || item.$state.isPlaying.get()) { item.intendsToPlay = true; item.$state.loadingState.set("loading"); item.armLoadWatchdog(); } }); }
/** * @type {Actions["supply"]} */ supply(args) { const existingMap = new Map(this.#items.value.map((a) => [a.id, a]));
// Start loading new streams for (const item of args.audio) { if ( "stream" in item && !existingMap.has(item.id) && !this.#streams.has(item.id) ) { this.#streams.set(item.id, item.stream); this.#resolveStream( item.id, item.stream, item.mimeType ?? "", item.seek, item.duration, this.#streamAbort.signal, ); } }
// Stop streams that are no longer needed const newIds = new Set(args.audio.map((a) => a.id));
for (const [id, objectUrl] of this.#mediaSourceUrls) { if (!newIds.has(id)) { URL.revokeObjectURL(objectUrl); this.#mediaSourceUrls.delete(id); } }
for (const id of this.#streams.keys()) { if (!newIds.has(id)) this.#streams.delete(id); }
/** @type {AudioUrl[]} Remove `stream` field, replace it with `url` */ const resolvedAudio = args.audio.map((a) => { let url = "stream" in a ? this.#mediaSourceUrls.get(a.id) : a.url;
if (!url && "stream" in a && this.#streams.has(a.id)) { // #resolveStream creates the media source URL synchronously, // so this should be unreachable. throw new Error("Stream did not produce a media source url"); }
// A stream rejected by #resolveStream (e.g. MediaSource unsupported // on this browser) renders without a source; #resolveStream flags // the error on the item's state instead. url = url ?? "";
return { id: a.id, isPreload: a.isPreload, mimeType: a.mimeType, progress: a.progress, track: a.track, url, }; });
const hasNewIds = resolvedAudio.some((a) => !existingMap.has(a.id)); const hasPreloadChanges = resolvedAudio.some( (a) => existingMap.get(a.id)?.isPreload !== a.isPreload, );
const hasUrlChanges = resolvedAudio.some( (a) => existingMap.get(a.id)?.url !== a.url, );
if (hasNewIds || hasPreloadChanges || hasUrlChanges) { this.#items.value = resolvedAudio; }
// When only the URL changed for an existing item (e.g. tab leadership handoff invalidated // a blob URL), the same <de-audio-item> element is reused via `keyed`. lit-html will // update <source src> but the browser won't reload on its own — call audio.load() if the // element hasn't successfully loaded yet so it picks up the fresh URL. if (hasUrlChanges && !hasNewIds) { for (const a of resolvedAudio) { if (existingMap.has(a.id) && existingMap.get(a.id)?.url !== a.url) { this.#withAudioNode(a.id, (audio) => { // Clear any `src` attribute left behind by a previously // stream-backed track (#resolveStream sets it imperatively): // it takes precedence over the <source> element and may point // at a revoked object URL. audio.removeAttribute("src"); if (audio.readyState === 0 || audio.error) audio.load(); }); } } }
if (args.play) this.play(args.play); }
// STREAMS
/** * @param {string} id * @param {ReadableStream} stream * @param {string} mimeType * @param {((timeSeconds: number) => Promise<ReadableStream>) | undefined} seekFn * @param {number | undefined} duration * @param {AbortSignal} [signal] */ async #resolveStream(id, stream, mimeType, seekFn, duration, signal) { // MediaSource is unavailable on iPhone before iOS 17.1, so bail out // early when MSE (or its managed variant) is missing, or when the mime // type is unsupported — otherwise the item would hang in a loading // state forever with an unhandled rejection. const win = /** @type {any} */ (globalThis); const MediaSourceCtor = /** @type {typeof MediaSource | undefined} */ ( win.MediaSource ?? win.ManagedMediaSource );
if ( !MediaSourceCtor || !mimeType || !MediaSourceCtor.isTypeSupported(mimeType) ) { // Delete synchronously so `supply()` treats the stream as resolved // (it renders the item without a source), then flag the error on the // item's state once its element exists. this.#streams.delete(id); stream.cancel().catch(() => {}); Promise.resolve().then(() => { this.#itemElement(id)?.$state.loadingState.set({ error: { code: 4 }, // MEDIA_ERR_SRC_NOT_SUPPORTED }); }); return; }
const mediaSource = new MediaSourceCtor(); const objectUrl = URL.createObjectURL(mediaSource);
this.#mediaSourceUrls.set(id, objectUrl); this.#streams.delete(id);
// Yield so the render triggered by supply() can complete, ensuring the // audio element is in the DOM before we set its src. await Promise.resolve();
if (!this.#mediaSourceUrls.has(id)) { // Item was removed while waiting URL.revokeObjectURL(objectUrl); stream.cancel().catch(() => {}); return; }
const itemEl = this.#itemElement(id); if (!itemEl) { URL.revokeObjectURL(objectUrl); this.#mediaSourceUrls.delete(id); stream.cancel().catch(() => {}); return; }
// MediaSource must be attached via audio.src directly; // <source> elements do not trigger sourceopen. itemEl.audio.src = objectUrl;
// Wait for `sourceopen`, but bail out if the element is torn down while // we're still waiting (sourceopen may never fire then). await new Promise((resolve) => { const onOpen = () => { cleanup(); resolve(undefined); }; const onAbort = () => { cleanup(); resolve(undefined); }; const cleanup = () => { mediaSource.removeEventListener("sourceopen", onOpen); signal?.removeEventListener("abort", onAbort); };
mediaSource.addEventListener("sourceopen", onOpen, { once: true }); signal?.addEventListener("abort", onAbort, { once: true }); });
if (!this.#mediaSourceUrls.has(id)) { // The item was removed — or the engine torn down — while awaiting // `sourceopen`. Nothing to buffer; release the URL if it wasn't already // revoked (e.g. by supply()). URL.revokeObjectURL(objectUrl); this.#mediaSourceUrls.delete(id); return; }
// 'reader' is always the current active reader; the seeking handler // closes over this variable so it always cancels the right one. let reader = stream.getReader(); let seekPending = false; let seekTarget = 0;
const onSeeking = () => { if (!seekFn) return; const audio = itemEl.audio; const target = audio.currentTime;
// Only intervene if the target is outside what's already buffered. for (let i = 0; i < audio.buffered.length; i++) { if ( audio.buffered.start(i) <= target && target <= audio.buffered.end(i) ) { return; // Browser can handle it with buffered data. } }
seekPending = true; seekTarget = target; reader.cancel().catch(() => {}); };
itemEl.audio.addEventListener("seeking", onSeeking);
try { if (duration !== undefined) mediaSource.duration = duration;
const sourceBuffer = mediaSource.addSourceBuffer(mimeType);
while (true) { if (!this.#mediaSourceUrls.has(id)) { await reader.cancel(); break; }
let done, value;
try { ({ done, value } = await reader.read()); } catch { done = true; }
if (!this.#mediaSourceUrls.has(id)) break;
if (seekPending) { seekPending = false;
// Clear all buffered data before feeding from the new position. if (sourceBuffer.updating) await waitForUpdateEnd(sourceBuffer); const removal = waitForUpdateEnd(sourceBuffer); sourceBuffer.remove(0, Infinity); if (!(await removal)) { throw new Error("SourceBuffer remove failed"); }
if (!seekFn) throw new Error("seekFn is undefined"); reader = (await seekFn(seekTarget)).getReader();
continue; }
if (done) { if (mediaSource.readyState === "open") mediaSource.endOfStream(); break; }
if (sourceBuffer.updating) await waitForUpdateEnd(sourceBuffer);
const appending = waitForUpdateEnd(sourceBuffer); sourceBuffer.appendBuffer(value); if (!(await appending)) { throw new Error("SourceBuffer append failed"); } } } catch (err) { console.error("[audio engine] Stream error:", err); if (mediaSource.readyState === "open") mediaSource.endOfStream("decode");
// Only surface the error if this stream is still the item's source — // on iOS the node may already have been reused for another track. if (this.#mediaSourceUrls.get(id) === objectUrl) { itemEl.$state.loadingState.set({ error: { code: 3 } }); } } finally { itemEl.audio.removeEventListener("seeking", onSeeking);
// Stop pulling from the stream's underlying source (e.g. a fetch). // Exiting the read loop without cancelling leaves the producer's // request open — a permanently pending connection. Cancelling an // already-cancelled/done reader is a no-op. reader.cancel().catch(() => {}); } }
// RENDER
/** * @param {RenderArg} _ */ render({ html }) { const allItems = this.items();
// Render every item, including the preloaded next track. On iOS this is // what lets the next track's bytes buffer while the current one plays, so // the locked-screen handoff only needs play() — no background load() that // would tear down the audio session and leave playback silent. const items = allItems;
const ids = allItems.map((i) => i.id);
this.querySelectorAll("de-audio-item").forEach((element) => { if (ids.includes(element.id)) return;
// Detached media elements can keep playing (notorious on iOS, but // possible elsewhere too). Updating <source src> alone doesn't stop // that — resource selection only re-runs on load() — so fully unload // the node before lit-html drops it. const audio = element.querySelector("audio"); if (!audio) return;
// Unhook it from the Web Audio graph (a `createMediaElementSource` node // can only be made once per element, and the element is about to be // dropped, so the source node must be released). this[UNROUTE_AUDIO](audio);
audio.pause(); audio.removeAttribute("src"); audio.querySelectorAll("source").forEach((s) => s.removeAttribute("src")); audio.load(); });
const group = this.group; const nodes = items.map((audio) => { const ip = audio.progress === undefined ? "0" : JSON.stringify(audio.progress);
return keyed( audio.id, html` <de-audio-item group="${this.broadcasted ? `${group}/${audio.id}` : nothing}" id="${audio.id}" initial-progress="${ip}" mime-type="${audio.mimeType ? audio.mimeType : nothing}" preload="${audio.isPreload ? `preload` : nothing}" url="${audio.url ?? nothing}" > <audio crossorigin="anonymous" preload="auto" > ${audio.url ? html` <source src="${audio.url}" ${audio.mimeType ? 'type="' + audio.mimeType + '"' : ""} /> ` : nothing} </audio> </de-audio-item> `, ); });
return html` <section id="audio-nodes"> ${nodes} </section> `; }
// 🛠️
/** * Get the state of a single audio item. * * @param {string} audioId * @returns {SignalReader<AudioStateReadOnly | undefined>} */ _state(audioId) { return computed(() => { const _trigger = this.#items.value;
const s = this.#itemElement(audioId)?.state; return s ? { ...s } : undefined; }); }
/** * Get the state of a single audio item. * * @param {string} audioId * @returns {AudioStateReadOnly | undefined} */ state(audioId) { return this._state(audioId)(); }
/** * @param {string} audioId */ #itemElement(audioId) { const node = this.querySelector( `de-audio-item[id="${audioId}"]:not([preload])`, ) ?? this.querySelector( `de-audio-item[id="${audioId}"]`, );
if (node) { const item = /** @type {AudioEngineItem} */ (node); return item; } }
/** * @param {string} audioId * @param {(audio: HTMLAudioElement, item: AudioEngineItem) => void} fn */ #withAudioNode(audioId, fn) { const item = this.#itemElement(audioId); if (item) fn(item.audio, item); }
/** * Drops a preloaded item whose background fetch is stuck. Removing it from * `#items` makes the render cleanup unload its <audio> element, aborting the * fetch; the track is re-resolved and re-rendered normally if it later * becomes the active track. * * Module-internal; called by {@link AudioEngineItem}'s load watchdog. * * @param {string} audioId */ [CANCEL_PRELOAD](audioId) { const items = this.#items.value; const item = items.find((i) => i.id === audioId); if (!item?.isPreload) return;
this.#items.value = items.filter((i) => i.id !== audioId); }
// WEB AUDIO
/** * The shared Web Audio graph, exposed so consumers (equalizer / visualizer * plugins, themes, …) can hook into the audio signal. * * Every <audio> element is routed into the `input` node (with master volume * already applied). By default `input` is connected straight to the * destination: * * source -> input -> destination * * To insert processing, disconnect that pass-through edge and reconnect it * through your own chain, ending at the destination. For example, wiring in * a biquad filter chain and an analyser: * * ```ignore * const { context, input, destination } = engine.webAudio; * const eq = context.createBiquadFilter(); * const analyser = context.createAnalyser(); * input.disconnect(destination); // remove the default pass-through * input.connect(eq); // re-route through your chain * eq.connect(analyser); * analyser.connect(destination); * ``` * * Use `context.resume()` to unlock the context on a user gesture (browsers * start it suspended until interaction). */ get webAudio() { this.#ensureAudioGraph();
return { context: /** @type {AudioContext} */ (this.#audioContext), input: /** @type {GainNode} */ (this.#input), destination: /** @type {AudioContext} */ (this.#audioContext).destination, }; }
/** Lazily creates the shared AudioContext and the default (pass-through) graph. */ #ensureAudioGraph() { if (this.#audioContext) return;
/** @type {typeof AudioContext | undefined} */ const Ctx = globalThis.AudioContext ?? /** @type {any} */ (globalThis) .webkitAudioContext; if (!Ctx) return;
const context = new Ctx(); const input = context.createGain();
// Pass-through; consumers disconnect this edge to insert their chain. input.connect(context.destination);
this.#audioContext = context; this.#input = input;
// Apply the (possibly persisted) master volume to the freshly created node. input.gain.value = this.#volume.value;
// Apply the (possibly persisted) audio output device for this group. this.#applySink();
// Unlock the context on any user gesture, for the life of the page. // Mobile browsers start the context suspended (autoplay policy) and can // suspend it again at any time (audio-session interruptions, route // changes, backgrounding). `resume()` is only ever allowed inside a user // gesture, so keep the listeners installed permanently: every routed // element's output flows through this context, and a context nobody // re-unlocks means silent playback. `resume()` on a running context is a // no-op, so re-firing on every interaction is safe. const unlock = () => { if (context.state !== "running") { context.resume().catch(() => {}); } }; ["touchstart", "touchend", "mousedown", "keydown"].forEach((e) => { document.addEventListener(e, unlock, { passive: true }); });
// Interruption watchdog: attempt an immediate resume whenever the browser // suspends the context while the page is visible (e.g. iOS after a // lock-screen interaction). If the browser demands a gesture, the // persistent listeners above cover the next interaction. context.addEventListener("statechange", () => { if (context.state === "suspended" && !document.hidden) { context.resume().catch(() => {}); } });
// Re-unlock a context that was suspended while the page was backgrounded // (mobile Safari suspends the audio session off-screen). document.addEventListener("visibilitychange", unlock); }
/** * Routes an <audio> element through the Web Audio graph. Safe to call more * than once per element (eg. when a single node is reused on iOS). * * Module-internal; use `webAudio` to consume the graph. * * @param {HTMLAudioElement} audio */ [ROUTE_AUDIO](audio) { if (this.#sourceNodes.has(audio)) return;
this.#ensureAudioGraph(); if (!this.#audioContext || !this.#input) return;
let source; try { source = this.#audioContext.createMediaElementSource(audio); } catch { // A `createMediaElementSource` node can only be created once per element. // This can only happen for an element whose node belongs to a graph that // was torn down — its output is owned by a dead node and cannot be // re-routed. Do NOT register it as routed: the `#sourceNodes` map is // also the volume effect's “handled by the graph” marker, and a bogus // entry would bypass master volume (full-volume output). Fall back to // element-level volume instead, which the volume effect keeps in sync. audio.volume = this.#volume.value; console.warn( "Failed to route audio element through the Web Audio graph; " + "falling back to element-level volume.", audio, ); return; } source.connect(this.#input); this.#sourceNodes.set(audio, source);
// Volume is handled by the graph's input gain node; keep the element's own // volume at unity so the two don't compound. audio.volume = 1; }
/** * Removes an <audio> element's source node from the graph. Mostly useful * when the element is dropped (no longer in `this.items()`). * * Module-internal; use `webAudio` to consume the graph. * * @param {HTMLAudioElement} audio */ [UNROUTE_AUDIO](audio) { const source = this.#sourceNodes.get(audio); if (!source) return;
source.disconnect(); this.#sourceNodes.delete(audio); }
/** Resumes the shared AudioContext if it is suspended (e.g. autoplay policy). */ #resumeContext() { this.#audioContext?.resume().catch(() => {}); }
// AUDIO OUTPUT (SINK)
/** * Selects the audio output device for this group and persists the choice. * * The device id comes from `navigator.mediaDevices.enumerateDevices()` * (`kind === "audiooutput"`); an empty string selects the system default. * Applied to the shared {@link AudioContext} via `setSinkId`, so every routed * `<audio>` element plays through the chosen device. Best-effort: browsers * without `AudioContext.setSinkId` (Firefox/Safari) ignore it and keep using * the system default. * * @param {string} sinkId */ async setSink(sinkId) { if (sinkId) localStorage.setItem(this.#sinkStorageKey(), sinkId); else localStorage.removeItem(this.#sinkStorageKey());
await this.#applySink(); }
/** @returns {string} localStorage key holding this group's output device id. */ #sinkStorageKey() { return `${this.constructor.prototype.constructor.NAME}/${this.group}/sink`; }
/** * Applies the stored output device to the shared AudioContext. No-op until * the graph exists or when the stored value hasn't changed. */ async #applySink() { const context = this.#audioContext; if (!context) return;
const sinkId = localStorage.getItem(this.#sinkStorageKey()) ?? ""; if (sinkId === this.#appliedSink) return;
const ctx = /** @type {AudioContext & { setSinkId?: (id: string) => Promise<void> }} */ ( context );
if (typeof ctx.setSinkId !== "function") { // No support: remember the value so we don't warn on every interaction. this.#appliedSink = sinkId; if (sinkId) { console.warn( "This browser does not support selecting an audio output device " + "(AudioContext.setSinkId); using the system default.", ); } return; }
try { await ctx.setSinkId(sinkId); this.#appliedSink = sinkId; } catch (err) { // e.g. NotAllowedError (permission / cross-origin iframe policy) or // NotFoundError (device unplugged). Keep the current device and retry // when the preference changes again. console.warn("Failed to set the audio output device.", err); } }}
export default AudioEngine;
////////////////////////////////////////////// ITEM ELEMENT////////////////////////////////////////////
class AudioEngineItem extends BroadcastableDiffuseElement { static NAME = "diffuse/engine/audio/item"; static observedAttributes = ["preload"];
// MEDIA ERROR RETRY /** @type {ReturnType<typeof setTimeout> | undefined} Pending automatic retry timeout. */ #retryTimer = undefined; /** @type {number} Retry attempts already used. */ #retryAttempt = 0;
// LOAD WATCHDOG /** @type {ReturnType<typeof setTimeout> | undefined} Pending load-watchdog timeout. */ #watchdogTimer = undefined; /** @type {number} Watchdog trips used for backoff growth. */ #watchdogAttempt = 0; /** @type {number} Consecutive data-less windows spent waiting on an in-flight fetch. */ #loadWaitTrips = 0; /** @type {number} Consecutive data-less reloads — recovery-declared-futile counter. */ #datalessReloads = 0;
/** @type {(() => void) | undefined} Visibility/online re-arm listener. */ #rearmListener = undefined;
constructor() { super();
// TODO: // const ip = this.getAttribute("initial-progress");
/** * Playback was requested but hasn't (visibly) started yet. Unlike * `$state.isPlaying` this is never claimed optimistically on iOS while * hidden, so it survives the "play() resolved but nothing plays" case * and lets the engine resume on refocus. Cleared once playback truly * starts, on explicit pause, or when playback fails in the foreground. */ this.intendsToPlay = false;
/** * True while the automatic retry loop is active. The engine's `play()` * catch uses it to suppress the "resume playback manually" error for * attempts the loop itself drives. */ this.autoRetrying = false;
/** * @type {AudioState} */ this.$state = { currentTime: signal(0), duration: signal(0), hasEnded: signal(false), isPlaying: signal(false), isPreload: signal(this.hasAttribute("preload")), loadingState: signal(/** @type {LoadingState} */ ("initialisation")),
progress: computed(() => { const currentTime = this.$state.currentTime.value; const duration = this.$state.duration.value;
if (!duration || isNaN(duration) || duration === Infinity) return 0;
return currentTime / duration; }), }; }
/** * @override * @param {string} name * @param {string} oldValue * @param {string} newValue */ attributeChangedCallback(name, oldValue, newValue) { super.attributeChangedCallback(name, oldValue, newValue); if (name === "preload") { this.$state.isPreload.set(newValue !== null);
// Now the active track: start watching its load for hangs, with a fresh // backoff (a preload phase may have accumulated wait trips). if (newValue === null) { this.#resetWatchdog(); this.#armWatchdog(); } } }
// LIFECYCLE
/** * @override */ async connectedCallback() { const audio = this.audio;
audio.addEventListener("canplay", this.canplayEvent); audio.addEventListener("durationchange", this.durationchangeEvent); audio.addEventListener("ended", this.endedEvent); audio.addEventListener("error", this.errorEvent); audio.addEventListener("pause", this.pauseEvent); audio.addEventListener("play", this.playEvent); audio.addEventListener("playing", this.playingEvent); audio.addEventListener("suspend", this.suspendEvent); audio.addEventListener("timeupdate", this.timeupdateEvent); audio.addEventListener("progress", this.progressEvent); audio.addEventListener("waiting", this.waitingEvent);
// Transition from initialisation to loading for non-preload items if (!this.hasAttribute("preload")) { this.$state.loadingState.set("loading"); }
// Watch the initial load: a fetch that hangs without data never fires // `error`. An active track is reloaded after a data-less window; a preload // is cancelled (see `#watchdogTrip`). this.#armWatchdog();
// Re-arm a bounded recovery attempt when conditions may have changed: // refocusing (e.g. after background recovery) or the network coming back. // An element that gave up (see `#watchdogTrip`) gets one fresh budget // here; healthy loads just reset and stop from the trip. Without this, // an element that declared recovery futile would never retry on its own. // Scoped to elements with play intent or an error so paused, never-loaded // items don't start a perpetual trip loop. this.#rearmListener = () => { if (document.hidden) return;
let errored = false; try { errored = this.audio?.error != null; } catch { // No child <audio> yet — nothing to recover. return; }
if ( !this.intendsToPlay && !this.$state.isPlaying.get() && !errored ) return;
this.#datalessReloads = 0; this.#armWatchdog(); }; document.addEventListener("visibilitychange", this.#rearmListener); globalThis.addEventListener("online", this.#rearmListener);
// Setup broadcasting if part of group if (this.hasAttribute("group")) { const actions = this.broadcast( this.identifier, { getCurrentTime: { strategy: "leaderOnly", fn: this.$state.currentTime.get, }, getDuration: { strategy: "leaderOnly", fn: this.$state.duration.get }, getHasEnded: { strategy: "leaderOnly", fn: this.$state.hasEnded.get }, getIsPlaying: { strategy: "leaderOnly", fn: this.$state.isPlaying.get, }, getIsPreload: { strategy: "leaderOnly", fn: this.$state.isPreload.get, }, getLoadingState: { strategy: "leaderOnly", fn: this.$state.loadingState.get, },
// SET setCurrentTime: { strategy: "replicate", fn: this.$state.currentTime.set, }, setDuration: { strategy: "replicate", fn: this.$state.duration.set }, setHasEnded: { strategy: "replicate", fn: this.$state.hasEnded.set }, setIsPlaying: { strategy: "replicate", fn: this.$state.isPlaying.set, }, setIsPreload: { strategy: "replicate", fn: this.$state.isPreload.set, }, setLoadingState: { strategy: "replicate", fn: this.$state.loadingState.set, }, }, { // Sync leadership with engine's broadcasting channel assumeLeadership: (await this.engine?.broadcastingStatus())?.leader, }, );
if (actions) { this.$state.currentTime.set = actions.setCurrentTime; this.$state.duration.set = actions.setDuration; this.$state.hasEnded.set = actions.setHasEnded; this.$state.isPlaying.set = actions.setIsPlaying; this.$state.isPreload.set = actions.setIsPreload; this.$state.loadingState.set = actions.setLoadingState;
untracked(async () => { this.$state.currentTime.value = await actions.getCurrentTime(); this.$state.duration.value = await actions.getDuration(); this.$state.hasEnded.value = await actions.getHasEnded(); this.$state.isPlaying.value = await actions.getIsPlaying(); this.$state.isPreload.value = await actions.getIsPreload(); this.$state.loadingState.value = await actions.getLoadingState(); }); } }
// Super super.connectedCallback();
// Route this item's <audio> through the engine's shared Web Audio graph // so volume flows through the gain node and consumers (equalizer, // visualizer plugins, etc) can tap into the signal. Idempotent per element. this.engine?.[ROUTE_AUDIO](this.audio); }
/** * @override */ disconnectedCallback() { this.cancelMediaRetry(); this.#disarmWatchdog();
if (this.#rearmListener) { document.removeEventListener("visibilitychange", this.#rearmListener); globalThis.removeEventListener("online", this.#rearmListener); this.#rearmListener = undefined; }
// NOTE: the Web Audio source node is intentionally NOT detached here. A // `createMediaElementSource` node can only be made once per element, so // un-routing a still-reusable element (e.g. the engine moving in and out // of the DOM) would permanently silence it. Items that are actually // dropped are un-routed by the engine's render cleanup before lit drops // them. super.disconnectedCallback(); }
// STATE
/** * @type {AudioStateReadOnly} */ get state() { return { id: this.id, mimeType: this.getAttribute("mime-type") ?? undefined, url: this.getAttribute("url") ?? "",
currentTime: this.$state.currentTime.get, duration: this.$state.duration.get, hasEnded: this.$state.hasEnded.get, isPlaying: this.$state.isPlaying.get, isPreload: this.$state.isPreload.get, loadingState: this.$state.loadingState.get,
progress: this.$state.progress, }; }
// RELATED ELEMENTS
get audio() { const el = this.querySelector("audio"); if (el) return /** @type {HTMLAudioElement} */ (el); else throw new Error("Cannot find child audio element"); }
get engine() { const el = this.closest("de-audio"); if (el) return /** @type {AudioEngine} */ (el); else return null; }
// MEDIA ERROR RETRY
/** * Schedules the next automatic retry after a transient media error. The * delay doubles per attempt (from {@link RETRY_BASE_DELAY_MS}) up to a * {@link RETRY_MAX_DELAY_MS} ceiling, then keeps retrying at that ceiling * until playback succeeds or the loop is cancelled (pause, drop). */ #scheduleMediaRetry() { if (this.#retryTimer !== undefined) return; if (!this.isConnected) return;
this.autoRetrying = true; const delay = Math.min( RETRY_BASE_DELAY_MS * 2 ** this.#retryAttempt, RETRY_MAX_DELAY_MS, ); this.#retryAttempt += 1; this.#retryTimer = setTimeout(() => { this.#retryTimer = undefined; this.#retryMedia(); }, delay); }
/** * Reloads an errored element and resumes playback. Called by the retry * timer; `initial-progress` (consumed in `canplayEvent`) preserves the * playback position across the reload. */ #retryMedia() { if (!this.isConnected) return;
let audio; try { audio = this.audio; } catch { return; }
// Only retry while the element actually has an error. If it recovered on // its own (browser-side retry, buffered data arriving) a `load()` here // would abort a healthy/playing element and start the failure cycle over. if (!audio.error) { // The element recovered on its own (browser-side retry, buffered data // arriving). Leave the loop so it doesn't keep suppressing later // `play()` failures, and so the next error starts from the base delay. this.autoRetrying = false; this.#retryAttempt = 0; return; }
if ( !isNaN(audio.duration) && audio.duration > 0 && audio.duration !== Infinity ) { this.setAttribute( "initial-progress", JSON.stringify(audio.currentTime / audio.duration), ); }
this.$state.loadingState.set("loading"); audio.load();
// The error loop only re-acts on a new `error` event; the watchdog covers // the case where this retry's fetch hangs without one. this.#armWatchdog();
this.engine?.play({ audioId: this.id }); }
/** * Stops the automatic retry loop (explicit pause, engine pause, or the * item being dropped). Safe to call from the engine for `pause()`. * Resets the backoff so the next failure starts from the base delay again. */ cancelMediaRetry() { if (this.#retryTimer !== undefined) { clearTimeout(this.#retryTimer); this.#retryTimer = undefined; } this.#retryAttempt = 0; this.autoRetrying = false; }
/** * Watchdog access for the engine (`seek` re-arms it, since a seek starts a * fresh load). No-op when already armed. */ armLoadWatchdog() { // A seek is explicit intent and starts a fresh load: grant a fresh // bounded-recovery budget along with it. this.#datalessReloads = 0; this.#armWatchdog(); }
// EVENTS
/** * @param {Event} event */ canplayEvent(event) { const audio = /** @type {HTMLAudioElement} */ (event.target); const item = engineItem(audio);
if ( item?.hasAttribute("initial-progress") && audio.duration && !isNaN(audio.duration) ) { const progress = JSON.parse( item.getAttribute("initial-progress") ?? "0", ); if ( progress !== 0 && !isNaN(audio.duration) && audio.duration !== Infinity ) { audio.currentTime = audio.duration * progress; }
item.removeAttribute("initial-progress"); }
// Data arrived or became playable — the load is healthy; stop watching // (mid-playback refills are covered by `waitingEvent` re-arming). if (item) { item.#resetWatchdog(); item.#disarmWatchdog(); }
finishedLoading(event); }
/** * @param {Event} event */ durationchangeEvent(event) { const audio = /** @type {HTMLAudioElement} */ (event.target);
if (!isNaN(audio.duration)) { engineItem(audio)?.$state.duration.set(audio.duration); } }
/** * @param {Event} event */ endedEvent(event) { const audio = /** @type {HTMLAudioElement} */ (event.target); audio.currentTime = 0;
const item = engineItem(audio); if (item) item.intendsToPlay = false; item?.$state.hasEnded.set(true); }
/** * @param {Event} event */ errorEvent(event) { const audio = /** @type {HTMLAudioElement} */ (event.target); const code = audio.error?.code || 0;
const item = engineItem(audio); if (!item) return;
// MEDIA_ERR_ABORTED: benign — fires whenever a `load()` interrupts an // in-flight request (our own retry reloads, `waitingEvent` recovery, // source swaps). Surface it as an error and the UI would show a stuck // "Audio error" after playback already recovered. if (code === 1) return;
item.$state.loadingState.set({ error: { code } });
// Transient network / CORS-handshake failures: retry automatically so // playback resumes on its own once the source is reachable again. The // first failure requires intent (the track was supposed to play); // afterwards the loop keeps retrying on its own. if (item.hasAttribute("preload")) return; if (audio.src.startsWith("blob:")) return; // MediaSource items handle their own errors if (!isRetryableMediaError(audio.error)) return; if ( item.#retryAttempt === 0 && !item.intendsToPlay && !item.$state.isPlaying.get() ) { return; } item.#scheduleMediaRetry(); }
/** * @param {Event} event */ pauseEvent(event) { const audio = /** @type {HTMLAudioElement} */ (event.target); const item = engineItem(audio);
// A pause that coincides with a transient media error is the failure // stopping playback, not the user pausing. Tearing down the retry loop and // clearing `isPlaying` here would leave the element with no recovery path: // `errorEvent`'s intent guard (and the watchdog's error branch) would both // bail out, so the item would stay stuck on "Audio error" forever. Leave // the loop and playback state untouched — an explicit pause goes through // `AudioEngine.pause()`, which cancels the retry and clears the intent // before calling `audio.pause()` (so it still stops recovery). if (isRetryableMediaError(audio.error)) return;
item?.cancelMediaRetry(); if (item) { item.#resetWatchdog(); item.#disarmWatchdog(); } item?.$state.isPlaying.set(false); }
/** * @param {Event} event */ playEvent(event) { const audio = /** @type {HTMLAudioElement} */ (event.target);
const item = engineItem(audio); item?.$state.hasEnded.set(false); item?.$state.isPlaying.set(true);
// Explicit play intent: grant a fresh bounded-recovery budget (a // capped-out element gets one full cycle back on user action). if (item) item.#resetWatchdog();
// In case audio was preloaded: if (audio.readyState >= 2) finishedLoading(event); }
/** * @param {Event} event */ playingEvent(event) { const audio = /** @type {HTMLAudioElement} */ (event.target); const item = engineItem(audio);
// Playback truly started, intent fulfilled. Leave the retry loop and // reset the backoffs so the next failure starts from the base delay. if (item) { item.intendsToPlay = false; item.autoRetrying = false; item.#retryAttempt = 0; item.#resetWatchdog(); item.#disarmWatchdog(); } item?.$state.isPlaying.set(true);
finishedLoading(event); }
/** * @param {Event} event */ suspendEvent(event) { finishedLoading(event); }
/** * @param {Event} event */ timeupdateEvent(event) { const audio = /** @type {HTMLAudioElement} */ (event.target); const item = engineItem(audio); if (!item) return;
// Playback is progressing — clear any lingering "loading" state (set by // a mid-playback `waiting`) so the theme doesn't keep showing // "Loading audio ..." while the track plays. The browser only restores // `canplay`/`playing` when readyState crosses its thresholds, which it // may not do after a brief refill stall. A `timeupdate` caused by a seek // (position jump while rebuffering) doesn't count as progress. if ( !audio.paused && !audio.seeking && item.$state.loadingState.get() !== "loaded" ) { item.$state.loadingState.set("loaded"); }
if (isNaN(audio.duration) || audio.duration === 0) return; item.$state.currentTime.set(audio.currentTime); }
/** * @param {Event} event */ progressEvent(event) { const item = engineItem(/** @type {HTMLMediaElement} */ (event.target)); if (!item) return;
// Bytes are arriving — the load is alive. Restart the watchdog window // from the base delay so a slow-but-steady download never trips it, and // clear the in-flight wait counter (data did come through). item.#resetWatchdog(); item.#restartWatchdog(); }
// LOAD WATCHDOG
/** * Starts watching for a hung load. Browsers don't reliably fire * `stalled`/`error` while a fetch sits in NETWORK_LOADING without data, so * the watchdog polls instead: `progress` (bytes arriving), `canplay` and * `playing` all re-arm or disarm it — if the timer fires anyway, the load * has been data-less for the whole backoff window and gets reloaded. */ #armWatchdog() { if (this.#watchdogTimer !== undefined) return; if (!this.isConnected) return;
const delay = Math.min( LOAD_WATCHDOG_MS * 2 ** this.#watchdogAttempt, RETRY_MAX_DELAY_MS, ); this.#watchdogTimer = setTimeout(() => { this.#watchdogTimer = undefined; this.#watchdogTrip(); }, delay); }
/** * Restarts the watchdog window from the current backoff. Unlike * {@link #armWatchdog} this clears an already-pending timer, so a stream of * `progress` events (bytes still arriving) keeps pushing the deadline out * instead of letting the original timer trip and discard the buffer. */ #restartWatchdog() { this.#disarmWatchdog(); this.#armWatchdog(); }
/** Clears the watchdog backoff once the load is known healthy (data/playable). */ #resetWatchdog() { this.#watchdogAttempt = 0; this.#loadWaitTrips = 0; this.#datalessReloads = 0; }
/** Stops the load watchdog (paused, dropped, playable). */ #disarmWatchdog() { if (this.#watchdogTimer !== undefined) { clearTimeout(this.#watchdogTimer); this.#watchdogTimer = undefined; } }
/** * Cancels a stuck preload: the engine drops the item, which unloads its * <audio> element and aborts the hung fetch. The track is re-resolved through * the normal supply path if it later becomes active. */ #cancelPreload() { this.engine?.[CANCEL_PRELOAD](this.id); }
/** * Called when a load produced no data (and no error) for the backoff window. * While the browser is still fetching (`NETWORK_LOADING`) we keep waiting — * a reload would cancel a live request and restart playback — up to * {@link LOAD_WAIT_LIMIT} windows, after which a silent request is treated as * hung. An active load the browser has given up on (`NETWORK_IDLE`) is * reloaded; a preload is cancelled instead. */ #watchdogTrip() { if (!this.isConnected) return;
let audio; try { audio = this.audio; } catch { return; }
if (audio.src.startsWith("blob:")) return; // MediaSource items manage their own loads
// On iOS the OS suspends background loads (and tears down the audio // session), so a hidden trip can only observe false positives — bail out. // Everywhere else, background tabs keep loading media: a fetch that dies // silently while hidden (laptop sleep, network switch) never fires // `error`/`progress`, so skipping the trip here would strand its pending // request AND leave the watchdog disarmed (this return is the only // non-re-arming exit for a connected element). Leaked pending requests // pile up until the browser's per-host connection limit starves all // traffic to that server, including audio — the same failure mode the // `BYTES_TIMEOUT_MS` guard in `components/input/common.js` documents for // artwork fetches. if (IS_IOS && document.hidden) return;
// The error retry loop is armed AND the element is errored: it owns // reloads until its next attempt clears the error (and re-arms us). If // the element isn't errored the loop is between attempts — a hang with no // `error` is exactly the watchdog's job, so fall through and reload. if (this.autoRetrying && audio.error) return;
// The load is actually fine — data arrived or the element is playable // since the timer was set. Give up watching until the next load kicks in. if (!audio.error && audio.readyState >= 2) { this.#resetWatchdog(); return; }
if (audio.error) { // An active error retry loop owns reloads and returns just above. If no // loop is pending — it was cancelled, or the error happened while the // item was still a preload — nothing would ever reload this element, so // re-engage the loop when we still intend to play the track. if ( this.#retryTimer === undefined && isRetryableMediaError(audio.error) && (this.intendsToPlay || this.$state.isPlaying.get()) ) { this.#scheduleMediaRetry(); } return; }
// Enough buffered data to play on — a slow refetch isn't a hang. if ( audio.buffered.length > 0 && audio.buffered.end(audio.buffered.length - 1) > audio.currentTime + 5 ) { this.#resetWatchdog(); return; }
// A preload never starts playback, so there is nothing to resume or retry: // a background fetch that produces no data is cancelled outright — the // track is re-resolved and re-rendered if it later becomes active. A // still-fetching request gets the same bounded patience as an active load. if (this.hasAttribute("preload")) { if ( audio.networkState === HTMLMediaElement.NETWORK_LOADING && this.#loadWaitTrips < LOAD_WAIT_LIMIT ) { this.#loadWaitTrips += 1; this.#armWatchdog(); return; } this.#cancelPreload(); return; }
// Nobody asked for this load yet (paused, play pending elsewhere): keep // checking, but don't reload — an abort here would discard the bytes a // future `play()` could already use. if (!this.intendsToPlay && !this.$state.isPlaying.get()) { this.#armWatchdog(); return; }
// The browser is still fetching (`NETWORK_LOADING`): the next chunk is on // its way, so wait for it. Reloading here aborts the in-flight request and // discards whatever was buffered, restarting playback from the preserved // position — the mid-playback restart we're trying to avoid. This is // bounded: a half-open connection can sit in `NETWORK_LOADING` forever, so // past `LOAD_WAIT_LIMIT` data-less windows we stop trusting the request and // reload it below. if ( audio.networkState === HTMLMediaElement.NETWORK_LOADING && this.#loadWaitTrips < LOAD_WAIT_LIMIT ) { this.#loadWaitTrips += 1; this.#armWatchdog(); return; }
// Sustained data-less failure: stop the reload cycle. See // DATALESS_RELOAD_LIMIT — when the wedged state is the connection pool or // the server itself, another reload cannot recover and keeps occupying // the connections recovery needs. Surface a terminal error instead; the // budget is restored by data, playback, a seek, refocus or `online`. this.#datalessReloads += 1; if (this.#datalessReloads > DATALESS_RELOAD_LIMIT) { if (this.#datalessReloads === DATALESS_RELOAD_LIMIT + 1) { this.$state.loadingState.set({ error: { code: 2 } }); // MEDIA_ERR_NETWORK } return; }
this.#watchdogAttempt += 1; this.#loadWaitTrips = 0;
// Preserve position across the reload (no-op for a never-started load). if ( !isNaN(audio.duration) && audio.duration > 0 && audio.duration !== Infinity ) { this.setAttribute( "initial-progress", JSON.stringify(audio.currentTime / audio.duration), ); }
this.$state.loadingState.set("loading"); audio.load(); if (this.intendsToPlay) { this.engine?.play({ audioId: this.id }); } this.#armWatchdog(); }
/** * @param {Event} event */ waitingEvent(event) { initiateLoading(event);
const audio = /** @type {HTMLAudioElement} */ (event.target); if (audio.seeking) return;
const item = engineItem(audio); if (!item || item.hasAttribute("preload")) return;
// Playback stalled for data. While the browser is still fetching // (`NETWORK_LOADING`) the watchdog just waits for the next chunk; it only // reloads a load the browser has given up on (`NETWORK_IDLE`, handled // below). `progress`, `seek` and `retryMedia` arm/restart it too; all paths // share the single watchdog timer. item.#armWatchdog();
if (audio.networkState !== HTMLMediaElement.NETWORK_IDLE) return;
const progress = !isNaN(audio.duration) && audio.duration > 0 && audio.duration !== Infinity ? audio.currentTime / audio.duration : 0;
if (progress > 0) { item.setAttribute("initial-progress", JSON.stringify(progress)); }
// Don't force a full reload if the browser already has buffered data — // it should be able to continue buffering on its own. This prevents // discarding the preloaded buffer when playback briefly catches up to // the end of the downloaded portion. if (audio.buffered.length > 0) return;
audio.load();
audio.addEventListener("canplay", () => { if (item.$state.isPlaying.get() || item.intendsToPlay) { item.engine?.play({ audioId: item.id }); } }, { once: true }); }}
export { AudioEngineItem };
////////////////////////////////////////////// 🛠️////////////////////////////////////////////
/** * Whether a media error is worth retrying automatically. `MEDIA_ERR_NETWORK` * and `MEDIA_ERR_SRC_NOT_SUPPORTED` both commonly surface from transient * connectivity or CORS-handshake failures; aborted and decode errors don't. * * @param {MediaError | null | undefined} error */function isRetryableMediaError(error) { return error?.code === 2 || error?.code === 4;}
/** * @param {HTMLAudioElement} audio */function engineItem(audio) { const c = audio.closest("de-audio-item"); if (c) return /** @type {AudioEngineItem} */ (c); else return null;}
/** * @param {Event} event */function finishedLoading(event) { const audio = /** @type {HTMLAudioElement} */ (event.target); engineItem(audio)?.$state.loadingState.set("loaded");}
/** * @param {Event} event */function initiateLoading(event) { const audio = /** @type {HTMLAudioElement} */ (event.target); if (audio.readyState < 4) { const item = engineItem(audio); if (item?.hasAttribute("preload")) return; item?.$state.loadingState.set("loading"); }}
/** * Resolves once the SourceBuffer finishes its current append/remove * operation. `true` on `updateend`, `false` on `updateerror` (which * WebKit's MSE may fire without a following `updateend`). * * @param {SourceBuffer} sourceBuffer * @returns {Promise<boolean>} */function waitForUpdateEnd(sourceBuffer) { return new Promise((resolve) => { const onEnd = () => { cleanup(); resolve(true); }; const onError = () => { cleanup(); resolve(false); }; const cleanup = () => { sourceBuffer.removeEventListener("updateend", onEnd); sourceBuffer.removeEventListener("updateerror", onError); };
sourceBuffer.addEventListener("updateend", onEnd); sourceBuffer.addEventListener("updateerror", onError); });}
////////////////////////////////////////////// REGISTER////////////////////////////////////////////
export const CLASS = AudioEngine;export const NAME = "de-audio";export const NAME_ITEM = "de-audio-item";
defineElement(NAME, AudioEngine);defineElement(NAME_ITEM, AudioEngineItem);