feat: music player redesign, candle section, impressum
MusicPlayer: - Beautiful inline section with numbered track list + click-to-play - Animated waveform bars on playing track - Time display (elapsed / duration) on progress bar - Floating mini-player with track name + time, shows after first play - Play/pause in mini-player, close button CandleSection: - 7 hand-coded CSS/Framer Motion candle flames with organic flicker - Layered flame (outer glow + main + inner core) + wax drip highlight - "Ruhe in Frieden" text with subtle glow Impressum: - New /impressum page with TMG §5 structure (placeholder address) - Privacy notice (no cookies/tracking, voluntary memory data) - Copyright, liability disclaimer - Consistent cream design with Cormorant/Lora typography page.tsx: - CandleSection added between VideoGallery and MusicPlayer - Musik nav link (conditional on tracks) - Footer Impressum link - MemorySection wrapped in id="erinnerungen" section for nav anchor Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+236
-75
@@ -14,17 +14,65 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { MediaItem } from '@/lib/types'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function TrackNumber({ index, isCurrent, playing }: { index: number; isCurrent: boolean; playing: boolean }) {
|
||||
if (isCurrent && playing) return <WaveformBars playing={playing} />
|
||||
return (
|
||||
<span className={`font-lora text-xs tabular-nums ${isCurrent ? 'text-amber-400/60' : 'text-amber-100/20'}`}>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [volume, setVolume] = useState(0.35)
|
||||
const [volume, setVolume] = useState(0.4)
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [visible, setVisible] = 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 track = tracks[current] ?? null
|
||||
|
||||
const trackName = (i: number) =>
|
||||
tracks[i]?.original_name?.replace(/\.[^/.]+$/, '') ||
|
||||
tracks[i]?.caption ||
|
||||
`Titel ${i + 1}`
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
@@ -36,9 +84,10 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
if (!audio || !track) return
|
||||
audio.src = `/api/files/${track.filename}`
|
||||
audio.volume = muted ? 0 : volume
|
||||
if (playing) {
|
||||
audio.play().catch(() => setPlaying(false))
|
||||
}
|
||||
setDuration(0)
|
||||
setElapsed(0)
|
||||
setProgress(0)
|
||||
if (playing) audio.play().catch(() => setPlaying(false))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [current])
|
||||
|
||||
@@ -47,11 +96,19 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
if (!audio) return
|
||||
if (playing) {
|
||||
audio.pause()
|
||||
setPlaying(false)
|
||||
} else {
|
||||
audio.play().catch(() => setPlaying(false))
|
||||
}
|
||||
}
|
||||
|
||||
const playTrack = (i: number) => {
|
||||
if (i === current) {
|
||||
togglePlay()
|
||||
} else {
|
||||
setCurrent(i)
|
||||
setPlaying(true)
|
||||
}
|
||||
setMiniVisible(true)
|
||||
}
|
||||
|
||||
const prev = () => setCurrent((c) => (c - 1 + tracks.length) % tracks.length)
|
||||
@@ -61,6 +118,12 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.duration) return
|
||||
setProgress((audio.currentTime / audio.duration) * 100)
|
||||
setElapsed(audio.currentTime)
|
||||
}
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const audio = audioRef.current
|
||||
if (audio) setDuration(audio.duration)
|
||||
}
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -73,11 +136,6 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
|
||||
if (tracks.length === 0 || !track) return null
|
||||
|
||||
const trackName =
|
||||
track.original_name?.replace(/\.[^/.]+$/, '') ||
|
||||
track.caption ||
|
||||
`Titel ${current + 1}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<audio
|
||||
@@ -85,93 +143,137 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
src={`/api/files/${track.filename}`}
|
||||
onEnded={next}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
/>
|
||||
|
||||
{/* Floating button */}
|
||||
<motion.button
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={`fixed bottom-6 right-6 z-40 rounded-full p-3.5 shadow-lg transition-colors backdrop-blur-sm ${
|
||||
playing
|
||||
? 'bg-amber-700/95 text-amber-100 ring-2 ring-amber-400/40'
|
||||
: 'bg-stone-800/90 text-amber-200/80 hover:bg-amber-900/90'
|
||||
}`}
|
||||
aria-label="Musik"
|
||||
{/* ── Inline section ── */}
|
||||
<section
|
||||
id="musik"
|
||||
className="py-20 px-4"
|
||||
style={{ background: 'linear-gradient(to bottom, #0a0706, #060304)' }}
|
||||
>
|
||||
<Music size={20} />
|
||||
{playing && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
{/* Player panel */}
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<div className="max-w-xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed bottom-20 right-6 z-40 bg-stone-950/95 backdrop-blur-md rounded-2xl p-5 shadow-2xl w-72 border border-amber-900/20"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-amber-200 font-cormorant italic text-base leading-snug truncate">
|
||||
{trackName}
|
||||
</p>
|
||||
<p className="text-amber-600 text-xs mt-0.5">
|
||||
{current + 1} / {tracks.length}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setVisible(false)}
|
||||
className="text-amber-800 hover:text-amber-500 ml-2 flex-shrink-0"
|
||||
<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>
|
||||
|
||||
{/* Track list */}
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<div className="w-7 flex-shrink-0 flex items-center justify-center">
|
||||
<TrackNumber index={i} isCurrent={i === current} playing={playing} />
|
||||
</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>
|
||||
|
||||
{/* Player 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>
|
||||
|
||||
{/* Progress */}
|
||||
<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>
|
||||
<div className="flex-1 relative group/slider">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
className="w-full cursor-pointer accent-amber-500"
|
||||
style={{ height: '2px' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-lora text-xs text-amber-100/20 w-9 tabular-nums">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
className="w-full mb-4 accent-amber-500 h-1 cursor-pointer"
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-center gap-5 mb-4">
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center justify-center gap-8 mb-5">
|
||||
<button
|
||||
onClick={prev}
|
||||
className="text-amber-500 hover:text-amber-300 transition-colors"
|
||||
className="text-amber-400/30 hover:text-amber-400/70 transition-colors"
|
||||
>
|
||||
<SkipBack size={18} />
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="bg-amber-700 hover:bg-amber-600 text-white rounded-full w-10 h-10 flex items-center justify-center transition-colors shadow-lg"
|
||||
|
||||
<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 shadow-lg"
|
||||
style={{ boxShadow: playing ? '0 0 24px rgba(196,160,74,0.15)' : undefined }}
|
||||
>
|
||||
{playing ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
|
||||
</button>
|
||||
{playing ? <Pause size={22} /> : <Play size={22} className="ml-0.5" />}
|
||||
</motion.button>
|
||||
|
||||
<button
|
||||
onClick={next}
|
||||
className="text-amber-500 hover:text-amber-300 transition-colors"
|
||||
className="text-amber-400/30 hover:text-amber-400/70 transition-colors"
|
||||
>
|
||||
<SkipForward size={18} />
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
className="text-amber-600 hover:text-amber-400 transition-colors"
|
||||
className="text-amber-600/40 hover:text-amber-400/60 transition-colors"
|
||||
>
|
||||
{muted ? <VolumeX size={15} /> : <Volume2 size={15} />}
|
||||
</button>
|
||||
@@ -185,9 +287,68 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
setVolume(parseFloat(e.target.value))
|
||||
setMuted(false)
|
||||
}}
|
||||
className="flex-1 accent-amber-600 h-1 cursor-pointer"
|
||||
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"
|
||||
>
|
||||
{/* Icon + pulse */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full bg-amber-800/40 flex items-center justify-center ${
|
||||
playing ? 'ring-1 ring-amber-400/30' : ''
|
||||
}`}
|
||||
>
|
||||
<Music size={14} className="text-amber-300/80" />
|
||||
</div>
|
||||
{playing && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<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">
|
||||
{trackName(current)}
|
||||
</p>
|
||||
<p className="text-amber-600/40 text-xs font-lora mt-0.5">
|
||||
{playing ? `${formatTime(elapsed)} / ${formatTime(duration)}` : 'pausiert'}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* Play/pause */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="text-amber-400/60 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
{playing ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
|
||||
{/* Close */}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user