41604e8314
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>
167 lines
5.5 KiB
TypeScript
167 lines
5.5 KiB
TypeScript
'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<HTMLAudioElement>(null)
|
|
const audioB = useRef<HTMLAudioElement>(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 (
|
|
<>
|
|
<audio ref={audioA} src={src} preload="auto" onEnded={handleEnded} />
|
|
<audio ref={audioB} src={src} preload="auto" onEnded={handleEnded} />
|
|
|
|
<button
|
|
onClick={() => setUserMuted((m) => !m)}
|
|
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-stone-950/85 backdrop-blur-sm border border-amber-900/20 shadow-lg flex items-center justify-center text-amber-400/60 hover:text-amber-300 transition-colors"
|
|
title={userMuted ? 'Ton an' : 'Ton aus'}
|
|
>
|
|
{userMuted ? <VolumeX size={22} /> : <Volume2 size={22} />}
|
|
</button>
|
|
</>
|
|
)
|
|
}
|