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:
denshooter
2026-02-16 03:57:48 +01:00
parent 4d56d4904a
commit 6363d1327b
6 changed files with 253 additions and 170 deletions
+7 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from 'next'
import { Cormorant_Garamond, Lora } from 'next/font/google'
import GlobalMusicPlayer from '@/components/GlobalMusicPlayer'
import './globals.css'
const cormorant = Cormorant_Garamond({
@@ -18,6 +19,8 @@ const lora = Lora({
display: 'swap',
})
export const dynamic = 'force-dynamic'
export const metadata: Metadata = {
title: 'In Erinnerung an Maria Malejka',
description:
@@ -36,7 +39,10 @@ export default function RootLayout({
}) {
return (
<html lang="de" className={`${cormorant.variable} ${lora.variable}`}>
<body className="font-lora antialiased">{children}</body>
<body className="font-lora antialiased">
{children}
<GlobalMusicPlayer />
</body>
</html>
)
}
+147
View File
@@ -0,0 +1,147 @@
'use client'
import { motion } from 'framer-motion'
import { ArrowLeft } from 'lucide-react'
const fade = {
initial: { opacity: 0, y: 20 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true },
transition: { duration: 0.8 },
}
export default function MeineOmaPage() {
return (
<main className="min-h-screen bg-cream">
<div className="max-w-2xl mx-auto px-6 py-20 sm:py-28">
<a
href="/"
className="inline-flex items-center gap-1.5 text-warm-brown-light/50 hover:text-warm-gold text-sm font-lora transition-colors mb-16"
>
<ArrowLeft size={14} />
Zurück
</a>
<motion.div {...fade} className="text-center mb-16">
<p className="text-warm-gold/50 text-xs tracking-[0.5em] uppercase font-lora mb-4">
Von Dennis
</p>
<h1 className="font-cormorant italic text-5xl sm:text-6xl text-warm-brown mb-4">
Meine Oma
</h1>
<div className="flex items-center justify-center gap-4">
<div className="h-px w-16 bg-warm-gold/30" />
<span className="text-warm-gold/40 text-lg">&#10045;</span>
<div className="h-px w-16 bg-warm-gold/30" />
</div>
</motion.div>
<div className="space-y-8 font-lora text-warm-brown/80 text-base sm:text-lg leading-relaxed">
<motion.p {...fade}>
Ich kam aus der Schule und das Essen stand schon da. Jedes Mal.
Rahmsauce. Ich weiß nicht, wie oft ich die gegessen habe, aber
es war nie genug. Ich würde alles dafür geben, noch einmal an
ihrem Küchentisch zu sitzen.
</motion.p>
<motion.p {...fade}>
Oma roch nach Oma. Ich weiß nicht, wie ich das anders beschreiben
soll. Nicht nach Parfum. Nicht nach irgendwas, das man kaufen kann.
Einfach nach ihr. Wenn ich an sie denke, ist das Erste, was
kommt, dieses Gefühl. Diese Wärme. Der Geruch von ihrem Haus.
</motion.p>
<motion.p {...fade}>
Bei ihr war es immer heiß. Immer. Der Kamin lief, die Heizung
lief, man hat geschwitzt und es war trotzdem schön. An Weihnachten
war der Karpfen in der Badewanne und die Geschenke im Wohnzimmer.
Aber die Tür blieb zu. Erst essen, dann Geschenke. Das war Gesetz.
Und wir haben uns jedes Mal gefreut, als wären wir fünf.
</motion.p>
<motion.p {...fade}>
Wenn Pico bei ihr war, hab ich nicht mehr existiert. Sie hat mit
ihm geredet, ihn gefüttert, ihn verwöhnt. Ich saß daneben und
war Luft. Aber das war okay. Weil sie so glücklich war dabei.
</motion.p>
<motion.p {...fade}>
Pico ist letztes Jahr gestorben. Und jetzt Oma. Ich stelle mir
vor, wie sie irgendwo sitzt und er neben ihr liegt und sie ihm
wieder irgendwas erzählt, was er nicht versteht. Und er hört
trotzdem zu.
</motion.p>
<motion.p {...fade}>
Sie war stur. Richtig stur. Deswegen hatte sie auch den
Oberschenkelhalsbruch. Weil sie alles alleine machen wollte.
Weil sie sich nichts sagen lassen hat. Man konnte sich aufregen.
Aber im Nachhinein war das auch das, was sie so stark gemacht hat.
</motion.p>
<motion.div
{...fade}
className="border-l-2 border-warm-gold/30 pl-6 py-2 my-12"
>
<p className="font-cormorant italic text-2xl sm:text-3xl text-warm-brown/70 leading-snug">
Ich habe es dir viel zu selten gesagt, Oma.
<br />
Aber ich habe dich geliebt. Sehr.
</p>
</motion.div>
<motion.p {...fade}>
Das letzte Mal hab ich sie im Krankenhaus besucht. In der
Geriatrie. Sie lief schon wieder, es ging ihr gut. Sie hat
im Aufenthaltsraum Blumenvasen gesehen und meinte, die könnte
man ja mitnehmen. Ich hab gelacht. So war sie halt.
</motion.p>
<motion.p {...fade}>
Ihr Zimmer war gegenüber von dem Zimmer, in dem meine Mutter
wochenlang gelegen hatte, als sie mit mir schwanger war. Mama
durfte sich kaum bewegen damals. Im selben Flur, in dem ich auf
die Welt kam, ist Oma gegangen. Das lässt mich nicht los.
</motion.p>
<motion.p {...fade}>
Am 10. Februar, um 13:13 Uhr, schrieb mir meine Schwester
auf Telegram: <span className="italic text-warm-brown">Sie
möchten die Reanimation abbrechen."</span>
</motion.p>
<motion.p {...fade}>
Meine Schwester ist schwanger. Sie hat den Ultraschall mit in
den Sarg gelegt. Ein Kind, das Oma nie treffen wird, aber das
in ihrem Haus aufwachsen wird. In den Wänden, die nach Oma
riechen. In der Küche, in der die Rahmsauce stand.
</motion.p>
<motion.p {...fade}>
Diese Seite ist für dich. Damit ich nicht vergesse. Damit
niemand vergisst.
</motion.p>
</div>
{/* Footer */}
<motion.div
{...fade}
className="mt-20 text-center"
>
<div className="flex items-center justify-center gap-4 mb-8">
<div className="h-px w-12 bg-warm-gold/20" />
<span className="text-warm-gold/30">&#10045;</span>
<div className="h-px w-12 bg-warm-gold/20" />
</div>
<a
href="/"
className="font-lora text-warm-brown-light/40 hover:text-warm-gold text-sm transition-colors"
>
Zurück zur Gedenkseite
</a>
</motion.div>
</div>
</main>
)
}
+1 -8
View File
@@ -5,7 +5,6 @@ import PhotoSlideshow from '@/components/PhotoSlideshow'
import PhotoGallery from '@/components/PhotoGallery'
import MemorySection from '@/components/MemorySection'
import WriteSection from '@/components/WriteSection'
import MusicPlayer from '@/components/MusicPlayer'
import VideoGallery from '@/components/VideoGallery'
import TributeSection from '@/components/TributeSection'
@@ -25,10 +24,7 @@ export default async function HomePage() {
const videos = plain<MediaItem>(
db.prepare("SELECT * FROM media WHERE type = 'video' ORDER BY sort_order, created_at").all()
)
const music = plain<MediaItem>(
db.prepare("SELECT * FROM media WHERE type = 'music' ORDER BY sort_order, created_at").all()
)
const memories = plain<Memory>(
const memories = plain<Memory>(
db.prepare('SELECT * FROM memories ORDER BY created_at DESC').all()
)
@@ -93,9 +89,6 @@ export default async function HomePage() {
{/* Videos */}
<VideoGallery videos={videos} />
{/* Floating music player */}
<MusicPlayer tracks={music} />
{/* Footer */}
<footer className="py-12 text-center border-t border-warm-border bg-amber-50/30">
<div className="max-w-lg mx-auto px-4">
+15
View File
@@ -0,0 +1,15 @@
import { getDb } from '@/lib/db'
import type { MediaItem } from '@/lib/types'
import MusicPlayer from './MusicPlayer'
function plain<T>(rows: unknown[]): T[] {
return JSON.parse(JSON.stringify(rows))
}
export default function GlobalMusicPlayer() {
const db = getDb()
const music = plain<MediaItem>(
db.prepare("SELECT * FROM media WHERE type = 'music' ORDER BY sort_order, created_at").all()
)
return <MusicPlayer tracks={music} />
}
+64 -38
View File
@@ -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
const startAndFade = (a: HTMLAudioElement) => {
if (startedRef.current) return
startedRef.current = true
a.volume = 0
a.play().then(() => setPlaying(true)).catch(() => {})
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
})
}
// Fade in on first interaction
// 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 (unmutedRef.current) return
unmutedRef.current = true
if (!startedRef.current) return
const t0 = performance.now()
const fade = () => {
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)
const t = Math.min((performance.now() - t0) / FADE_IN_MS, 1)
if (!fadingRef.current) active.volume = VOLUME * t
if (t < 1) requestAnimationFrame(fade)
}
requestAnimationFrame(step)
requestAnimationFrame(fade)
}
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))
}).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])
+12 -116
View File
@@ -13,8 +13,6 @@ export default function TributeSection() {
return (
<section id="ueber-oma" className="py-20 sm:py-28">
<div className="max-w-2xl mx-auto px-6">
{/* ── Family perspective ─────────────────────────────────── */}
<motion.div {...fade} className="text-center mb-16">
<p className="text-warm-gold/50 text-xs tracking-[0.5em] uppercase font-lora mb-4">
Für immer in unseren Herzen
@@ -56,8 +54,8 @@ export default function TributeSection() {
Weihnachten bei Oma und Opa war jedes Jahr das Gleiche, und genau
das hat es so besonders gemacht. Der Kamin lief. Der Karpfen
schwamm in der Badewanne. Die Geschenke lagen im Wohnzimmer,
aber die Tür blieb zu, bis alle fertig gegessen hatten. Berge
von Essen. Und sie mittendrin, glücklich wenn alle satt waren.
aber die Tür blieb zu, bis alle fertig gegessen hatten.
Und sie mittendrin, glücklich wenn alle satt waren.
Sie hat es geliebt, zu geben.
</motion.p>
@@ -76,8 +74,8 @@ export default function TributeSection() {
</motion.p>
<motion.p {...fade}>
Ihr Haus, in dem so viel passiert ist, wird jetzt von Jacky und
Niklas weitergeführt. Bald wird dort ein neues Leben beginnen.
Ihr Haus, in dem so viel passiert ist, wird jetzt von ihrer
Enkelin weitergeführt. Bald wird dort ein neues Leben beginnen.
Ein Kind, das seine Uroma nie kennenlernen wird. Aber dessen
Ultraschallbild bei ihr im Sarg liegt. So nah, wie es nur geht.
</motion.p>
@@ -113,116 +111,7 @@ export default function TributeSection() {
</motion.p>
</div>
{/* Divider between family and personal */}
<motion.div {...fade} className="my-24 flex items-center justify-center gap-4">
<div className="h-px w-20 bg-warm-gold/15" />
<span className="text-warm-gold/25 text-sm">&#10045;&#10045;&#10045;</span>
<div className="h-px w-20 bg-warm-gold/15" />
</motion.div>
{/* ── Dennis's perspective ───────────────────────────────── */}
<motion.div {...fade} className="text-center mb-16">
<p className="text-warm-gold/50 text-xs tracking-[0.5em] uppercase font-lora mb-4">
Von Dennis
</p>
<h2 className="font-cormorant italic text-5xl sm:text-6xl text-warm-brown mb-4">
Meine Oma
</h2>
<div className="flex items-center justify-center gap-4">
<div className="h-px w-16 bg-warm-gold/30" />
<span className="text-warm-gold/40 text-lg">&#10045;</span>
<div className="h-px w-16 bg-warm-gold/30" />
</div>
</motion.div>
<div className="space-y-8 font-lora text-warm-brown/80 text-base sm:text-lg leading-relaxed">
<motion.p {...fade}>
Ich kam aus der Schule und das Essen stand schon da. Jedes Mal.
Rahmsauce. Ich weiß nicht, wie oft ich die gegessen habe, aber
es war nie genug. Ich würde alles dafür geben, noch einmal an
ihrem Küchentisch zu sitzen.
</motion.p>
<motion.p {...fade}>
Oma roch nach Oma. Ich weiß nicht, wie ich das anders beschreiben
soll. Nicht nach Parfum. Nicht nach irgendwas, das man kaufen kann.
Einfach nach ihr. Wenn ich an sie denke, ist das Erste, was
kommt, dieses Gefühl. Diese Wärme. Der Geruch von ihrem Haus.
</motion.p>
<motion.p {...fade}>
Bei ihr war es immer heiß. Immer. Der Kamin lief, die Heizung
lief, man hat geschwitzt und es war trotzdem schön. An Weihnachten
war der Karpfen in der Badewanne und die Geschenke im Wohnzimmer.
Aber die Tür blieb zu. Erst essen, dann Geschenke. Das war Gesetz.
Und wir haben uns jedes Mal gefreut, als wären wir fünf.
</motion.p>
<motion.p {...fade}>
Wenn Pico bei ihr war, hab ich nicht mehr existiert. Sie hat mit
ihm geredet, ihn gefüttert, ihn verwöhnt. Ich saß daneben und
war Luft. Aber das war okay. Weil sie so glücklich war dabei.
</motion.p>
<motion.p {...fade}>
Pico ist letztes Jahr gestorben. Und jetzt Oma. Ich stelle mir
vor, wie sie irgendwo sitzt und er neben ihr liegt und sie ihm
wieder irgendwas erzählt, was er nicht versteht. Und er hört
trotzdem zu.
</motion.p>
<motion.p {...fade}>
Sie war stur. Richtig stur. Deswegen hatte sie auch den
Oberschenkelhalsbruch. Weil sie alles alleine machen wollte.
Weil sie sich nichts sagen lassen hat. Man konnte sich aufregen.
Aber im Nachhinein war das auch das, was sie so stark gemacht hat.
</motion.p>
<motion.div
{...fade}
className="border-l-2 border-warm-gold/30 pl-6 py-2 my-12"
>
<p className="font-cormorant italic text-2xl sm:text-3xl text-warm-brown/70 leading-snug">
Ich habe es dir viel zu selten gesagt, Oma.
<br />
Aber ich habe dich geliebt. Sehr.
</p>
</motion.div>
<motion.p {...fade}>
Das letzte Mal hab ich sie im Krankenhaus besucht. In der
Geriatrie. Sie lief schon wieder, es ging ihr gut. Sie hat
im Aufenthaltsraum Blumenvasen gesehen und meinte, die könnte
man ja mitnehmen. Ich hab gelacht. So war sie halt.
</motion.p>
<motion.p {...fade}>
Ihr Zimmer war gegenüber von dem Zimmer, in dem meine Mutter
wochenlang gelegen hatte, als sie mit mir schwanger war. Mama
durfte sich kaum bewegen damals. Im selben Flur, in dem ich auf
die Welt kam, ist Oma gegangen. Das lässt mich nicht los.
</motion.p>
<motion.p {...fade}>
Am 10. Februar, um 13:13 Uhr, schrieb mir meine Schwester
auf Telegram: <span className="italic text-warm-brown">Sie
möchten die Reanimation abbrechen."</span>
</motion.p>
<motion.p {...fade}>
Jacky ist schwanger. Sie hat den Ultraschall mit in den Sarg
gelegt. Ein Kind, das Oma nie treffen wird, aber das in ihrem
Haus aufwachsen wird. In den Wänden, die nach Oma riechen. In
der Küche, in der die Rahmsauce stand.
</motion.p>
<motion.p {...fade}>
Diese Seite ist für dich. Damit ich nicht vergesse. Damit
niemand vergisst.
</motion.p>
</div>
{/* Closing details */}
{/* Closing */}
<motion.div
{...fade}
className="mt-20 text-center"
@@ -241,6 +130,13 @@ export default function TributeSection() {
<p className="font-lora text-warm-brown-light/30 text-xs mt-3 tracking-wide">
Beerdigung am 20. Februar 2026
</p>
<a
href="/meine-oma"
className="inline-block mt-10 font-cormorant italic text-warm-brown-light/30 hover:text-warm-gold/60 text-sm transition-colors duration-300"
>
Von Dennis
</a>
</motion.div>
</div>
</section>