Files
fotospiel-app/resources/js/guest-v2/screens/LiveShowScreen.tsx
2026-02-03 15:18:44 +01:00

218 lines
8.6 KiB
TypeScript

import React from 'react';
import { useParams } from 'react-router-dom';
import { Loader2, Maximize2, Minimize2, Pause, Play } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { useLiveShowState } from '@/guest/hooks/useLiveShowState';
import { useLiveShowPlayback } from '@/guest/hooks/useLiveShowPlayback';
import LiveShowStage from '@/guest/components/LiveShowStage';
import LiveShowBackdrop from '@/guest/components/LiveShowBackdrop';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { prefersReducedMotion } from '@/guest/lib/motion';
import { resolveLiveShowEffect } from '@/guest/lib/liveShowEffects';
import EventLogo from '../components/EventLogo';
export default function LiveShowScreen() {
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 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 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">
<div className="flex items-center gap-2">
<EventLogo name={stageTitle} icon={event?.type?.icon ?? null} size="s" />
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
{stageTitle}
</span>
</div>
<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>
)}
{showEmpty && (
<div className="max-w-md space-y-2 px-6 text-center text-white/80">
<p className="text-lg font-semibold text-white">
{t('liveShowPlayer.empty.title', 'Noch keine Live-Fotos')}
</p>
<p className="text-sm text-white/70">
{t('liveShowPlayer.empty.description', 'Warte auf die ersten Uploads...')}
</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>
</motion.div>
)}
</AnimatePresence>
</div>
);
}