'use client' import { useState, useRef, useEffect, useCallback } from 'react' import { Volume2, VolumeX } from 'lucide-react' import type { MediaItem } from '@/lib/types' const TAIL_SKIP = 10 const CROSSFADE_DURATION = 3 const VOLUME = 0.4 const FADE_IN_MS = 2000 export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) { const track = tracks[0] ?? null const src = track ? `/api/files/${track.filename}` : null const audioA = useRef(null) const audioB = useRef(null) 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) const getActive = useCallback( () => (activeRef.current === 'A' ? audioA.current : audioB.current), [], ) const getInactive = useCallback( () => (activeRef.current === 'A' ? audioB.current : audioA.current), [], ) // ── Crossfade loop ──────────────────────────────────────────── const crossfade = useCallback(() => { if (fadingRef.current) return fadingRef.current = true const out = getActive()! const inp = getInactive()! activeRef.current = activeRef.current === 'A' ? 'B' : 'A' inp.currentTime = 0 inp.volume = 0 inp.play().catch(() => {}) const startTime = performance.now() const outStartVol = out.volume const targetVol = userMuted ? 0 : VOLUME const step = () => { const t = Math.min((performance.now() - startTime) / (CROSSFADE_DURATION * 1000), 1) inp.volume = targetVol * t out.volume = outStartVol * (1 - t) if (t < 1) { requestAnimationFrame(step) } else { out.pause() out.currentTime = 0 fadingRef.current = false } } requestAnimationFrame(step) }, [userMuted, getActive, getInactive]) // Monitor for crossfade useEffect(() => { if (!playing || !src) return let id: number const tick = () => { const a = getActive() if (a && a.duration) { if (a.duration - a.currentTime <= TAIL_SKIP && !fadingRef.current) { crossfade() } } id = requestAnimationFrame(tick) } id = requestAnimationFrame(tick) return () => cancelAnimationFrame(id) }, [playing, src, getActive, crossfade]) // Fallback loop const handleEnded = useCallback(() => { const a = getActive() if (a) { a.currentTime = 0; a.play().catch(() => {}) } }, [getActive]) // Sync volume on mute toggle useEffect(() => { const vol = userMuted ? 0 : VOLUME const a = getActive() const b = getInactive() if (a && !fadingRef.current) a.volume = vol if (b && !fadingRef.current && !b.paused) b.volume = vol }, [userMuted, getActive, getInactive]) // ── 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) }, []) // ── Start playback (called directly in gesture handler for mobile) ── const startPlayback = useCallback(() => { if (startedRef.current) return const a = audioA.current 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 return ( <>