import { keyed } from "lit-html/directives/keyed.js"; import { BroadcastableDiffuseElement, nothing } from "@common/element.js"; import { computed, signal, untracked } from "@common/signal.js"; /** * @import {Actions, Audio, 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 {Audio[]} */ ([])); #volume = signal(0.5); // 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.nameWithGroup, { 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 progress = el.$state.progress.value; const canPlay = () => { this.seek({ audioId: item.id, percentage: progress, }); 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, percentage }) { this.#withAudioNode(audioId, (audio) => { if (!isNaN(audio.duration)) { audio.currentTime = audio.duration * percentage; } }); } /** * @type {Actions["supply"]} */ supply(args) { const existingSet = new Set(this.#items.value.map((a) => a.id)); const newSet = new Set(args.audio.map((a) => a.id)); if (newSet.difference(existingSet).size !== 0) { this.#items.value = args.audio; } if (args.play) this.play(args.play); } // 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.progress() === 1; }); } /** * 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(); const ip = this.getAttribute("initial-progress"); /** * @type {AudioState} */ this.$state = { duration: signal(0), hasEnded: signal(false), isPlaying: signal(false), isPreload: signal(this.hasAttribute("preload")), loadingState: signal(/** @type {LoadingState} */ ("loading")), progress: signal(ip ? parseFloat(ip) : 0), }; } // 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.nameWithGroup, { 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, }, getProgress: { strategy: "leaderOnly", fn: this.$state.progress.get }, // 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, }, setProgress: { strategy: "replicate", fn: this.$state.progress.set }, }, { // Sync leadership with engine's broadcasting channel assumeLeadership: (await this.engine?.broadcastingStatus())?.leader, }, ); if (actions) { 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; this.$state.progress.set = actions.setProgress; untracked(async () => { 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(); this.$state.progress.value = await actions.getProgress(); }); } } // Super super.connectedCallback(); } // STATE /** * @type {AudioStateReadOnly} */ get state() { return { id: this.id, mimeType: (this.getAttribute("mime-type") ?? undefined), url: (this.getAttribute("url") ?? ""), 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.get, }; } // 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", ); 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; const progress = audio.currentTime / audio.duration; if (progress === 0) return; engineItem(audio)?.$state.progress.set(progress); } /** * @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"; customElements.define(NAME, AudioEngine); customElements.define(NAME_ITEM, AudioEngineItem);