feat: persönliche Gedenkseite – Tribute, Autoplay-Musik, Next.js 16 Fixes

- TributeSection: zwei Perspektiven (Familie + Dennis), emotional und persönlich
- MusicPlayer: Autoplay mit stummem Start + Fade-In bei Interaktion, nahtloser
  Crossfade-Loop (überspringt stille letzten 10s), kompakter Mute-Button
- Alle API-Routes: export const runtime = 'nodejs' für node:sqlite in Next.js 16
- Admin: defensive res.ok Checks vor .json() Parsing
- DB: mkdirSync erst zur Laufzeit, path.resolve für DATA_DIR
- page.tsx: plain() Helper für null-prototype SQLite-Rows
- erinnerungen.md: alle Familieninfos und Erinnerungen dokumentiert
- Nav: Musik-Tab entfernt, "Über Oma" hinzugefügt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
denshooter
2026-02-16 03:48:46 +01:00
parent 313b5ff7fd
commit 4d56d4904a
17 changed files with 1121 additions and 600 deletions
+132 -452
View File
@@ -1,481 +1,161 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
Play,
Pause,
SkipForward,
SkipBack,
Volume2,
VolumeX,
X,
} from 'lucide-react'
import { Volume2, VolumeX } from 'lucide-react'
import type { MediaItem } from '@/lib/types'
// ─── helpers ────────────────────────────────────────────────────────────────
function formatTime(s: number) {
if (!s || isNaN(s) || !isFinite(s)) return '--:--'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}
function WaveformBars({ playing }: { playing: boolean }) {
return (
<div className="flex items-end gap-px h-4">
{[0.55, 1, 0.7, 0.9, 0.5].map((h, i) => (
<motion.div
key={i}
className="w-[3px] bg-amber-400/70 rounded-full"
animate={
playing
? { height: ['30%', `${h * 100}%`, '45%', `${h * 75}%`, '30%'] }
: { height: '30%' }
}
transition={{ duration: 0.75 + i * 0.13, repeat: Infinity, ease: 'easeInOut', delay: i * 0.11 }}
style={{ height: '30%' }}
/>
))}
</div>
)
}
// ─── ambient audio via Web Audio API ────────────────────────────────────────
//
// "Singing bowls" synthesis: each tone is a struck resonance with
// natural exponential decay sounds like real crystal/tibetan bowls,
// not oscillator drones. Appropriate and peaceful for a memorial.
//
// Scale: A-minor pentatonic (A3 C4 D4 E4 G4 A4 C5 E5)
// Timing: random gaps of 38 s between strikes, so it breathes naturally.
// Reverb: two long delay tails for a large, warm hall.
function useAmbient() {
const ctxRef = useRef<AudioContext | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout>[]>([])
const [playing, setPlaying] = useState(false)
const start = useCallback(() => {
if (ctxRef.current) return
const ctx = new AudioContext()
ctxRef.current = ctx
// ── Signal chain ────────────────────────────────────────────────────
const master = ctx.createGain()
master.gain.value = 0.55
master.connect(ctx.destination)
// Long hall reverb (two delay tails, no feedback)
const mkTail = (t: number, wet: number) => {
const d = ctx.createDelay(6.0)
d.delayTime.value = t
const g = ctx.createGain()
g.gain.value = wet
d.connect(g)
g.connect(ctx.destination)
return d
}
const tail1 = mkTail(2.4, 0.30)
const tail2 = mkTail(4.8, 0.18)
// ── Bowl strike ──────────────────────────────────────────────────────
// Two slightly detuned sine waves → natural "shimmer beat" of a real bowl.
// Gain follows exponential decay → sounds exactly like a struck bowl.
const strike = (freq: number, vol: number) => {
if (!ctxRef.current) return
const now = ctx.currentTime
const decay = 7 + Math.random() * 5 // 712 s natural ring
const detune = freq * 0.0025 // 0.25 % → slow shimmer beat
;[freq, freq + detune].forEach((f, i) => {
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.value = f
const g = ctx.createGain()
// Instant attack (bowl is struck), then pure exponential decay
g.gain.setValueAtTime(vol * (i === 0 ? 1 : 0.6), now)
g.gain.exponentialRampToValueAtTime(0.0001, now + decay)
osc.connect(g)
g.connect(master)
g.connect(tail1)
g.connect(tail2)
osc.start(now)
osc.stop(now + decay + 0.05)
})
}
// ── Scale ───────────────────────────────────────────────────────────
// A-minor pentatonic every note here sounds consonant with every other.
// Weighted toward lower, warmer tones (more prominent) vs. higher (softer).
const bowls: { freq: number; vol: number }[] = [
{ freq: 220.00, vol: 0.38 }, // A3 deep, warm anchor
{ freq: 261.63, vol: 0.32 }, // C4
{ freq: 293.66, vol: 0.28 }, // D4
{ freq: 329.63, vol: 0.30 }, // E4
{ freq: 392.00, vol: 0.26 }, // G4
{ freq: 440.00, vol: 0.22 }, // A4
{ freq: 523.25, vol: 0.18 }, // C5 bright, light
{ freq: 659.25, vol: 0.14 }, // E5 delicate shimmer
]
const pickBowl = () => bowls[Math.floor(Math.random() * bowls.length)]
// ── Scheduler ───────────────────────────────────────────────────────
// Occasional harmonic pairs (two bowls together) feel more musical.
const scheduleNext = () => {
if (!ctxRef.current) return
const gap = 3000 + Math.random() * 5000 // 38 s between strikes
// ~30 % chance of a soft harmonic pair (two bowls a fifth apart)
const b = pickBowl()
strike(b.freq, b.vol)
if (Math.random() < 0.30) {
const fifth = b.freq * 1.5 // perfect fifth
strike(fifth, b.vol * 0.55) // softer companion tone
}
timerRef.current.push(setTimeout(scheduleNext, gap))
}
// Open with three spaced strikes to fill the silence gently
strike(220.00, 0.38) // A3 immediately
timerRef.current.push(setTimeout(() => { if (ctxRef.current) strike(329.63, 0.30) }, 2800)) // E4
timerRef.current.push(setTimeout(() => { if (ctxRef.current) strike(261.63, 0.28) }, 5200)) // C4
timerRef.current.push(setTimeout(scheduleNext, 7500)) // then random
setPlaying(true)
}, [])
const stop = useCallback(() => {
timerRef.current.forEach(clearTimeout)
timerRef.current = []
const ctx = ctxRef.current
if (ctx) {
// Brief master fade so the currently ringing bowls die out naturally
const fade = ctx.createGain()
fade.gain.setValueAtTime(1, ctx.currentTime)
fade.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5)
fade.connect(ctx.destination)
setTimeout(() => { ctx.close(); ctxRef.current = null }, 2700)
}
setPlaying(false)
}, [])
const toggle = useCallback(() => {
if (playing) stop()
else start()
}, [playing, start, stop])
return { playing, toggle, start, stop }
}
// ─── component ──────────────────────────────────────────────────────────────
const TAIL_SKIP = 10
const CROSSFADE_DURATION = 3
const VOLUME = 0.4
const FADE_IN_DURATION = 2000 // ms to fade in after first interaction
export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
const [current, setCurrent] = useState(0)
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 unmutedRef = useRef(false)
const [userMuted, setUserMuted] = useState(false)
const [playing, setPlaying] = useState(false)
const [volume, setVolume] = useState(0.4)
const [muted, setMuted] = useState(false)
const [progress, setProgress] = useState(0)
const [duration, setDuration] = useState(0)
const [elapsed, setElapsed] = useState(0)
const [miniVisible, setMiniVisible] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
const ambient = useAmbient()
const hasTrack = tracks.length > 0
const track = tracks[current] ?? null
const getActive = useCallback(
() => (activeRef.current === 'A' ? audioA.current : audioB.current),
[],
)
const getInactive = useCallback(
() => (activeRef.current === 'A' ? audioB.current : audioA.current),
[],
)
const trackName = (i: number) =>
tracks[i]?.original_name?.replace(/\.[^/.]+$/, '') ||
tracks[i]?.caption ||
`Titel ${i + 1}`
const getVolume = useCallback(
() => (userMuted ? 0 : VOLUME),
[userMuted],
)
// Crossfade to loop back
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 = getVolume()
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)
}, [getVolume, getActive, getInactive])
// Monitor playback for crossfade trigger
useEffect(() => {
const a = audioRef.current
if (!a) return
a.volume = muted ? 0 : volume
}, [volume, muted])
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) {
crossfade()
}
}
id = requestAnimationFrame(tick)
}
id = requestAnimationFrame(tick)
return () => cancelAnimationFrame(id)
}, [playing, src, getActive, crossfade])
// Fallback: if audio ends without crossfade, restart
const handleEnded = useCallback(() => {
const a = getActive()
if (a) {
a.currentTime = 0
a.play().catch(() => {})
}
}, [getActive])
// Sync volume when user toggles mute
useEffect(() => {
const a = audioRef.current
if (!a || !track) return
a.src = `/api/files/${track.filename}`
a.volume = muted ? 0 : volume
setDuration(0); setElapsed(0); setProgress(0)
if (playing) a.play().catch(() => setPlaying(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current])
if (!unmutedRef.current) return
const vol = getVolume()
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])
const togglePlay = () => {
const a = audioRef.current
// Autoplay strategy:
// 1. Start muted (browsers allow this)
// 2. On first user interaction (scroll/click/touch/key), fade volume in
useEffect(() => {
if (!src) return
const a = audioA.current
if (!a) return
if (playing) a.pause()
else a.play().catch(() => setPlaying(false))
}
const playTrack = (i: number) => {
if (i === current) togglePlay()
else { setCurrent(i); setPlaying(true) }
setMiniVisible(true)
}
// Start muted playback immediately
a.volume = 0
a.play().then(() => setPlaying(true)).catch(() => {})
const prev = () => setCurrent((c) => (c - 1 + tracks.length) % tracks.length)
const next = () => setCurrent((c) => (c + 1) % tracks.length)
// Fade in on first interaction
const fadeIn = () => {
if (unmutedRef.current) return
unmutedRef.current = true
const handleTimeUpdate = () => {
const a = audioRef.current
if (!a || !a.duration) return
setProgress((a.currentTime / a.duration) * 100)
setElapsed(a.currentTime)
}
const active = activeRef.current === 'A' ? audioA.current : audioB.current
if (!active) return
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const a = audioRef.current
if (!a || !a.duration) return
const pct = parseFloat(e.target.value)
a.currentTime = (pct / 100) * a.duration
setProgress(pct)
}
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)
}
// Decide what the mini-player shows
const miniLabel = hasTrack ? trackName(current) : 'Stille Begleitung'
const miniPlaying = hasTrack ? playing : ambient.playing
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))
}
}, [src])
if (!track || !src) return null
return (
<>
{hasTrack && (
<audio
ref={audioRef}
src={`/api/files/${track!.filename}`}
onEnded={next}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => { const a = audioRef.current; if (a) setDuration(a.duration) }}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
/>
)}
<audio ref={audioA} src={src} preload="auto" onEnded={handleEnded} />
<audio ref={audioB} src={src} preload="auto" onEnded={handleEnded} />
{/* ── Inline section ─────────────────────────────────────── */}
<section
id="musik"
className="py-20 px-4"
style={{ background: 'linear-gradient(to bottom, #0a0706, #060304)' }}
<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'}
>
<div className="max-w-xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-12"
>
<p className="text-amber-200/30 text-xs tracking-[0.5em] uppercase font-lora mb-3">
Begleitung in Tönen
</p>
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-amber-100/70 mb-3">
Musik
</h2>
<div className="flex items-center justify-center gap-4">
<div className="h-px w-12 bg-amber-400/15" />
<span className="text-amber-400/25"></span>
<div className="h-px w-12 bg-amber-400/15" />
</div>
</motion.div>
{/* ── No uploads: ambient player ── */}
{!hasTrack && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
className="text-center"
>
<p className="font-lora text-amber-100/30 text-sm mb-8 leading-relaxed max-w-xs mx-auto">
Sanfte Klangschalen begleiten dich
lade eigene Musik hoch, um sie hier abzuspielen.
</p>
<div className="flex flex-col items-center gap-5">
<motion.button
onClick={() => { ambient.toggle(); setMiniVisible(true) }}
whileTap={{ scale: 0.94 }}
className="w-16 h-16 rounded-full bg-amber-400/10 hover:bg-amber-400/18 border border-amber-400/20 hover:border-amber-400/40 text-amber-300/70 flex items-center justify-center transition-all duration-200"
style={{ boxShadow: ambient.playing ? '0 0 28px rgba(196,160,74,0.14)' : undefined }}
>
{ambient.playing
? <Pause size={22} />
: <Play size={22} className="ml-1" />
}
</motion.button>
{ambient.playing && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-end gap-1"
>
<WaveformBars playing={ambient.playing} />
</motion.div>
)}
<p className="font-cormorant italic text-amber-200/35 text-lg">
Klangschalen
</p>
</div>
</motion.div>
)}
{/* ── With uploads: track list + controls ── */}
{hasTrack && (
<>
<div className="space-y-0.5 mb-8">
{tracks.map((t, i) => (
<motion.button
key={t.id}
initial={{ opacity: 0, x: -8 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.05 }}
onClick={() => playTrack(i)}
className={`w-full flex items-center gap-4 px-4 py-3.5 rounded-xl transition-all duration-200 text-left group ${
i === current
? 'bg-amber-400/[0.07] border border-amber-400/15'
: 'hover:bg-white/[0.03] border border-transparent'
}`}
>
<div className="w-7 flex-shrink-0 flex items-center justify-center">
{i === current && playing
? <WaveformBars playing={playing} />
: <span className={`font-lora text-xs tabular-nums ${i === current ? 'text-amber-400/60' : 'text-amber-100/20'}`}>
{String(i + 1).padStart(2, '0')}
</span>
}
</div>
<span className={`font-cormorant italic text-lg flex-1 min-w-0 truncate transition-colors ${
i === current ? 'text-amber-200/80' : 'text-amber-100/35 group-hover:text-amber-100/60'
}`}>
{trackName(i)}
</span>
<span className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{i === current && playing
? <Pause size={13} className="text-amber-400/50" />
: <Play size={13} className="text-amber-400/40" />
}
</span>
</motion.button>
))}
</div>
{/* Controls */}
<div className="border-t border-amber-400/10 pt-6">
<p className="text-center font-cormorant italic text-amber-200/50 text-xl mb-5 truncate px-4">
{trackName(current)}
</p>
<div className="flex items-center gap-3 mb-6">
<span className="font-lora text-xs text-amber-100/20 w-9 text-right tabular-nums">{formatTime(elapsed)}</span>
<input
type="range" min="0" max="100" step="0.1" value={progress}
onChange={handleSeek}
className="flex-1 cursor-pointer accent-amber-500"
style={{ height: '2px' }}
/>
<span className="font-lora text-xs text-amber-100/20 w-9 tabular-nums">{formatTime(duration)}</span>
</div>
<div className="flex items-center justify-center gap-8 mb-5">
<button onClick={prev} className="text-amber-400/30 hover:text-amber-400/70 transition-colors">
<SkipBack size={20} />
</button>
<motion.button
onClick={() => { togglePlay(); setMiniVisible(true) }}
whileTap={{ scale: 0.93 }}
className="w-14 h-14 rounded-full bg-amber-400/10 hover:bg-amber-400/18 border border-amber-400/20 hover:border-amber-400/40 text-amber-300/80 flex items-center justify-center transition-all duration-200"
style={{ boxShadow: playing ? '0 0 24px rgba(196,160,74,0.15)' : undefined }}
>
{playing ? <Pause size={22} /> : <Play size={22} className="ml-0.5" />}
</motion.button>
<button onClick={next} className="text-amber-400/30 hover:text-amber-400/70 transition-colors">
<SkipForward size={20} />
</button>
</div>
<div className="flex items-center justify-center gap-3">
<button onClick={() => setMuted((m) => !m)} className="text-amber-600/40 hover:text-amber-400/60 transition-colors">
{muted ? <VolumeX size={15} /> : <Volume2 size={15} />}
</button>
<input
type="range" min="0" max="1" step="0.01"
value={muted ? 0 : volume}
onChange={(e) => { setVolume(parseFloat(e.target.value)); setMuted(false) }}
className="w-28 accent-amber-600 cursor-pointer"
style={{ height: '2px' }}
/>
</div>
</div>
</>
)}
</div>
</section>
{/* ── Floating mini-player ─────────────────────────────────── */}
<AnimatePresence>
{miniVisible && (
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.95 }}
transition={{ duration: 0.25 }}
className="fixed bottom-6 right-6 z-50 flex items-center gap-3 bg-stone-950/96 backdrop-blur-md px-4 py-3 rounded-2xl border border-amber-900/25 shadow-2xl"
>
<div className="relative flex-shrink-0">
<div className={`w-8 h-8 rounded-full bg-amber-800/40 flex items-center justify-center ${miniPlaying ? 'ring-1 ring-amber-400/30' : ''}`}>
{miniPlaying
? <WaveformBars playing />
: <Play size={12} className="text-amber-300/70 ml-0.5" />
}
</div>
{miniPlaying && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
)}
</div>
<button
onClick={() => document.getElementById('musik')?.scrollIntoView({ behavior: 'smooth' })}
className="max-w-[130px] text-left"
>
<p className="text-amber-200/70 font-cormorant italic text-sm truncate leading-tight">
{miniLabel}
</p>
<p className="text-amber-600/40 text-xs font-lora mt-0.5">
{hasTrack
? (playing ? `${formatTime(elapsed)} / ${formatTime(duration)}` : 'pausiert')
: (ambient.playing ? 'läuft …' : 'pausiert')
}
</p>
</button>
<button
onClick={hasTrack ? togglePlay : () => { ambient.toggle() }}
className="text-amber-400/60 hover:text-amber-300 transition-colors"
>
{miniPlaying ? <Pause size={16} /> : <Play size={16} />}
</button>
<button
onClick={() => setMiniVisible(false)}
className="text-amber-800/60 hover:text-amber-500/80 transition-colors ml-1"
>
<X size={13} />
</button>
</motion.div>
)}
</AnimatePresence>
{userMuted ? <VolumeX size={22} /> : <Volume2 size={22} />}
</button>
</>
)
}