feat(slideshow): public fullscreen slideshow viewer

- /gallery/:slug/show/:token route + SlideshowPage: splash -> fullscreen kiosk,
  crossfade/cut/slide/kenburns/dip-to-white/dip-to-black transitions, color
  filters, white/original logo watermark overlay, contain/letterbox, cursor
  auto-hide, quiet-append of new uploads, live settings poll, and decode-ahead
  preload (first slide decoded before playback) so transitions do not struggle.
- slideshow.service for session/state + shared style types.
This commit is contained in:
Luca
2026-06-19 22:11:02 +02:00
parent dea5e0f8a6
commit fd02254f78
3 changed files with 557 additions and 0 deletions
+8
View File
@@ -10,6 +10,7 @@ import { ThemeProvider } from './contexts/ThemeContext';
import { GalleryPage } from './pages/GalleryPage';
import { ClientAccessPage } from './pages/ClientAccessPage';
import { PreviewPage } from './pages/gallery/PreviewPage';
const SlideshowPage = lazy(() => import('./pages/gallery/SlideshowPage').then((m) => ({ default: m.SlideshowPage })));
import { LegalPage } from './pages/public/LegalPage';
import {
AdminLoginPage,
@@ -159,6 +160,13 @@ function App() {
<Routes>
{/* Public gallery routes */}
<Route path="/gallery/preview" element={<PreviewPage />} />
{/* Live Slideshow ("Diashow") — token-only fullscreen kiosk.
Self-manages its session token; no GalleryAuthProvider. */}
<Route path="/gallery/:slug/show/:token" element={
<Suspense fallback={<Loading />}>
<SlideshowPage />
</Suspense>
} />
<Route path="/gallery/:slug/client-access" element={
<GalleryAuthProvider>
<ClientAccessPage />
@@ -0,0 +1,440 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { galleryService } from '../../services/gallery.service';
import { slideshowService, type SlideshowSettings } from '../../services/slideshow.service';
import { storeGalleryToken, setActiveGallerySlug } from '../../utils/galleryAuthStorage';
import { buildResourceUrl } from '../../utils/url';
import type { Photo } from '../../types';
const DEFAULT_SETTINGS: SlideshowSettings = {
interval_ms: 5000,
transition: 'crossfade',
transition_ms: 800,
colorfilter: 'none',
watermark: null,
};
// How often the running show re-checks settings + photo count (tiny payload).
const STATE_POLL_MS = 3000;
// Prefer the aspect-preserved preview (≤1920px) over the full original; fall
// back to the standard url. Always absolutised so it works whether the API is
// same-origin or an explicit absolute base.
function photoSrc(photo: Photo): string {
return buildResourceUrl(photo.preview_url || photo.hero_url || photo.url);
}
// CSS `filter` applied directly to the image for filters that are pure tone
// adjustments. Tint/vignette filters are drawn as a separate overlay instead.
function imageFilter(colorfilter: SlideshowSettings['colorfilter']): string {
switch (colorfilter) {
case 'bw': return 'grayscale(1)';
case 'sepia': return 'sepia(0.75)';
default: return 'none';
}
}
// Overlay for tint/vignette filters (drawn above the image, below the
// watermark). Returns null when the filter needs no overlay.
function colorOverlayStyle(colorfilter: SlideshowSettings['colorfilter']): React.CSSProperties | null {
switch (colorfilter) {
case 'warm':
return { background: 'rgba(255, 160, 60, 0.18)', mixBlendMode: 'soft-light' };
case 'cool':
return { background: 'rgba(60, 140, 255, 0.18)', mixBlendMode: 'soft-light' };
case 'vignette':
return { background: 'radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(0,0,0,0.55) 100%)' };
default:
return null;
}
}
function watermarkCorner(position: string): React.CSSProperties {
const pad = '4vmin';
switch (position) {
case 'top-left': return { top: pad, left: pad };
case 'top-right': return { top: pad, right: pad };
case 'bottom-left': return { bottom: pad, left: pad };
case 'bottom-right':
default: return { bottom: pad, right: pad };
}
}
type Phase = 'splash' | 'running' | 'ended';
export function SlideshowPage() {
const { slug = '', token = '' } = useParams<{ slug: string; token: string }>();
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>('splash');
const [error, setError] = useState<string | null>(null);
const [eventName, setEventName] = useState<string>('');
const [photos, setPhotos] = useState<Photo[]>([]);
const [settings, setSettings] = useState<SlideshowSettings>(DEFAULT_SETTINGS);
// Two-buffer crossfade: each buffer holds an index into `photos`. `active`
// says which buffer is on top. Advancing loads the next index into the
// hidden buffer, then flips `active` so CSS opacity transitions it in.
const [buffers, setBuffers] = useState<[number, number]>([0, 0]);
const [active, setActive] = useState<0 | 1>(0);
const [cursorHidden, setCursorHidden] = useState(false);
// Bumped on each dip transition to re-trigger the white/black flash overlay.
const [flash, setFlash] = useState(0);
// Refs so timers/pollers read the latest value without re-subscribing.
const photosRef = useRef<Photo[]>(photos);
const settingsRef = useRef<SlideshowSettings>(settings);
const activeRef = useRef<0 | 1>(active);
const positionRef = useRef(0); // index currently shown in the active buffer
photosRef.current = photos;
settingsRef.current = settings;
activeRef.current = active;
// ----- New uploads APPEND quietly: keep existing order/position, only add. -----
const mergePhotos = useCallback((incoming: Photo[]) => {
setPhotos((prev) => {
if (prev.length === 0) return incoming;
const seen = new Set(prev.map((p) => p.id));
const added = incoming.filter((p) => !seen.has(p.id));
return added.length ? [...prev, ...added] : prev;
});
}, []);
const loadPhotos = useCallback(async () => {
const data = await galleryService.getGalleryPhotos(slug);
mergePhotos(data.photos || []);
}, [slug, mergePhotos]);
// Fetch + fully decode an image so the next swap paints instantly (decode()
// forces the browser to rasterise it now rather than on first display, which
// is what otherwise makes the transition stutter). Best-effort.
const preloadDecode = useCallback(async (photo?: Photo) => {
if (!photo) return;
try {
const img = new Image();
img.src = photoSrc(photo);
if (img.decode) await img.decode();
} catch {
/* decode can reject on cache/abort — harmless, the <img> still loads */
}
}, []);
// ----- Advance one slide (wraps at the end). -----
const advance = useCallback(() => {
const list = photosRef.current;
if (list.length < 2) return;
const next = (positionRef.current + 1) % list.length;
positionRef.current = next;
const hidden: 0 | 1 = activeRef.current === 0 ? 1 : 0;
setBuffers((prev) => {
const updated: [number, number] = [...prev] as [number, number];
updated[hidden] = next;
return updated;
});
setActive(hidden);
const tr = settingsRef.current.transition;
if (tr === 'dipwhite' || tr === 'dipblack') setFlash((f) => f + 1);
}, []);
// ----- Start: gesture-driven (fullscreen needs a user gesture). -----
const start = useCallback(async () => {
try {
setError(null);
const session = await slideshowService.getSession(slug, token);
// Store the minted token so XHR photo calls carry it; the cookie set by
// /session covers <img> requests.
storeGalleryToken(slug, session.token);
setActiveGallerySlug(slug);
setEventName(session.event.event_name || '');
setSettings(session.settings || DEFAULT_SETTINGS);
// Load the list and DECODE the first slide (and the next) before we flip
// to running, so playback starts on an already-rasterised image instead
// of struggling on the first transition.
const data = await galleryService.getGalleryPhotos(slug);
const list = data.photos || [];
setPhotos(list);
await preloadDecode(list[0]);
void preloadDecode(list[1]);
positionRef.current = 0;
setBuffers([0, 0]);
setActive(0);
setPhase('running');
// Enter fullscreen (best-effort — projector/kiosk; ignore if blocked).
try {
await document.documentElement.requestFullscreen?.();
} catch {
/* fullscreen denied — keep running windowed */
}
} catch (e: any) {
if (e?.response?.status === 404) {
setError(t('slideshow.notFound', 'This slideshow link is not active.'));
} else {
setError(t('slideshow.startFailed', 'Could not start the slideshow. Please try again.'));
}
}
}, [slug, token, preloadDecode, t]);
// ----- Auto-advance timer (restarts when interval or photo count changes). -----
useEffect(() => {
if (phase !== 'running') return;
if (photos.length < 2) return;
const id = window.setInterval(advance, Math.max(1000, settings.interval_ms));
return () => window.clearInterval(id);
}, [phase, photos.length, settings.interval_ms, advance]);
// ----- Live poll: settings changes + new uploads + link death. -----
useEffect(() => {
if (phase !== 'running') return;
let cancelled = false;
const id = window.setInterval(async () => {
try {
const state = await slideshowService.getState(slug, token);
if (cancelled) return;
const prev = settingsRef.current;
const next: SlideshowSettings = {
interval_ms: state.interval_ms,
transition: state.transition,
transition_ms: state.transition_ms,
colorfilter: state.colorfilter,
watermark: state.watermark,
};
if (JSON.stringify(next) !== JSON.stringify({
interval_ms: prev.interval_ms,
transition: prev.transition,
transition_ms: prev.transition_ms,
colorfilter: prev.colorfilter,
watermark: prev.watermark,
})) {
setSettings(next);
}
if (state.photo_count !== photosRef.current.length) {
await loadPhotos();
}
} catch (e: any) {
if (cancelled) return;
if (e?.response?.status === 404) {
// Admin disabled/rotated the link, or the gallery expired.
setPhase('ended');
}
// Other errors (transient network): ignore and keep showing.
}
}, STATE_POLL_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [phase, slug, token, loadPhotos]);
// ----- Decode-ahead the next 12 images so transitions don't pop. -----
useEffect(() => {
if (phase !== 'running' || photos.length === 0) return;
for (let i = 1; i <= 2; i++) {
void preloadDecode(photos[(positionRef.current + i) % photos.length]);
}
}, [phase, photos, buffers, preloadDecode]);
// ----- Auto-hide the cursor after inactivity. -----
useEffect(() => {
if (phase !== 'running') return;
let timer: number;
const onMove = () => {
setCursorHidden(false);
window.clearTimeout(timer);
timer = window.setTimeout(() => setCursorHidden(true), 3000);
};
onMove();
window.addEventListener('mousemove', onMove);
return () => {
window.removeEventListener('mousemove', onMove);
window.clearTimeout(timer);
};
}, [phase]);
const isDip = settings.transition === 'dipwhite' || settings.transition === 'dipblack';
// Dip & cut swap instantly under the flash; the others crossfade.
const bufferMs = settings.transition === 'cut' || isDip ? 0 : Math.max(0, settings.transition_ms);
const bufferStyle = (buf: 0 | 1): React.CSSProperties => {
const isActive = active === buf;
const base: React.CSSProperties = {
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: isActive ? 1 : 0,
transition: `opacity ${bufferMs}ms ease-in-out, transform ${bufferMs}ms ease-in-out`,
};
if (settings.transition === 'slide') {
base.transform = isActive ? 'translateX(0)' : 'translateX(6%)';
}
return base;
};
const imgStyle = (buf: 0 | 1): React.CSSProperties => {
const isActive = active === buf;
const style: React.CSSProperties = {
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
filter: imageFilter(settings.colorfilter),
};
if (settings.transition === 'kenburns' && isActive) {
style.animation = `picpeak-kenburns ${Math.max(2000, settings.interval_ms)}ms ease-out forwards`;
}
return style;
};
const renderBuffer = (buf: 0 | 1) => {
const idx = buffers[buf];
const photo = photos[idx];
if (!photo) return null;
return (
<div style={bufferStyle(buf)}>
<img src={photoSrc(photo)} alt="" style={imgStyle(buf)} draggable={false} />
</div>
);
};
const overlay = colorOverlayStyle(settings.colorfilter);
const watermarkUrl = settings.watermark ? buildResourceUrl(settings.watermark.url) : null;
const containerStyle: React.CSSProperties = useMemo(
() => ({
position: 'fixed',
inset: 0,
background: '#000',
overflow: 'hidden',
cursor: cursorHidden ? 'none' : 'default',
userSelect: 'none',
}),
[cursorHidden]
);
return (
<div style={containerStyle}>
<style>{`
@keyframes picpeak-kenburns {
0% { transform: scale(1) translate(0, 0); }
100% { transform: scale(1.12) translate(-2%, -2%); }
}
@keyframes picpeak-dip {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
`}</style>
{phase === 'splash' && (
<div
onClick={start}
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
cursor: 'pointer',
textAlign: 'center',
padding: 24,
}}
>
<div style={{ fontSize: 64, lineHeight: 1, marginBottom: 24 }}></div>
<div style={{ fontSize: 24, fontWeight: 600 }}>
{t('slideshow.start', 'Start slideshow')}
</div>
{eventName && <div style={{ fontSize: 16, opacity: 0.7, marginTop: 8 }}>{eventName}</div>}
{error && <div style={{ color: '#f87171', marginTop: 24, fontSize: 16 }}>{error}</div>}
</div>
)}
{phase === 'running' && photos.length === 0 && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
opacity: 0.6,
fontSize: 20,
}}
>
{t('slideshow.waiting', 'Waiting for photos…')}
</div>
)}
{phase === 'running' && photos.length > 0 && (
<>
{renderBuffer(0)}
{renderBuffer(1)}
{/* Tint / vignette overlay */}
{overlay && <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', ...overlay }} />}
{/* Dip-to-white / dip-to-black flash (re-keyed each advance) */}
{isDip && flash > 0 && (
<div
key={flash}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background: settings.transition === 'dipwhite' ? '#fff' : '#000',
animation: `picpeak-dip ${Math.max(200, settings.transition_ms)}ms ease-in-out`,
}}
/>
)}
{/* ZDF/ARD-ident-style watermark: white, semi-transparent corner logo */}
{watermarkUrl && (
<img
src={watermarkUrl}
alt=""
draggable={false}
style={{
position: 'absolute',
...watermarkCorner(settings.watermark!.position),
width: 'auto',
height: 'auto',
maxWidth: '16vw',
maxHeight: '14vh',
opacity: Math.min(1, Math.max(0, (settings.watermark!.opacity ?? 60) / 100)),
// 'white' recolors the logo white (dark/transparent marks); 'original'
// leaves a boxed/colored logo as-is so it doesn't become a white blob.
filter: settings.watermark!.style === 'original' ? 'none' : 'brightness(0) invert(1)',
pointerEvents: 'none',
}}
/>
)}
</>
)}
{phase === 'ended' && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
opacity: 0.7,
fontSize: 20,
}}
>
{t('slideshow.ended', 'Slideshow ended.')}
</div>
)}
</div>
);
}
export default SlideshowPage;
+109
View File
@@ -0,0 +1,109 @@
import { api } from '../config/api';
export type SlideshowTransition = 'crossfade' | 'cut' | 'slide' | 'kenburns' | 'dipwhite' | 'dipblack';
export type SlideshowColorFilter = 'none' | 'bw' | 'sepia' | 'warm' | 'cool' | 'vignette';
// Which logo the watermark overlays. The first three are branding assets the
// admin uploads in Settings → Branding; 'event' is the event's own hero logo.
export type SlideshowWatermarkSource = 'logo' | 'logo_dark' | 'favicon' | 'event';
export type SlideshowWatermarkPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
// 'white' recolors the logo white (TV-ident look, for dark/transparent marks);
// 'original' keeps the logo's own colors (for boxed/colored badges).
export type SlideshowWatermarkStyle = 'white' | 'original';
export const SLIDESHOW_WATERMARK_STYLES: SlideshowWatermarkStyle[] = ['white', 'original'];
// Per-surface watermark choice. 'inherit' = follow the global default
// (admin Settings → Slideshow); 'on'/'off' = explicit override.
export type SlideshowWatermarkMode = 'inherit' | 'on' | 'off';
export const SLIDESHOW_WATERMARK_MODES: SlideshowWatermarkMode[] = ['inherit', 'on', 'off'];
export const SLIDESHOW_TRANSITIONS: SlideshowTransition[] = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
export const SLIDESHOW_COLORFILTERS: SlideshowColorFilter[] = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
export const SLIDESHOW_WATERMARK_SOURCES: SlideshowWatermarkSource[] = ['logo', 'logo_dark', 'favicon', 'event'];
export const SLIDESHOW_WATERMARK_POSITIONS: SlideshowWatermarkPosition[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
// Canonical editable style shape, shared by the per-event card and the
// per-event-type preset editor. The per-event-type `slideshow_preset` JSON
// uses exactly these keys; the per-event API maps them to `show_*` columns.
export interface SlideshowStyle {
interval_ms: number;
transition: SlideshowTransition;
transition_ms: number;
watermark: SlideshowWatermarkMode;
watermark_source: SlideshowWatermarkSource;
watermark_position: SlideshowWatermarkPosition;
watermark_opacity: number;
watermark_style: SlideshowWatermarkStyle;
colorfilter: SlideshowColorFilter;
}
export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
interval_ms: 5000,
transition: 'crossfade',
transition_ms: 800,
watermark: 'inherit',
watermark_source: 'logo',
watermark_position: 'bottom-right',
watermark_opacity: 60,
watermark_style: 'white',
colorfilter: 'none',
};
// Global slideshow defaults (admin Settings → Slideshow). The per-event
// watermark mode 'inherit' falls back to these.
export interface SlideshowGlobalDefaults {
slideshow_watermark_enabled: boolean;
slideshow_watermark_source: SlideshowWatermarkSource;
slideshow_watermark_position: SlideshowWatermarkPosition;
slideshow_watermark_opacity: number;
slideshow_watermark_style: SlideshowWatermarkStyle;
}
// Resolved watermark the kiosk renders (logo URL already resolved server-side).
export interface SlideshowWatermark {
url: string;
position: SlideshowWatermarkPosition;
opacity: number;
style: SlideshowWatermarkStyle;
}
export interface SlideshowSettings {
interval_ms: number;
transition: SlideshowTransition;
transition_ms: number;
colorfilter: SlideshowColorFilter;
watermark: SlideshowWatermark | null;
}
export interface SlideshowSession {
token: string;
event: {
event_name: string;
event_type?: string;
color_theme?: string | null;
};
settings: SlideshowSettings;
photo_count: number;
expires_at: string | null;
}
// Tiny live-poll payload (settings + current photo count). Hit every few
// seconds by the running show so admin changes and new uploads take effect.
export interface SlideshowState extends SlideshowSettings {
photo_count: number;
expires_at: string | null;
}
export const slideshowService = {
// Open a slideshow session: validates the share token, sets the gallery
// auth cookie (so <img> requests are authorized) and returns the minted
// session token + current settings/count. Throws 404 if the link is
// disabled, rotated, or the gallery isn't live.
async getSession(slug: string, token: string): Promise<SlideshowSession> {
const response = await api.get<SlideshowSession>(`/gallery/${slug}/show/${token}/session`);
return response.data;
},
async getState(slug: string, token: string): Promise<SlideshowState> {
const response = await api.get<SlideshowState>(`/gallery/${slug}/show/${token}/state`);
return response.data;
},
};