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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user