/** * Looping background music — drop your MP3 at public/audio/ambient.mp3 * (MP3 preferred; OGG/WAV also work if you update AMBIENT_MUSIC_SRC). */ export const BGM_STORAGE_KEY = 'aetherforge-bgm'; export const BGM_VOLUME_KEY = 'aetherforge-bgm-volume'; /** Served from Vite public/ — place ambient.mp3 here before enabling in Settings. */ export const AMBIENT_MUSIC_SRC = '/audio/ambient.mp3'; export function loadBgmEnabled(): boolean { try { const v = localStorage.getItem(BGM_STORAGE_KEY); return v === '1'; } catch { return false; } } export function loadBgmVolume(): number { try { const v = localStorage.getItem(BGM_VOLUME_KEY); if (v === null) return 0.22; const n = parseFloat(v); return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.22; } catch { return 0.22; } } function persistBgmEnabled(enabled: boolean) { try { localStorage.setItem(BGM_STORAGE_KEY, enabled ? '1' : '0'); } catch { /* ignore */ } } function persistBgmVolume(volume: number) { try { localStorage.setItem(BGM_VOLUME_KEY, String(volume)); } catch { /* ignore */ } } export class AmbientMusicPlayer { private audio: HTMLAudioElement | null = null; private enabled = loadBgmEnabled(); private volume = loadBgmVolume(); private unlocked = false; private playing = false; private listeners = new Set<(playing: boolean) => void>(); isEnabled() { return this.enabled; } isPlaying() { return this.playing; } getVolume() { return this.volume; } subscribe(fn: (playing: boolean) => void) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; } private setPlaying(v: boolean) { if (this.playing === v) return; this.playing = v; for (const fn of this.listeners) fn(v); } setEnabled(enabled: boolean) { this.enabled = enabled; persistBgmEnabled(enabled); if (enabled) { this.ensureAudio(); void this.tryPlay(); } else { this.pause(); } } setVolume(volume: number) { this.volume = Math.min(1, Math.max(0, volume)); persistBgmVolume(this.volume); if (this.audio) this.audio.volume = this.volume; } /** Browsers block autoplay until a user gesture unlocks audio. */ unlock() { if (this.unlocked) return; this.unlocked = true; this.ensureAudio(); if (this.enabled) void this.tryPlay(); } togglePlay() { if (this.playing) { this.pause(); return false; } if (!this.enabled) { this.setEnabled(true); } void this.tryPlay(); return true; } private ensureAudio() { if (this.audio || typeof document === 'undefined') return; const el = new Audio(AMBIENT_MUSIC_SRC); el.loop = true; el.preload = 'auto'; el.volume = this.volume; el.addEventListener('play', () => this.setPlaying(true)); el.addEventListener('pause', () => this.setPlaying(false)); el.addEventListener('ended', () => this.setPlaying(false)); el.addEventListener('error', () => { this.setPlaying(false); }); this.audio = el; } private pause() { if (!this.audio) return; this.audio.pause(); this.setPlaying(false); } async tryPlay(): Promise { if (!this.enabled) return false; this.ensureAudio(); if (!this.audio) return false; this.audio.volume = this.volume; try { await this.audio.play(); this.setPlaying(true); return true; } catch { this.setPlaying(false); /* Autoplay policy — wait for Settings toggle or first click */ return false; } } } export const ambientMusicPlayer = new AmbientMusicPlayer();