From f0db0e86429b0c93f1354f405978dda4e4e0cbad Mon Sep 17 00:00:00 2001 From: cottongin Date: Fri, 20 Mar 2026 12:57:07 -0400 Subject: [PATCH] feat: extract audio controller into ES module, fix restart bug Made-with: Cursor --- js/audio-controller.js | 139 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 js/audio-controller.js diff --git a/js/audio-controller.js b/js/audio-controller.js new file mode 100644 index 0000000..dff4da8 --- /dev/null +++ b/js/audio-controller.js @@ -0,0 +1,139 @@ +/** + * Theme audio for the room code lobby: reload, volume, loop, and fade-out. + * Implements the overlay component contract: init, activate, deactivate, update, getStatus. + */ + +/** + * @typedef {object} AudioControllerInputs + * @property {HTMLInputElement} enabled + * @property {HTMLInputElement} volume + * @property {HTMLInputElement} soundUrl + */ + +export class AudioController { + /** @type {HTMLAudioElement | null} */ + #audio = null; + + /** @type {AudioControllerInputs | null} */ + #inputs = null; + + /** @type {boolean} */ + #active = false; + + /** @type {ReturnType | null} */ + #fadeInterval = null; + + /** + * @param {HTMLAudioElement} audioElement + * @param {AudioControllerInputs} inputs + */ + init(audioElement, inputs) { + this.#audio = audioElement; + this.#inputs = inputs; + } + + /** + * @param {object} [_ctx] + */ + activate(_ctx) { + this.deactivate(); + + this.#active = true; + + const inputs = this.#inputs; + const audio = this.#audio; + if (!inputs || !audio) { + return; + } + + if (!inputs.enabled.checked) { + return; + } + + audio.src = inputs.soundUrl.value; + audio.load(); + audio.volume = parseFloat(inputs.volume.value); + audio.loop = true; + audio.currentTime = 0; + audio.play().catch((error) => { + console.log('Audio playback failed:', error); + }); + } + + deactivate() { + this.#active = false; + + if (this.#fadeInterval != null) { + clearInterval(this.#fadeInterval); + this.#fadeInterval = null; + } + + const audio = this.#audio; + const inputs = this.#inputs; + if (!audio) { + return; + } + + audio.pause(); + audio.currentTime = 0; + + if (inputs) { + audio.volume = parseFloat(inputs.volume.value); + } + } + + /** + * @param {object} [_ctx] + */ + update(_ctx) { + // No in-state audio updates. + } + + getStatus() { + const audio = this.#audio; + const inputs = this.#inputs; + + return { + active: this.#active, + enabled: inputs?.enabled.checked ?? false, + playing: Boolean(audio && !audio.paused), + volume: audio?.volume ?? 0, + src: audio?.currentSrc ?? audio?.src ?? '', + }; + } + + /** + * Gradually reduce volume to zero, then pause. No-op if audio is already paused. + * @param {number} durationMs + */ + startFadeOut(durationMs) { + const audio = this.#audio; + if (!audio || audio.paused) { + return; + } + + if (this.#fadeInterval != null) { + clearInterval(this.#fadeInterval); + this.#fadeInterval = null; + } + + const startVolume = audio.volume; + const stepCount = 10; + const intervalMs = Math.max(1, durationMs / stepCount); + let step = 0; + + this.#fadeInterval = setInterval(() => { + step += 1; + audio.volume = Math.max(0, startVolume * (1 - step / stepCount)); + + if (step >= stepCount) { + audio.volume = 0; + audio.pause(); + if (this.#fadeInterval != null) { + clearInterval(this.#fadeInterval); + this.#fadeInterval = null; + } + } + }, intervalMs); + } +}