diff --git a/src/components/MusicPlayer.tsx b/src/components/MusicPlayer.tsx index 4de13ef..69a9be3 100644 --- a/src/components/MusicPlayer.tsx +++ b/src/components/MusicPlayer.tsx @@ -18,6 +18,7 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) { const activeRef = useRef<'A' | 'B'>('A') const fadingRef = useRef(false) const startedRef = useRef(false) + const audibleRef = useRef(false) const [userMuted, setUserMuted] = 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(() => { if (fadingRef.current) return fadingRef.current = true @@ -63,15 +64,14 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) { requestAnimationFrame(step) }, [userMuted, getActive, getInactive]) - // Monitor for crossfade trigger + // Monitor for crossfade useEffect(() => { if (!playing || !src) return let id: number const tick = () => { const a = getActive() if (a && a.duration) { - const remaining = a.duration - a.currentTime - if (remaining <= TAIL_SKIP && !fadingRef.current) { + if (a.duration - a.currentTime <= TAIL_SKIP && !fadingRef.current) { crossfade() } } @@ -81,13 +81,10 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) { return () => cancelAnimationFrame(id) }, [playing, src, getActive, crossfade]) - // Fallback if crossfade misses + // Fallback loop const handleEnded = useCallback(() => { const a = getActive() - if (a) { - a.currentTime = 0 - a.play().catch(() => {}) - } + if (a) { a.currentTime = 0; a.play().catch(() => {}) } }, [getActive]) // 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 }, [userMuted, getActive, getInactive]) - // ── Autoplay strategy ─────────────────────────────────────────────────── - // Mobile Safari needs play() called *directly* inside a user gesture. - // Desktop Chrome allows muted autoplay (volume=0). - // - // Strategy: - // 1. Try silent autoplay immediately (desktop) - // 2. On first touch/click, call play() directly in the handler (mobile) - // 3. Then fade volume in over 2s - useEffect(() => { - if (!src) return - - 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 - }) + // ── Fade volume from 0 → VOLUME ────────────────────────────── + const fadeIn = useCallback(() => { + if (audibleRef.current) return + audibleRef.current = true + const active = activeRef.current === 'A' ? audioA.current : audioB.current + if (!active) return + const t0 = performance.now() + const step = () => { + const t = Math.min((performance.now() - t0) / FADE_IN_MS, 1) + if (!fadingRef.current) active.volume = VOLUME * t + if (t < 1) requestAnimationFrame(step) } + 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 - if (a) { - a.volume = 0 - a.play().then(() => { - startedRef.current = true - setPlaying(true) - // Already playing silently, fade in on first interaction - const fadeIn = () => { - if (!startedRef.current) return - const t0 = performance.now() - const fade = () => { - const active = activeRef.current === 'A' ? audioA.current : audioB.current - if (!active) return - const t = Math.min((performance.now() - t0) / FADE_IN_MS, 1) - if (!fadingRef.current) active.volume = VOLUME * t - if (t < 1) requestAnimationFrame(fade) - } - requestAnimationFrame(fade) - } - const events = ['scroll', 'click', 'touchstart', 'keydown'] as const - events.forEach((e) => window.addEventListener(e, fadeIn, { once: true, passive: true })) - }).catch(() => { - // Attempt 2: autoplay blocked (mobile) → wait for user gesture - // play() MUST be called synchronously inside the handler - const onGesture = () => { - const el = audioA.current - if (el) startAndFade(el) - // Clean up other listeners - gestureEvents.forEach((e) => document.removeEventListener(e, onGesture)) - } - const gestureEvents = ['click', 'touchstart', 'touchend', 'keydown'] as const - gestureEvents.forEach((e) => - document.addEventListener(e, onGesture, { once: true, passive: true }) - ) - }) - } - }, [src]) + if (!a) return + startedRef.current = true + a.volume = 0 + a.play().then(() => { + setPlaying(true) + fadeIn() + }).catch(() => { startedRef.current = false }) + }, [fadeIn]) + + // ── Autoplay ───────────────────────────────────────────────── + useEffect(() => { + if (!src || startedRef.current) return + const a = audioA.current + if (!a) return + + // Try silent autoplay (desktop allows this) + a.volume = 0 + a.play().then(() => { + startedRef.current = true + setPlaying(true) + // Playing silently — fade in on first interaction + const onInteract = () => fadeIn() + const events = ['scroll', 'click', 'touchstart', 'keydown'] as const + events.forEach((e) => window.addEventListener(e, onInteract, { once: true, passive: true })) + }).catch(() => { + // Blocked (mobile) — start on first gesture + const onGesture = () => startPlayback() + const events = ['click', 'touchstart', 'touchend', 'keydown'] as const + events.forEach((e) => document.addEventListener(e, onGesture, { once: true, passive: true })) + }) + }, [src, fadeIn, startPlayback]) if (!track || !src) return null