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 "./types.d.ts" * @import {RenderArg} from "~/common/element.d.ts" * @import {SignalReader} from "~/common/signal.d.ts" */ //////////////////////////////////////////// // CONSTANTS //////////////////////////////////////////// const SILENT_MP3 = "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV"; //////////////////////////////////////////// // ELEMENT //////////////////////////////////////////// /** * @implements {Actions} */ class AudioEngine extends BroadcastableDiffuseElement { static NAME = "diffuse/engine/audio"; constructor() { super(); this.isPlaying = this.isPlaying.bind(this); this.state = this.state.bind(this); } // SIGNALS #items = signal(/** @type {AudioUrl[]} */ ([])); #volume = signal(0.75); /** @type {Map} Streams pending MediaSource setup */ #streams = new Map(); /** @type {Map} MediaSource object URLs created from streams, keyed by item ID */ #mediaSourceUrls = new Map(); // STATE items = this.#items.get; volume = this.#volume.get; // LIFECYCLE /** * @override */ connectedCallback() { // 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()); }); // 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(); }); }); }); } // 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) => audio.pause()); } /** * @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.$state.isPlaying.set(true); promise.catch((e) => { if (!audio.isConnected) { return; /* The node was removed from the DOM, we can ignore this error */ } const err = "Couldn't play audio automatically. Please resume playback manually."; console.error(err, e); item.$state.isPlaying.set(false); }); }); } /** * @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, ); } } // 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) => { const url = "stream" in a ? this.#mediaSourceUrls.get(a.id) : a.url; if (!url) { throw new Error("Stream did not produce a media source 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) => { 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 */ async #resolveStream(id, stream, mimeType, seekFn, duration) { const mediaSource = new MediaSource(); 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; // elements do not trigger sourceopen. itemEl.audio.src = objectUrl; await new Promise((resolve) => { mediaSource.addEventListener("sourceopen", resolve, { once: true }); }); if (duration !== undefined) mediaSource.duration = duration; const sourceBuffer = mediaSource.addSourceBuffer(mimeType); // '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 { 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 new Promise((r) => sourceBuffer.addEventListener("updateend", r, { once: true }) ); } await new Promise((r) => { sourceBuffer.addEventListener("updateend", r, { once: true }); sourceBuffer.remove(0, Infinity); }); 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 new Promise((r) => sourceBuffer.addEventListener("updateend", r, { once: true }) ); } sourceBuffer.appendBuffer(value); await new Promise((r) => sourceBuffer.addEventListener("updateend", r, { once: true }) ); } } catch (err) { console.error("[audio engine] Stream error:", err); if (mediaSource.readyState === "open") mediaSource.endOfStream("decode"); } finally { itemEl.audio.removeEventListener("seeking", onSeeking); } } // RENDER /** * @param {RenderArg} _ */ render({ html }) { const ids = this.items().map((i) => i.id); this.querySelectorAll("de-audio-item").forEach((element) => { if (ids.includes(element.id)) return; const source = element.querySelector("source"); if (source) source.src = SILENT_MP3; }); const group = this.group; const nodes = this.items().map((audio) => { const ip = audio.progress === undefined ? "0" : JSON.stringify(audio.progress); return keyed( audio.id, html` `, ); }); return html`
${nodes}
`; } // 🛠️ /** * Convenience signal to track if something is, or was, playing. */ _isPlaying() { return 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()); }); } /** * 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; }); } /** * Convenience signal to track if something is, or was, playing. */ isPlaying() { return this._isPlaying()(); } /** * 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])`, ); 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"; constructor() { super(); // TODO: // const ip = this.getAttribute("initial-progress"); /** * @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} */ ("loading")), progress: computed(() => { const currentTime = this.$state.currentTime.value; const duration = this.$state.duration.value; if (isNaN(duration)) return 0; if (duration === Infinity) return 0; return currentTime / duration; }), }; } // 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("suspend", this.suspendEvent); audio.addEventListener("timeupdate", this.timeupdateEvent); audio.addEventListener("waiting", this.waitingEvent); // 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; engineItem(audio)?.$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 === 4) 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); } } 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) { engineItem(audio)?.$state.loadingState.set("loading"); } } //////////////////////////////////////////// // REGISTER //////////////////////////////////////////// export const CLASS = AudioEngine; export const NAME = "de-audio"; export const NAME_ITEM = "de-audio-item"; defineElement(NAME, AudioEngine); defineElement(NAME_ITEM, AudioEngineItem);