import { useAudioStore } from '~/stores/audio' import { useUserStore } from '~/stores/user' export const useAudioReplacer = () => { const activeReplacements = new Map>() /** * Wartet auf `canplay` eines AudioElements oder bricht mit Timeout ab. */ const waitForCanPlay = (audio: HTMLAudioElement, timeout = 10000) => { return new Promise((resolve) => { const timer = setTimeout(() => resolve(false), timeout) audio.oncanplay = () => { clearTimeout(timer) resolve(true) } audio.onerror = () => { clearTimeout(timer) resolve(false) } audio.load() }) } /** * Replace an Audio Tag with an smooth fade in and fade out. * @param title, newSrc, duration */ const replaceAudioWithFade = async ({ newSrc, duration = 1.5 }: { title: string newSrc: string duration?: number }) => { const { getNextSoundscape } = usePlayerControls() const title = getNextSoundscape() // Verhindere gleichzeitige Ersetzungen if (activeReplacements.has(title)) { await activeReplacements.get(title) } const promise = (async () => { const audioStore = useAudioStore() await audioStore.ensureAudioContextRunning() const ctx = audioStore.getContext() const masterGain = audioStore.masterGainMusic const oldAudio = document.getElementById(title) as HTMLAudioElement if (!oldAudio || !ctx || !masterGain) { return } // Step 1: Fade out + stop await fadeOutAndStop(oldAudio, duration) // Step 2: Replace const newAudio = oldAudio.cloneNode(true) as HTMLAudioElement newAudio.src = newSrc newAudio.volume = 0 newAudio.loop = true newAudio.hidden = true const sink = useUserStore().audioOutputDevice as MediaDeviceInfo newAudio.setSinkId(sink.deviceId) // Remove previous MediaElementSource if it exists try { const clone = ctx.createMediaElementSource(new Audio()) clone.disconnect() } catch (err) { console.warn('[AudioReplacer] MediaElementSource clone check failed', err) } // Replace in DOM oldAudio.replaceWith(newAudio) // Step 3: Connect to WebAudio let gainNode: GainNode | null = null try { const source = ctx.createMediaElementSource(newAudio) gainNode = ctx.createGain() source.connect(gainNode).connect(masterGain) } catch (err) { console.warn('[AudioReplacer] Could not attach WebAudio. Using fallback.') } // Step 4: Wait for canplay await waitForCanPlay(newAudio, 4000) // Step 5: Play and fade in await newAudio.play().catch(console.error) fadeVolume(newAudio, 0, 1, duration * 1000) })() activeReplacements.set(title, promise) try { await promise } finally { activeReplacements.delete(title) } } /** * Faded ein AudioElement über WebAudio oder Volume auf 0 aus. */ const fadeOutAndStop = (audio: HTMLAudioElement, duration: number) => { return new Promise((resolve) => { fadeVolume(audio, audio.volume, 0, duration * 1000) setTimeout(() => { audio.pause() audio.src = '' resolve() }, duration * 1000) }) } /** * Faded ein AudioElement über WebAudio oder Volume auf 1 ein. */ const fadeInAudio = async ( audio: HTMLAudioElement, duration = 1.5 ): Promise => { const audioStore = useAudioStore() await audioStore.ensureAudioContextRunning() const ctx = audioStore.getContext() const masterGain = audioStore.masterGainMusic as GainNode try { const gainNode = ctx.createGain() const source = ctx.createMediaElementSource(audio) source.connect(gainNode).connect(masterGain) gainNode.gain.setValueAtTime(0, ctx.currentTime) gainNode.gain.linearRampToValueAtTime(1, ctx.currentTime + duration) audio.play() await new Promise((res) => setTimeout(res, duration * 1000)) } catch (error) { audio.play() await fadeVolume(audio, 0, 1, duration) } } /** * Faded die Lautstärke eines AudioElements linear. */ const fadeVolume = (audio: HTMLAudioElement, from: number, to: number, duration: number) => { const steps = 20 const stepTime = duration / steps let currentStep = 0 const fadeInterval = setInterval(() => { currentStep++ const progress = currentStep / steps const newVolume = from + (to - from) * progress audio.volume = Math.max(0, Math.min(1, newVolume)) if (currentStep >= steps) { clearInterval(fadeInterval) audio.volume = to } }, stepTime) } return { fadeOutAndStop, fadeInAudio, waitForCanPlay, fadeVolume, replaceAudioWithFade } }