170 lines
4.5 KiB
TypeScript
170 lines
4.5 KiB
TypeScript
import { logger } from "@/utils/core/Logger";
|
|
|
|
interface PlaySoundOptions {
|
|
playbackRate?: number;
|
|
}
|
|
|
|
export class AudioManager {
|
|
private static _instance: AudioManager | null = null;
|
|
private readonly _audioPools = new Map<string, HTMLAudioElement[]>();
|
|
private _music: HTMLAudioElement | null = null;
|
|
private _musicPath: string | null = null;
|
|
private _musicUnlockHandler: (() => void) | null = null;
|
|
|
|
private static readonly MAX_POOL_SIZE_PER_SOUND = 6;
|
|
private static readonly IGNORED_PLAYBACK_ERRORS = new Set([
|
|
"AbortError",
|
|
"NotAllowedError",
|
|
]);
|
|
|
|
static getInstance(): AudioManager {
|
|
if (!AudioManager._instance) {
|
|
AudioManager._instance = new AudioManager();
|
|
}
|
|
|
|
return AudioManager._instance;
|
|
}
|
|
|
|
private constructor() {}
|
|
|
|
playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void {
|
|
const audio = this._acquireAudio(path);
|
|
audio.volume = Math.max(0, Math.min(1, volume));
|
|
audio.playbackRate = options.playbackRate ?? 1;
|
|
audio.currentTime = 0;
|
|
|
|
void audio.play().catch((error: unknown) => {
|
|
if (
|
|
error instanceof DOMException &&
|
|
AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
logger.error("AudioManager", "Failed to play sound", {
|
|
path,
|
|
error: AudioManager._toLogValue(error),
|
|
});
|
|
});
|
|
}
|
|
|
|
playMusic(path: string, volume = 1): void {
|
|
if (this._musicPath === path && this._music) {
|
|
this._music.volume = Math.max(0, Math.min(1, volume));
|
|
if (!this._music.paused) return;
|
|
} else {
|
|
this.stopMusic();
|
|
this._music = new Audio(path);
|
|
this._music.loop = true;
|
|
this._musicPath = path;
|
|
}
|
|
|
|
this._music.volume = Math.max(0, Math.min(1, volume));
|
|
|
|
void this._music.play().catch((error: unknown) => {
|
|
if (
|
|
error instanceof DOMException &&
|
|
AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name)
|
|
) {
|
|
this._waitForUserGestureToPlayMusic();
|
|
return;
|
|
}
|
|
|
|
logger.error("AudioManager", "Failed to play music", {
|
|
path,
|
|
error: AudioManager._toLogValue(error),
|
|
});
|
|
});
|
|
}
|
|
|
|
stopMusic(): void {
|
|
this._removeMusicUnlockHandler();
|
|
this._music?.pause();
|
|
this._music = null;
|
|
this._musicPath = null;
|
|
}
|
|
|
|
destroy(): void {
|
|
this.stopMusic();
|
|
this._audioPools.forEach((pool) => {
|
|
pool.forEach((audio) => {
|
|
audio.pause();
|
|
audio.src = "";
|
|
});
|
|
});
|
|
this._audioPools.clear();
|
|
AudioManager._instance = null;
|
|
}
|
|
|
|
private _acquireAudio(path: string): HTMLAudioElement {
|
|
const existingPool = this._audioPools.get(path);
|
|
|
|
if (existingPool) {
|
|
const availableAudio = existingPool.find(
|
|
(audio) => audio.paused || audio.ended,
|
|
);
|
|
if (availableAudio) return availableAudio;
|
|
|
|
if (existingPool.length < AudioManager.MAX_POOL_SIZE_PER_SOUND) {
|
|
const pooledAudio = new Audio(path);
|
|
existingPool.push(pooledAudio);
|
|
return pooledAudio;
|
|
}
|
|
|
|
const recycledAudio = existingPool[0];
|
|
if (recycledAudio) return recycledAudio;
|
|
}
|
|
|
|
const initialAudio = new Audio(path);
|
|
this._audioPools.set(path, [initialAudio]);
|
|
return initialAudio;
|
|
}
|
|
|
|
private _waitForUserGestureToPlayMusic(): void {
|
|
if (this._musicUnlockHandler) return;
|
|
|
|
this._musicUnlockHandler = () => {
|
|
this._removeMusicUnlockHandler();
|
|
const music = this._music;
|
|
if (!music) return;
|
|
|
|
void music.play().catch((error: unknown) => {
|
|
if (
|
|
error instanceof DOMException &&
|
|
AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
logger.error("AudioManager", "Failed to unlock music playback", {
|
|
path: this._musicPath,
|
|
error: AudioManager._toLogValue(error),
|
|
});
|
|
});
|
|
};
|
|
|
|
window.addEventListener("pointerdown", this._musicUnlockHandler, {
|
|
once: true,
|
|
});
|
|
window.addEventListener("keydown", this._musicUnlockHandler, {
|
|
once: true,
|
|
});
|
|
}
|
|
|
|
private _removeMusicUnlockHandler(): void {
|
|
if (!this._musicUnlockHandler) return;
|
|
|
|
window.removeEventListener("pointerdown", this._musicUnlockHandler);
|
|
window.removeEventListener("keydown", this._musicUnlockHandler);
|
|
this._musicUnlockHandler = null;
|
|
}
|
|
|
|
private static _toLogValue(error: unknown): Error | DOMException | string {
|
|
if (error instanceof Error || error instanceof DOMException) {
|
|
return error;
|
|
}
|
|
|
|
return String(error);
|
|
}
|
|
}
|