feat: Musik global, Dennis-Teil auf eigene Seite, Mobile-Autoplay
- MusicPlayer ins Layout verschoben (läuft auf allen Seiten) - Mobile-Autoplay: Desktop startet stumm + fade-in bei Scroll, Mobile wartet auf ersten Touch und startet dann mit Fade-In - Dennis-Perspektive auf eigene Seite /meine-oma ausgelagert, dezenter Link "Von Dennis" am Ende der Tribute-Sektion - "Berge von Essen" entfernt - "Jacky und Niklas" → "ihre Enkelin" / "Meine Schwester" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import type { MediaItem } from '@/lib/types'
|
||||
const TAIL_SKIP = 10
|
||||
const CROSSFADE_DURATION = 3
|
||||
const VOLUME = 0.4
|
||||
const FADE_IN_DURATION = 2000 // ms to fade in after first interaction
|
||||
const FADE_IN_MS = 2000
|
||||
|
||||
export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
const track = tracks[0] ?? null
|
||||
@@ -17,7 +17,7 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
const audioB = useRef<HTMLAudioElement>(null)
|
||||
const activeRef = useRef<'A' | 'B'>('A')
|
||||
const fadingRef = useRef(false)
|
||||
const unmutedRef = useRef(false)
|
||||
const startedRef = useRef(false)
|
||||
|
||||
const [userMuted, setUserMuted] = useState(false)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
@@ -31,12 +31,7 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
[],
|
||||
)
|
||||
|
||||
const getVolume = useCallback(
|
||||
() => (userMuted ? 0 : VOLUME),
|
||||
[userMuted],
|
||||
)
|
||||
|
||||
// Crossfade to loop back
|
||||
// ── Crossfade loop ──────────────────────────────────────────────────────
|
||||
const crossfade = useCallback(() => {
|
||||
if (fadingRef.current) return
|
||||
fadingRef.current = true
|
||||
@@ -51,7 +46,7 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
|
||||
const startTime = performance.now()
|
||||
const outStartVol = out.volume
|
||||
const targetVol = getVolume()
|
||||
const targetVol = userMuted ? 0 : VOLUME
|
||||
|
||||
const step = () => {
|
||||
const t = Math.min((performance.now() - startTime) / (CROSSFADE_DURATION * 1000), 1)
|
||||
@@ -66,9 +61,9 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(step)
|
||||
}, [getVolume, getActive, getInactive])
|
||||
}, [userMuted, getActive, getInactive])
|
||||
|
||||
// Monitor playback for crossfade trigger
|
||||
// Monitor for crossfade trigger
|
||||
useEffect(() => {
|
||||
if (!playing || !src) return
|
||||
let id: number
|
||||
@@ -86,7 +81,7 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [playing, src, getActive, crossfade])
|
||||
|
||||
// Fallback: if audio ends without crossfade, restart
|
||||
// Fallback if crossfade misses
|
||||
const handleEnded = useCallback(() => {
|
||||
const a = getActive()
|
||||
if (a) {
|
||||
@@ -95,50 +90,81 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
}
|
||||
}, [getActive])
|
||||
|
||||
// Sync volume when user toggles mute
|
||||
// Sync volume on mute toggle
|
||||
useEffect(() => {
|
||||
if (!unmutedRef.current) return
|
||||
const vol = getVolume()
|
||||
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, getVolume, getActive, getInactive])
|
||||
}, [userMuted, getActive, getInactive])
|
||||
|
||||
// Autoplay strategy:
|
||||
// 1. Start muted (browsers allow this)
|
||||
// 2. On first user interaction (scroll/click/touch/key), fade volume in
|
||||
// ── 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 a = audioA.current
|
||||
if (!a) return
|
||||
|
||||
// Start muted playback immediately
|
||||
a.volume = 0
|
||||
a.play().then(() => setPlaying(true)).catch(() => {})
|
||||
|
||||
// Fade in on first interaction
|
||||
const fadeIn = () => {
|
||||
if (unmutedRef.current) return
|
||||
unmutedRef.current = true
|
||||
|
||||
const active = activeRef.current === 'A' ? audioA.current : audioB.current
|
||||
if (!active) return
|
||||
|
||||
const startTime = performance.now()
|
||||
const step = () => {
|
||||
const t = Math.min((performance.now() - startTime) / FADE_IN_DURATION, 1)
|
||||
active.volume = VOLUME * t
|
||||
if (t < 1) requestAnimationFrame(step)
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
const events = ['scroll', 'click', 'touchstart', 'keydown'] as const
|
||||
events.forEach((e) => window.addEventListener(e, fadeIn, { once: true, passive: true }))
|
||||
|
||||
return () => {
|
||||
events.forEach((e) => window.removeEventListener(e, fadeIn))
|
||||
// Attempt 1: silent autoplay (works on desktop)
|
||||
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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user