241 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|