Browsers block ES module imports over the file:// protocol due to CORS. Users opening the overlay by double-clicking the HTML file saw all JS fail to load. Replace import/export with a window.OBS global namespace and classic <script> tags so the overlay works without a local server. Made-with: Cursor
143 lines
3.1 KiB
JavaScript
143 lines
3.1 KiB
JavaScript
/**
|
|
* 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
|
|
*/
|
|
|
|
class AudioController {
|
|
/** @type {HTMLAudioElement | null} */
|
|
#audio = null;
|
|
|
|
/** @type {AudioControllerInputs | null} */
|
|
#inputs = null;
|
|
|
|
/** @type {boolean} */
|
|
#active = false;
|
|
|
|
/** @type {ReturnType<typeof setInterval> | 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);
|
|
}
|
|
}
|
|
|
|
window.OBS = window.OBS || {};
|
|
window.OBS.AudioController = AudioController;
|