Files
fotospiel-app/resources/js/guest/pages/LiveShowPlayerPage.tsx
Codex Agent 53eb560aa5
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add live show player playback and effects
2026-01-05 18:31:01 +01:00

241 lines
9.4 KiB
TypeScript

import React from 'react';
import { useParams } from 'react-router-dom';
import { Loader2, Maximize2, Minimize2, Pause, Play, WifiOff } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { useLiveShowState } from '../hooks/useLiveShowState';
import { useLiveShowPlayback } from '../hooks/useLiveShowPlayback';
import LiveShowStage from '../components/LiveShowStage';
import LiveShowBackdrop from '../components/LiveShowBackdrop';
import { useTranslation } from '../i18n/useTranslation';
import { prefersReducedMotion } from '../lib/motion';
import { resolveLiveShowEffect } from '../lib/liveShowEffects';
export default function LiveShowPlayerPage() {
const { token } = useParams<{ token: string }>();
const { t } = useTranslation();
const { status, connection, error, event, photos, settings } = useLiveShowState(token ?? null);
const [paused, setPaused] = React.useState(false);
const { frame, layout, frameKey, nextFrame } = useLiveShowPlayback(photos, settings, { paused });
const hasPhoto = frame.length > 0;
const stageTitle = event?.name ?? t('liveShowPlayer.title', 'Live Show');
const reducedMotion = prefersReducedMotion();
const effect = resolveLiveShowEffect(settings.effect_preset, settings.effect_intensity, reducedMotion);
const showStage = status === 'ready' && hasPhoto;
const showEmpty = status === 'ready' && !hasPhoto;
const [controlsVisible, setControlsVisible] = React.useState(true);
const [isFullscreen, setIsFullscreen] = React.useState(false);
const [isOnline, setIsOnline] = React.useState(typeof navigator !== 'undefined' ? navigator.onLine : true);
const hideTimerRef = React.useRef<number | null>(null);
const preloadRef = React.useRef<Set<string>>(new Set());
const stageRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
document.body.classList.add('guest-immersive');
return () => {
document.body.classList.remove('guest-immersive');
};
}, []);
React.useEffect(() => {
const updateOnline = () => setIsOnline(navigator.onLine);
window.addEventListener('online', updateOnline);
window.addEventListener('offline', updateOnline);
return () => {
window.removeEventListener('online', updateOnline);
window.removeEventListener('offline', updateOnline);
};
}, []);
React.useEffect(() => {
const handleFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('fullscreenchange', handleFullscreen);
handleFullscreen();
return () => document.removeEventListener('fullscreenchange', handleFullscreen);
}, []);
const revealControls = React.useCallback(() => {
setControlsVisible(true);
if (hideTimerRef.current) {
window.clearTimeout(hideTimerRef.current);
}
hideTimerRef.current = window.setTimeout(() => {
setControlsVisible(false);
}, 3000);
}, []);
React.useEffect(() => {
if (!showStage) {
setControlsVisible(true);
return;
}
revealControls();
}, [revealControls, showStage, frameKey]);
const togglePause = React.useCallback(() => {
setPaused((prev) => !prev);
}, []);
const toggleFullscreen = React.useCallback(async () => {
const target = stageRef.current ?? document.documentElement;
try {
if (!document.fullscreenElement) {
await target.requestFullscreen?.();
} else {
await document.exitFullscreen?.();
}
} catch (err) {
console.warn('Fullscreen toggle failed', err);
}
}, []);
React.useEffect(() => {
const handleKey = (event: KeyboardEvent) => {
if (event.target && (event.target as HTMLElement).closest('input, textarea, select, button')) {
return;
}
if (event.code === 'Space') {
event.preventDefault();
togglePause();
revealControls();
}
if (event.key.toLowerCase() === 'f') {
event.preventDefault();
toggleFullscreen();
revealControls();
}
if (event.key === 'Escape' && document.fullscreenElement) {
event.preventDefault();
document.exitFullscreen?.();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [revealControls, toggleFullscreen, togglePause]);
React.useEffect(() => {
const candidates = [...frame, ...nextFrame].slice(0, 6);
candidates.forEach((photo) => {
const src = photo.full_url || photo.thumb_url;
if (!src || preloadRef.current.has(src)) {
return;
}
const img = new Image();
img.src = src;
preloadRef.current.add(src);
});
}, [frame, nextFrame]);
return (
<div
ref={stageRef}
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-black text-white"
aria-busy={status === 'loading'}
onMouseMove={revealControls}
onTouchStart={revealControls}
>
<LiveShowBackdrop mode={settings.background_mode} photo={frame[0]} intensity={settings.effect_intensity} />
<div className="pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between px-6 py-4 text-sm">
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
{stageTitle}
</span>
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
{connection === 'sse'
? t('liveShowPlayer.connection.live', 'Live')
: t('liveShowPlayer.connection.sync', 'Sync')}
</span>
</div>
{status === 'loading' && (
<div className="flex flex-col items-center gap-4 text-white/70">
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
<p className="text-sm">{t('liveShowPlayer.loading', 'Live Show wird geladen...')}</p>
</div>
)}
{status === 'error' && (
<div className="max-w-md space-y-2 px-6 text-center">
<p className="text-lg font-semibold text-white">
{t('liveShowPlayer.error.title', 'Live Show nicht erreichbar')}
</p>
<p className="text-sm text-white/70">
{error ?? t('liveShowPlayer.error.description', 'Bitte überprüfe den Live-Link.')}
</p>
</div>
)}
<AnimatePresence initial={false} mode="sync">
{showStage && (
<motion.div key={frameKey} className="relative z-10 flex min-h-0 w-full flex-1 items-stretch" {...effect.frame}>
<LiveShowStage layout={layout} photos={frame} title={stageTitle} />
</motion.div>
)}
</AnimatePresence>
{showStage && effect.flash && (
<motion.div
key={`flash-${frameKey}`}
className="pointer-events-none absolute inset-0 z-20 bg-white"
{...effect.flash}
/>
)}
<AnimatePresence initial={false}>
{controlsVisible && (
<motion.div
className="absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-3 rounded-full border border-white/10 bg-black/60 px-4 py-2 text-xs text-white/80 shadow-lg backdrop-blur"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.2 }}
>
<button
type="button"
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
onClick={togglePause}
>
{paused ? <Play className="h-4 w-4" aria-hidden /> : <Pause className="h-4 w-4" aria-hidden />}
<span>{paused ? t('liveShowPlayer.controls.play', 'Play') : t('liveShowPlayer.controls.pause', 'Pause')}</span>
</button>
<button
type="button"
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
onClick={toggleFullscreen}
>
{isFullscreen ? <Minimize2 className="h-4 w-4" aria-hidden /> : <Maximize2 className="h-4 w-4" aria-hidden />}
<span>
{isFullscreen
? t('liveShowPlayer.controls.exitFullscreen', 'Exit fullscreen')
: t('liveShowPlayer.controls.fullscreen', 'Fullscreen')}
</span>
</button>
{!isOnline && (
<span className="flex items-center gap-2 text-white/70">
<WifiOff className="h-4 w-4" aria-hidden />
{t('liveShowPlayer.controls.offline', 'Offline')}
</span>
)}
</motion.div>
)}
</AnimatePresence>
{paused && showStage && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<div className="rounded-full border border-white/20 bg-black/50 px-6 py-3 text-sm font-semibold uppercase tracking-[0.3em] text-white/80">
{t('liveShowPlayer.controls.paused', 'Paused')}
</div>
</div>
)}
{showEmpty && (
<div className="max-w-md space-y-2 px-6 text-center text-white/70">
<p className="text-lg font-semibold text-white">
{t('liveShowPlayer.empty.title', 'Noch keine Live-Fotos')}
</p>
<p className="text-sm">{t('liveShowPlayer.empty.description', 'Warte auf die ersten Uploads...')}</p>
</div>
)}
</div>
);
}