Initial commit
This commit is contained in:
182
composables/useAudioReplacer.ts
Normal file
182
composables/useAudioReplacer.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useAudioStore } from '~/stores/audio'
|
||||
import { useUserStore } from '~/stores/user'
|
||||
|
||||
|
||||
export const useAudioReplacer = () => {
|
||||
|
||||
const activeReplacements = new Map<string, Promise<void>>()
|
||||
|
||||
/**
|
||||
* Wartet auf `canplay` eines AudioElements oder bricht mit Timeout ab.
|
||||
*/
|
||||
const waitForCanPlay = (audio: HTMLAudioElement, timeout = 10000) => {
|
||||
return new Promise<boolean>((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<void>((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<void> => {
|
||||
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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user