import { 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 nodes are created on every track change. On iOS we
* therefore render a single 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} MediaSource object URLs created from streams, keyed by item ID */
#mediaSourceUrls = new Map();
/** @type {Map} Streams pending MediaSource setup */
#streams = new Map();
/** Aborts in-flight MediaSource setup when the element is disconnected. */
#streamAbort = new AbortController();
// WEB AUDIO
//
// Every 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} */
#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
} 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 element is reused via `keyed`. lit-html will
// update 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 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) | 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;
// 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 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`
${audio.url
? html`
`
: nothing}
`,
);
});
return html`
`;
}
// 🛠️
/**
* Get the state of a single audio item.
*
* @param {string} audioId
* @returns {SignalReader}
*/
_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 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 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 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 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
* `` 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 }} */ (
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 | undefined} Pending automatic retry timeout. */
#retryTimer = undefined;
/** @type {number} Retry attempts already used. */
#retryAttempt = 0;
// LOAD WATCHDOG
/** @type {ReturnType | 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 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 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
* 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}
*/
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);