fix: remove candles, deduplicate dates, add ambient music fallback
- Remove CandleSection from page; dates now appear only in hero - Footer stripped to just quote + impressum/admin links (no name/dates) - Musik nav link always visible - MusicPlayer: Web Audio API ambient mode when no tracks uploaded - A-minor pad (55/110/130/164/220 Hz sine oscillators) - Feedback delay for spaciousness, per-note LFO swells, 6s fade-in - "Stille Begleitung" UI with waveform bars - When tracks are uploaded: full track list + cycle mode as before - Floating mini-player works for both modes Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+19
-51
@@ -5,7 +5,6 @@ import PhotoSlideshow from '@/components/PhotoSlideshow'
|
|||||||
import PhotoGallery from '@/components/PhotoGallery'
|
import PhotoGallery from '@/components/PhotoGallery'
|
||||||
import MemorySection from '@/components/MemorySection'
|
import MemorySection from '@/components/MemorySection'
|
||||||
import WriteSection from '@/components/WriteSection'
|
import WriteSection from '@/components/WriteSection'
|
||||||
import CandleSection from '@/components/CandleSection'
|
|
||||||
import MusicPlayer from '@/components/MusicPlayer'
|
import MusicPlayer from '@/components/MusicPlayer'
|
||||||
import VideoGallery from '@/components/VideoGallery'
|
import VideoGallery from '@/components/VideoGallery'
|
||||||
|
|
||||||
@@ -36,39 +35,25 @@ export default async function HomePage() {
|
|||||||
<nav className="sticky top-0 z-20 bg-cream/80 backdrop-blur-sm border-b border-warm-border">
|
<nav className="sticky top-0 z-20 bg-cream/80 backdrop-blur-sm border-b border-warm-border">
|
||||||
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-center gap-6 sm:gap-10">
|
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-center gap-6 sm:gap-10">
|
||||||
{photos.length > 0 && (
|
{photos.length > 0 && (
|
||||||
<a
|
<a href="#bilder" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||||
href="#bilder"
|
|
||||||
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
|
|
||||||
>
|
|
||||||
Bilder
|
Bilder
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<a
|
<a href="#erinnerungen" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||||
href="#erinnerungen"
|
|
||||||
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
|
|
||||||
>
|
|
||||||
Erinnerungen
|
Erinnerungen
|
||||||
</a>
|
</a>
|
||||||
{videos.length > 0 && (
|
{videos.length > 0 && (
|
||||||
<a
|
<a href="#videos" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||||
href="#videos"
|
|
||||||
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
|
|
||||||
>
|
|
||||||
Videos
|
Videos
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{music.length > 0 && (
|
<a href="#musik" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||||
<a
|
Musik
|
||||||
href="#musik"
|
</a>
|
||||||
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
|
|
||||||
>
|
|
||||||
Musik
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Photo section */}
|
{/* Photos */}
|
||||||
{photos.length > 0 && (
|
{photos.length > 0 && (
|
||||||
<section id="bilder" className="py-16 sm:py-20">
|
<section id="bilder" className="py-16 sm:py-20">
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
@@ -82,14 +67,13 @@ export default async function HomePage() {
|
|||||||
<div className="h-px w-16 bg-warm-gold/40" />
|
<div className="h-px w-16 bg-warm-gold/40" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{photos.length > 1 && <PhotoSlideshow photos={photos} />}
|
{photos.length > 1 && <PhotoSlideshow photos={photos} />}
|
||||||
<PhotoGallery photos={photos} />
|
<PhotoGallery photos={photos} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Write section – public */}
|
{/* Write */}
|
||||||
<WriteSection />
|
<WriteSection />
|
||||||
|
|
||||||
{/* Memories */}
|
{/* Memories */}
|
||||||
@@ -100,49 +84,33 @@ export default async function HomePage() {
|
|||||||
{/* Videos */}
|
{/* Videos */}
|
||||||
<VideoGallery videos={videos} />
|
<VideoGallery videos={videos} />
|
||||||
|
|
||||||
{/* Candle section */}
|
{/* Music – always rendered (ambient fallback when no tracks) */}
|
||||||
<CandleSection />
|
|
||||||
|
|
||||||
{/* Music player */}
|
|
||||||
<MusicPlayer tracks={music} />
|
<MusicPlayer tracks={music} />
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="py-16 text-center border-t border-warm-border bg-amber-50/30">
|
<footer className="py-12 text-center border-t border-warm-border bg-amber-50/30">
|
||||||
<div className="max-w-lg mx-auto px-4">
|
<div className="max-w-lg mx-auto px-4">
|
||||||
<p className="font-cormorant italic text-3xl text-warm-gold mb-3">
|
|
||||||
In liebevoller Erinnerung
|
|
||||||
</p>
|
|
||||||
<p className="font-lora text-warm-brown-light text-sm tracking-wider">
|
|
||||||
Maria Malejka
|
|
||||||
</p>
|
|
||||||
<p className="font-lora text-warm-brown-light/60 text-xs mt-1 tracking-widest">
|
|
||||||
29. November 1944 — 10. Februar 2026
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Ornament */}
|
|
||||||
<div className="flex items-center justify-center gap-3 mt-6 mb-6">
|
|
||||||
<div className="h-px w-12 bg-warm-gold/20" />
|
|
||||||
<span className="text-warm-gold/40 text-sm">✦</span>
|
|
||||||
<div className="h-px w-12 bg-warm-gold/20" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="font-cormorant italic text-warm-brown-light/60 text-lg">
|
<p className="font-cormorant italic text-warm-brown-light/60 text-lg">
|
||||||
„Du bist nicht fort, nur ein Schritt voraus."
|
„Du bist nicht fort, nur ein Schritt voraus."
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Footer links */}
|
<div className="flex items-center justify-center gap-3 mt-5">
|
||||||
<div className="mt-10 flex items-center justify-center gap-6">
|
<div className="h-px w-8 bg-warm-gold/20" />
|
||||||
|
<span className="text-warm-gold/30 text-xs">✦</span>
|
||||||
|
<div className="h-px w-8 bg-warm-gold/20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-5">
|
||||||
<a
|
<a
|
||||||
href="/impressum"
|
href="/impressum"
|
||||||
className="text-warm-brown-light/40 hover:text-warm-brown-light/70 text-xs font-lora tracking-wider transition-colors duration-200"
|
className="text-warm-brown-light/35 hover:text-warm-brown-light/65 text-xs font-lora tracking-wider transition-colors duration-200"
|
||||||
>
|
>
|
||||||
Impressum
|
Impressum
|
||||||
</a>
|
</a>
|
||||||
<span className="text-warm-border/40 text-xs">·</span>
|
<span className="text-warm-border/40 text-xs">·</span>
|
||||||
{/* Hidden admin link */}
|
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className="text-warm-border/30 hover:text-warm-border/60 text-xs transition-colors"
|
className="text-warm-border/25 hover:text-warm-border/50 text-xs transition-colors"
|
||||||
title="Verwaltung"
|
title="Verwaltung"
|
||||||
>
|
>
|
||||||
·
|
·
|
||||||
|
|||||||
+278
-195
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
SkipBack,
|
SkipBack,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
Music,
|
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { MediaItem } from '@/lib/types'
|
import type { MediaItem } from '@/lib/types'
|
||||||
|
|
||||||
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatTime(s: number) {
|
function formatTime(s: number) {
|
||||||
if (!s || isNaN(s) || !isFinite(s)) return '--:--'
|
if (!s || isNaN(s) || !isFinite(s)) return '--:--'
|
||||||
const m = Math.floor(s / 60)
|
const m = Math.floor(s / 60)
|
||||||
@@ -33,12 +34,7 @@ function WaveformBars({ playing }: { playing: boolean }) {
|
|||||||
? { height: ['30%', `${h * 100}%`, '45%', `${h * 75}%`, '30%'] }
|
? { height: ['30%', `${h * 100}%`, '45%', `${h * 75}%`, '30%'] }
|
||||||
: { height: '30%' }
|
: { height: '30%' }
|
||||||
}
|
}
|
||||||
transition={{
|
transition={{ duration: 0.75 + i * 0.13, repeat: Infinity, ease: 'easeInOut', delay: i * 0.11 }}
|
||||||
duration: 0.75 + i * 0.13,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
delay: i * 0.11,
|
|
||||||
}}
|
|
||||||
style={{ height: '30%' }}
|
style={{ height: '30%' }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -46,15 +42,99 @@ function WaveformBars({ playing }: { playing: boolean }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrackNumber({ index, isCurrent, playing }: { index: number; isCurrent: boolean; playing: boolean }) {
|
// ─── ambient audio via Web Audio API ────────────────────────────────────────
|
||||||
if (isCurrent && playing) return <WaveformBars playing={playing} />
|
// A-minor chord: A1 55Hz · A2 110Hz · C3 130.81Hz · E3 164.81Hz · A3 220Hz
|
||||||
return (
|
|
||||||
<span className={`font-lora text-xs tabular-nums ${isCurrent ? 'text-amber-400/60' : 'text-amber-100/20'}`}>
|
function useAmbient() {
|
||||||
{String(index + 1).padStart(2, '0')}
|
const ctxRef = useRef<AudioContext | null>(null)
|
||||||
</span>
|
const stopFnsRef = useRef<(() => void)[]>([])
|
||||||
)
|
const [playing, setPlaying] = useState(false)
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
if (ctxRef.current) return
|
||||||
|
const ctx = new AudioContext()
|
||||||
|
ctxRef.current = ctx
|
||||||
|
|
||||||
|
const master = ctx.createGain()
|
||||||
|
master.gain.setValueAtTime(0, ctx.currentTime)
|
||||||
|
master.gain.linearRampToValueAtTime(0.11, ctx.currentTime + 6)
|
||||||
|
master.connect(ctx.destination)
|
||||||
|
|
||||||
|
// Feedback delay for spaciousness
|
||||||
|
const delay = ctx.createDelay(2.0)
|
||||||
|
delay.delayTime.value = 1.4
|
||||||
|
const fbGain = ctx.createGain()
|
||||||
|
fbGain.gain.value = 0.22
|
||||||
|
delay.connect(fbGain)
|
||||||
|
fbGain.connect(delay)
|
||||||
|
fbGain.connect(master)
|
||||||
|
|
||||||
|
const notes = [
|
||||||
|
{ freq: 55, gain: 0.55 },
|
||||||
|
{ freq: 110, gain: 0.45 },
|
||||||
|
{ freq: 130.81, gain: 0.38 },
|
||||||
|
{ freq: 164.81, gain: 0.48 },
|
||||||
|
{ freq: 220, gain: 0.30 },
|
||||||
|
]
|
||||||
|
|
||||||
|
notes.forEach(({ freq, gain }, i) => {
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'sine'
|
||||||
|
osc.frequency.value = freq + (i % 2 === 0 ? 0.15 : -0.15) // micro-detune
|
||||||
|
const g = ctx.createGain()
|
||||||
|
g.gain.value = gain
|
||||||
|
|
||||||
|
// Slow per-note swell LFO
|
||||||
|
const lfo = ctx.createOscillator()
|
||||||
|
lfo.type = 'sine'
|
||||||
|
lfo.frequency.value = 0.06 + i * 0.018
|
||||||
|
const lfoG = ctx.createGain()
|
||||||
|
lfoG.gain.value = gain * 0.38
|
||||||
|
lfo.connect(lfoG)
|
||||||
|
lfoG.connect(g.gain)
|
||||||
|
lfo.start()
|
||||||
|
|
||||||
|
osc.connect(g)
|
||||||
|
g.connect(master)
|
||||||
|
g.connect(delay)
|
||||||
|
osc.start()
|
||||||
|
|
||||||
|
stopFnsRef.current.push(() => {
|
||||||
|
try { osc.stop(); lfo.stop() } catch { /* already stopped */ }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
setPlaying(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
const ctx = ctxRef.current
|
||||||
|
if (!ctx) return
|
||||||
|
const master = ctx.destination
|
||||||
|
// Fade out gracefully
|
||||||
|
const g = ctx.createGain()
|
||||||
|
g.gain.setValueAtTime(1, ctx.currentTime)
|
||||||
|
g.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.5)
|
||||||
|
g.connect(master)
|
||||||
|
setTimeout(() => {
|
||||||
|
stopFnsRef.current.forEach((fn) => fn())
|
||||||
|
stopFnsRef.current = []
|
||||||
|
ctx.close()
|
||||||
|
ctxRef.current = null
|
||||||
|
setPlaying(false)
|
||||||
|
}, 1600)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (playing) stop()
|
||||||
|
else start()
|
||||||
|
}, [playing, start, stop])
|
||||||
|
|
||||||
|
return { playing, toggle, start, stop }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||||
const [current, setCurrent] = useState(0)
|
const [current, setCurrent] = useState(0)
|
||||||
const [playing, setPlaying] = useState(false)
|
const [playing, setPlaying] = useState(false)
|
||||||
@@ -65,7 +145,9 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
|||||||
const [elapsed, setElapsed] = useState(0)
|
const [elapsed, setElapsed] = useState(0)
|
||||||
const [miniVisible, setMiniVisible] = useState(false)
|
const [miniVisible, setMiniVisible] = useState(false)
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
const ambient = useAmbient()
|
||||||
|
|
||||||
|
const hasTrack = tracks.length > 0
|
||||||
const track = tracks[current] ?? null
|
const track = tracks[current] ?? null
|
||||||
|
|
||||||
const trackName = (i: number) =>
|
const trackName = (i: number) =>
|
||||||
@@ -74,40 +156,31 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
|||||||
`Titel ${i + 1}`
|
`Titel ${i + 1}`
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current
|
const a = audioRef.current
|
||||||
if (!audio) return
|
if (!a) return
|
||||||
audio.volume = muted ? 0 : volume
|
a.volume = muted ? 0 : volume
|
||||||
}, [volume, muted])
|
}, [volume, muted])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current
|
const a = audioRef.current
|
||||||
if (!audio || !track) return
|
if (!a || !track) return
|
||||||
audio.src = `/api/files/${track.filename}`
|
a.src = `/api/files/${track.filename}`
|
||||||
audio.volume = muted ? 0 : volume
|
a.volume = muted ? 0 : volume
|
||||||
setDuration(0)
|
setDuration(0); setElapsed(0); setProgress(0)
|
||||||
setElapsed(0)
|
if (playing) a.play().catch(() => setPlaying(false))
|
||||||
setProgress(0)
|
|
||||||
if (playing) audio.play().catch(() => setPlaying(false))
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [current])
|
}, [current])
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
const audio = audioRef.current
|
const a = audioRef.current
|
||||||
if (!audio) return
|
if (!a) return
|
||||||
if (playing) {
|
if (playing) a.pause()
|
||||||
audio.pause()
|
else a.play().catch(() => setPlaying(false))
|
||||||
} else {
|
|
||||||
audio.play().catch(() => setPlaying(false))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const playTrack = (i: number) => {
|
const playTrack = (i: number) => {
|
||||||
if (i === current) {
|
if (i === current) togglePlay()
|
||||||
togglePlay()
|
else { setCurrent(i); setPlaying(true) }
|
||||||
} else {
|
|
||||||
setCurrent(i)
|
|
||||||
setPlaying(true)
|
|
||||||
}
|
|
||||||
setMiniVisible(true)
|
setMiniVisible(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,40 +188,39 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
|||||||
const next = () => setCurrent((c) => (c + 1) % tracks.length)
|
const next = () => setCurrent((c) => (c + 1) % tracks.length)
|
||||||
|
|
||||||
const handleTimeUpdate = () => {
|
const handleTimeUpdate = () => {
|
||||||
const audio = audioRef.current
|
const a = audioRef.current
|
||||||
if (!audio || !audio.duration) return
|
if (!a || !a.duration) return
|
||||||
setProgress((audio.currentTime / audio.duration) * 100)
|
setProgress((a.currentTime / a.duration) * 100)
|
||||||
setElapsed(audio.currentTime)
|
setElapsed(a.currentTime)
|
||||||
}
|
|
||||||
|
|
||||||
const handleLoadedMetadata = () => {
|
|
||||||
const audio = audioRef.current
|
|
||||||
if (audio) setDuration(audio.duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const audio = audioRef.current
|
const a = audioRef.current
|
||||||
if (!audio || !audio.duration) return
|
if (!a || !a.duration) return
|
||||||
const pct = parseFloat(e.target.value)
|
const pct = parseFloat(e.target.value)
|
||||||
audio.currentTime = (pct / 100) * audio.duration
|
a.currentTime = (pct / 100) * a.duration
|
||||||
setProgress(pct)
|
setProgress(pct)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tracks.length === 0 || !track) return null
|
// Decide what the mini-player shows
|
||||||
|
const miniLabel = hasTrack ? trackName(current) : 'Stille Begleitung'
|
||||||
|
const miniPlaying = hasTrack ? playing : ambient.playing
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<audio
|
{hasTrack && (
|
||||||
ref={audioRef}
|
<audio
|
||||||
src={`/api/files/${track.filename}`}
|
ref={audioRef}
|
||||||
onEnded={next}
|
src={`/api/files/${track!.filename}`}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onEnded={next}
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onPlay={() => setPlaying(true)}
|
onLoadedMetadata={() => { const a = audioRef.current; if (a) setDuration(a.duration) }}
|
||||||
onPause={() => setPlaying(false)}
|
onPlay={() => setPlaying(true)}
|
||||||
/>
|
onPause={() => setPlaying(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Inline section ── */}
|
{/* ── Inline section ─────────────────────────────────────── */}
|
||||||
<section
|
<section
|
||||||
id="musik"
|
id="musik"
|
||||||
className="py-20 px-4"
|
className="py-20 px-4"
|
||||||
@@ -174,128 +246,143 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Track list */}
|
{/* ── No uploads: ambient player ── */}
|
||||||
<div className="space-y-0.5 mb-8">
|
{!hasTrack && (
|
||||||
{tracks.map((t, i) => (
|
<motion.div
|
||||||
<motion.button
|
initial={{ opacity: 0 }}
|
||||||
key={t.id}
|
animate={{ opacity: 1 }}
|
||||||
initial={{ opacity: 0, x: -8 }}
|
transition={{ duration: 1 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
className="text-center"
|
||||||
viewport={{ once: true }}
|
>
|
||||||
transition={{ delay: i * 0.05 }}
|
<p className="font-lora text-amber-100/30 text-sm mb-8 leading-relaxed max-w-xs mx-auto">
|
||||||
onClick={() => playTrack(i)}
|
Ein sanftes Klangteppich begleitet dich,
|
||||||
className={`w-full flex items-center gap-4 px-4 py-3.5 rounded-xl transition-all duration-200 text-left group ${
|
während du durch die Erinnerungen scrollst.
|
||||||
i === current
|
</p>
|
||||||
? 'bg-amber-400/[0.07] border border-amber-400/15'
|
|
||||||
: 'hover:bg-white/[0.03] border border-transparent'
|
<div className="flex flex-col items-center gap-5">
|
||||||
}`}
|
<motion.button
|
||||||
>
|
onClick={() => { ambient.toggle(); setMiniVisible(true) }}
|
||||||
<div className="w-7 flex-shrink-0 flex items-center justify-center">
|
whileTap={{ scale: 0.94 }}
|
||||||
<TrackNumber index={i} isCurrent={i === current} playing={playing} />
|
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">
|
||||||
|
Stille Begleitung
|
||||||
|
</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>
|
||||||
|
|
||||||
<span
|
<div className="flex items-center justify-center gap-8 mb-5">
|
||||||
className={`font-cormorant italic text-lg flex-1 min-w-0 truncate transition-colors ${
|
<button onClick={prev} className="text-amber-400/30 hover:text-amber-400/70 transition-colors">
|
||||||
i === current
|
<SkipBack size={20} />
|
||||||
? 'text-amber-200/80'
|
</button>
|
||||||
: 'text-amber-100/35 group-hover:text-amber-100/60'
|
<motion.button
|
||||||
}`}
|
onClick={() => { togglePlay(); setMiniVisible(true) }}
|
||||||
>
|
whileTap={{ scale: 0.93 }}
|
||||||
{trackName(i)}
|
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"
|
||||||
</span>
|
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>
|
||||||
|
|
||||||
<span className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center justify-center gap-3">
|
||||||
{i === current && playing ? (
|
<button onClick={() => setMuted((m) => !m)} className="text-amber-600/40 hover:text-amber-400/60 transition-colors">
|
||||||
<Pause size={13} className="text-amber-400/50" />
|
{muted ? <VolumeX size={15} /> : <Volume2 size={15} />}
|
||||||
) : (
|
</button>
|
||||||
<Play size={13} className="text-amber-400/40" />
|
<input
|
||||||
)}
|
type="range" min="0" max="1" step="0.01"
|
||||||
</span>
|
value={muted ? 0 : volume}
|
||||||
</motion.button>
|
onChange={(e) => { setVolume(parseFloat(e.target.value)); setMuted(false) }}
|
||||||
))}
|
className="w-28 accent-amber-600 cursor-pointer"
|
||||||
</div>
|
style={{ height: '2px' }}
|
||||||
|
/>
|
||||||
{/* Player controls */}
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<span className="font-lora text-xs text-amber-100/20 w-9 tabular-nums">
|
</>
|
||||||
{formatTime(duration)}
|
)}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls row */}
|
|
||||||
<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 shadow-lg"
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Volume */}
|
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Floating mini-player ── */}
|
{/* ── Floating mini-player ─────────────────────────────────── */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{miniVisible && (
|
{miniVisible && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -305,44 +392,40 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
|||||||
transition={{ duration: 0.25 }}
|
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"
|
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="relative flex-shrink-0">
|
||||||
<div
|
<div className={`w-8 h-8 rounded-full bg-amber-800/40 flex items-center justify-center ${miniPlaying ? 'ring-1 ring-amber-400/30' : ''}`}>
|
||||||
className={`w-8 h-8 rounded-full bg-amber-800/40 flex items-center justify-center ${
|
{miniPlaying
|
||||||
playing ? 'ring-1 ring-amber-400/30' : ''
|
? <WaveformBars playing />
|
||||||
}`}
|
: <Play size={12} className="text-amber-300/70 ml-0.5" />
|
||||||
>
|
}
|
||||||
<Music size={14} className="text-amber-300/80" />
|
|
||||||
</div>
|
</div>
|
||||||
{playing && (
|
{miniPlaying && (
|
||||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track info */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => document.getElementById('musik')?.scrollIntoView({ behavior: 'smooth' })}
|
||||||
document.getElementById('musik')?.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
className="max-w-[130px] text-left"
|
className="max-w-[130px] text-left"
|
||||||
>
|
>
|
||||||
<p className="text-amber-200/70 font-cormorant italic text-sm truncate leading-tight">
|
<p className="text-amber-200/70 font-cormorant italic text-sm truncate leading-tight">
|
||||||
{trackName(current)}
|
{miniLabel}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-amber-600/40 text-xs font-lora mt-0.5">
|
<p className="text-amber-600/40 text-xs font-lora mt-0.5">
|
||||||
{playing ? `${formatTime(elapsed)} / ${formatTime(duration)}` : 'pausiert'}
|
{hasTrack
|
||||||
|
? (playing ? `${formatTime(elapsed)} / ${formatTime(duration)}` : 'pausiert')
|
||||||
|
: (ambient.playing ? 'läuft …' : 'pausiert')
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Play/pause */}
|
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
onClick={hasTrack ? togglePlay : () => { ambient.toggle() }}
|
||||||
className="text-amber-400/60 hover:text-amber-300 transition-colors"
|
className="text-amber-400/60 hover:text-amber-300 transition-colors"
|
||||||
>
|
>
|
||||||
{playing ? <Pause size={16} /> : <Play size={16} />}
|
{miniPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Close */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setMiniVisible(false)}
|
onClick={() => setMiniVisible(false)}
|
||||||
className="text-amber-800/60 hover:text-amber-500/80 transition-colors ml-1"
|
className="text-amber-800/60 hover:text-amber-500/80 transition-colors ml-1"
|
||||||
|
|||||||
Reference in New Issue
Block a user