A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
36 kB · 1208 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209import { 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);
////////////////////////////////////////////// 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();
// 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(() => { 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) audio.volume = this.#volume.value; }, );
localStorage.setItem(VOLUME_KEY, this.#volume.value.toString()); });
// iOS: resume playback that silently failed to start while the page was // 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(); });
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) => { 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.#withAudioNode(audioId, (audio, item) => { audio.volume = volume ?? this.volume(); audio.muted = false;
// 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;
const err = "Couldn't play audio automatically. Please resume playback manually."; console.error(err, e); 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) => { if (currentTime != undefined) { audio.currentTime = currentTime; } else if ( percentage != undefined && !isNaN(audio.duration) && audio.duration !== Infinity ) { audio.currentTime = percentage * audio.duration; } }); }
/** * @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); return; }
const itemEl = this.#itemElement(id); if (!itemEl) { URL.revokeObjectURL(objectUrl); this.#mediaSourceUrls.delete(id); 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); } }
// 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;
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); }}
export default AudioEngine;
////////////////////////////////////////////// ITEM ELEMENT////////////////////////////////////////////
class AudioEngineItem extends BroadcastableDiffuseElement { static NAME = "diffuse/engine/audio/item"; static observedAttributes = ["preload"];
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;
/** * @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); } }
// 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("waiting", this.waitingEvent);
// Transition from initialisation to loading for non-preload items if (!this.hasAttribute("preload")) { this.$state.loadingState.set("loading"); }
// 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(); }
// 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; }
// 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"); }
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;
engineItem(audio)?.$state.loadingState.set({ error: { code } }); }
/** * @param {Event} event */ pauseEvent(event) { const audio = /** @type {HTMLAudioElement} */ (event.target); const item = engineItem(audio);
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);
// 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. if (item) item.intendsToPlay = false; 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); if (isNaN(audio.duration) || audio.duration === 0) return;
engineItem(audio)?.$state.currentTime.set(audio.currentTime); }
/** * @param {Event} event */ waitingEvent(event) { initiateLoading(event);
const audio = /** @type {HTMLAudioElement} */ (event.target); if (audio.seeking) return; if (audio.networkState !== HTMLMediaElement.NETWORK_IDLE) return;
const item = engineItem(audio); if (!item || item.hasAttribute("preload")) 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 };
////////////////////////////////////////////// 🛠️////////////////////////////////////////////
/** * @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);