fix: Music autoplay auf Desktop – saubere fadeIn/startPlayback Logik

Refs statt State für started/audible Tracking, fadeIn als eigene
stabile Callback-Funktion, kein verschachteltes Promise-Handling mehr.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
denshooter
2026-02-16 04:02:03 +01:00
parent 6363d1327b
commit 41604e8314
+53 -74
View File
@@ -18,6 +18,7 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
const activeRef = useRef<'A' | 'B'>('A') const activeRef = useRef<'A' | 'B'>('A')
const fadingRef = useRef(false) const fadingRef = useRef(false)
const startedRef = useRef(false) const startedRef = useRef(false)
const audibleRef = useRef(false)
const [userMuted, setUserMuted] = useState(false) const [userMuted, setUserMuted] = useState(false)
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
@@ -31,7 +32,7 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
[], [],
) )
// ── Crossfade loop ────────────────────────────────────────────────────── // ── Crossfade loop ────────────────────────────────────────────
const crossfade = useCallback(() => { const crossfade = useCallback(() => {
if (fadingRef.current) return if (fadingRef.current) return
fadingRef.current = true fadingRef.current = true
@@ -63,15 +64,14 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
requestAnimationFrame(step) requestAnimationFrame(step)
}, [userMuted, getActive, getInactive]) }, [userMuted, getActive, getInactive])
// Monitor for crossfade trigger // Monitor for crossfade
useEffect(() => { useEffect(() => {
if (!playing || !src) return if (!playing || !src) return
let id: number let id: number
const tick = () => { const tick = () => {
const a = getActive() const a = getActive()
if (a && a.duration) { if (a && a.duration) {
const remaining = a.duration - a.currentTime if (a.duration - a.currentTime <= TAIL_SKIP && !fadingRef.current) {
if (remaining <= TAIL_SKIP && !fadingRef.current) {
crossfade() crossfade()
} }
} }
@@ -81,13 +81,10 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
return () => cancelAnimationFrame(id) return () => cancelAnimationFrame(id)
}, [playing, src, getActive, crossfade]) }, [playing, src, getActive, crossfade])
// Fallback if crossfade misses // Fallback loop
const handleEnded = useCallback(() => { const handleEnded = useCallback(() => {
const a = getActive() const a = getActive()
if (a) { if (a) { a.currentTime = 0; a.play().catch(() => {}) }
a.currentTime = 0
a.play().catch(() => {})
}
}, [getActive]) }, [getActive])
// Sync volume on mute toggle // Sync volume on mute toggle
@@ -99,74 +96,56 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
if (b && !fadingRef.current && !b.paused) b.volume = vol if (b && !fadingRef.current && !b.paused) b.volume = vol
}, [userMuted, getActive, getInactive]) }, [userMuted, getActive, getInactive])
// ── Autoplay strategy ─────────────────────────────────────────────────── // ── Fade volume from 0 → VOLUME ──────────────────────────────
// Mobile Safari needs play() called *directly* inside a user gesture. const fadeIn = useCallback(() => {
// Desktop Chrome allows muted autoplay (volume=0). if (audibleRef.current) return
// audibleRef.current = true
// Strategy: const active = activeRef.current === 'A' ? audioA.current : audioB.current
// 1. Try silent autoplay immediately (desktop) if (!active) return
// 2. On first touch/click, call play() directly in the handler (mobile) const t0 = performance.now()
// 3. Then fade volume in over 2s const step = () => {
useEffect(() => { const t = Math.min((performance.now() - t0) / FADE_IN_MS, 1)
if (!src) return if (!fadingRef.current) active.volume = VOLUME * t
if (t < 1) requestAnimationFrame(step)
const startAndFade = (a: HTMLAudioElement) => {
if (startedRef.current) return
startedRef.current = true
a.volume = 0
a.play().then(() => {
setPlaying(true)
// Fade in
const t0 = performance.now()
const fade = () => {
const t = Math.min((performance.now() - t0) / FADE_IN_MS, 1)
a.volume = VOLUME * t
if (t < 1) requestAnimationFrame(fade)
}
requestAnimationFrame(fade)
}).catch(() => {
startedRef.current = false
})
} }
requestAnimationFrame(step)
}, [])
// Attempt 1: silent autoplay (works on desktop) // ── Start playback (called directly in gesture handler for mobile) ──
const startPlayback = useCallback(() => {
if (startedRef.current) return
const a = audioA.current const a = audioA.current
if (a) { if (!a) return
a.volume = 0 startedRef.current = true
a.play().then(() => { a.volume = 0
startedRef.current = true a.play().then(() => {
setPlaying(true) setPlaying(true)
// Already playing silently, fade in on first interaction fadeIn()
const fadeIn = () => { }).catch(() => { startedRef.current = false })
if (!startedRef.current) return }, [fadeIn])
const t0 = performance.now()
const fade = () => { // ── Autoplay ─────────────────────────────────────────────────
const active = activeRef.current === 'A' ? audioA.current : audioB.current useEffect(() => {
if (!active) return if (!src || startedRef.current) return
const t = Math.min((performance.now() - t0) / FADE_IN_MS, 1) const a = audioA.current
if (!fadingRef.current) active.volume = VOLUME * t if (!a) return
if (t < 1) requestAnimationFrame(fade)
} // Try silent autoplay (desktop allows this)
requestAnimationFrame(fade) a.volume = 0
} a.play().then(() => {
const events = ['scroll', 'click', 'touchstart', 'keydown'] as const startedRef.current = true
events.forEach((e) => window.addEventListener(e, fadeIn, { once: true, passive: true })) setPlaying(true)
}).catch(() => { // Playing silently — fade in on first interaction
// Attempt 2: autoplay blocked (mobile) → wait for user gesture const onInteract = () => fadeIn()
// play() MUST be called synchronously inside the handler const events = ['scroll', 'click', 'touchstart', 'keydown'] as const
const onGesture = () => { events.forEach((e) => window.addEventListener(e, onInteract, { once: true, passive: true }))
const el = audioA.current }).catch(() => {
if (el) startAndFade(el) // Blocked (mobile) — start on first gesture
// Clean up other listeners const onGesture = () => startPlayback()
gestureEvents.forEach((e) => document.removeEventListener(e, onGesture)) const events = ['click', 'touchstart', 'touchend', 'keydown'] as const
} events.forEach((e) => document.addEventListener(e, onGesture, { once: true, passive: true }))
const gestureEvents = ['click', 'touchstart', 'touchend', 'keydown'] as const })
gestureEvents.forEach((e) => }, [src, fadeIn, startPlayback])
document.addEventListener(e, onGesture, { once: true, passive: true })
)
})
}
}, [src])
if (!track || !src) return null if (!track || !src) return null