diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index 97aa01e..1f91f64 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -17,7 +17,11 @@
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."}
-{"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"open","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T11:11:36.821959901+01:00","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]}
+{"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T18:30:13.318396255+01:00","closed_at":"2026-01-05T18:30:13.318396255+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]}
+{"id":"fotospiel-app-539.2","title":"Live Show player shell + routing + data layer","description":"Add /show/{token} route + guest player page shell, Live Show API client, SSE/polling subscription and state model.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:41.587003393+01:00","created_by":"soeren","updated_at":"2026-01-05T16:44:39.577762479+01:00","closed_at":"2026-01-05T16:44:39.577762479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.2","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:41.641767879+01:00","created_by":"soeren"}]}
+{"id":"fotospiel-app-539.3","title":"Live Show playback engine (queue, pacing, layouts)","description":"Implement player playback scheduler, queue management, and layout rendering for single/split/grid.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:56.531080931+01:00","created_by":"soeren","updated_at":"2026-01-05T17:40:45.929168571+01:00","closed_at":"2026-01-05T17:40:45.929168571+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:56.631147026+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:57:56.655278463+01:00","created_by":"soeren"}]}
+{"id":"fotospiel-app-539.4","title":"Live Show effects presets + background modes","description":"Map effect presets/intensity to animations and implement background modes (blur last, gradient, solid, brand).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T15:58:08.823403031+01:00","created_by":"soeren","updated_at":"2026-01-05T18:14:21.141791556+01:00","closed_at":"2026-01-05T18:14:21.141791556+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.4","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:58:08.841926692+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.4","depends_on_id":"fotospiel-app-539.3","type":"blocks","created_at":"2026-01-05T15:58:08.859783346+01:00","created_by":"soeren"}]}
+{"id":"fotospiel-app-539.5","title":"Live Show player UX polish (fullscreen, states, performance)","description":"Add fullscreen/keyboard controls, loading/empty/offline states, and performance safeguards (preload, memory).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T15:58:22.340342615+01:00","created_by":"soeren","updated_at":"2026-01-05T18:28:03.575811673+01:00","closed_at":"2026-01-05T18:28:03.575811673+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.5","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:58:22.365600168+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.5","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:58:22.412769585+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.5","depends_on_id":"fotospiel-app-539.3","type":"blocks","created_at":"2026-01-05T15:58:22.450984326+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-55n","title":"Tenant admin onboarding: add Paddle error UX + test coverage","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:40.463283816+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:40.463283816+01:00"}
{"id":"fotospiel-app-574","title":"Paddle catalog sync: extend PaddleClient tests/mocks for catalog endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:03.486301225+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:39.626820206+01:00","closed_at":"2026-01-02T21:11:39.626820206+01:00","close_reason":"Deprioritized"}
{"id":"fotospiel-app-576","title":"Tenant admin onboarding: legacy asset audit + component inventory","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:59.996563146+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:05.599274641+01:00","closed_at":"2026-01-01T16:08:05.599274641+01:00","close_reason":"Completed in codebase (verified)"}
diff --git a/.beads/last-touched b/.beads/last-touched
index d7853ab..96c31ba 100644
--- a/.beads/last-touched
+++ b/.beads/last-touched
@@ -1 +1 @@
-fotospiel-app-exp
+fotospiel-app-539.5
diff --git a/resources/js/guest/components/LiveShowBackdrop.tsx b/resources/js/guest/components/LiveShowBackdrop.tsx
new file mode 100644
index 0000000..37ef284
--- /dev/null
+++ b/resources/js/guest/components/LiveShowBackdrop.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import type { LiveShowBackgroundMode, LiveShowPhoto } from '../services/liveShowApi';
+
+function resolvePhotoUrl(photo?: LiveShowPhoto | null): string | null {
+ if (!photo) {
+ return null;
+ }
+ return photo.full_url || photo.thumb_url || null;
+}
+
+function resolveBlurAmount(intensity: number): number {
+ const safe = Number.isFinite(intensity) ? intensity : 70;
+ return 28 + Math.min(60, Math.max(0, safe)) * 0.45;
+}
+
+export default function LiveShowBackdrop({
+ mode,
+ photo,
+ intensity,
+}: {
+ mode: LiveShowBackgroundMode;
+ photo?: LiveShowPhoto | null;
+ intensity: number;
+}) {
+ const photoUrl = resolvePhotoUrl(photo);
+ const blurAmount = resolveBlurAmount(intensity);
+ const fallbackMode = mode === 'blur_last' && !photoUrl ? 'gradient' : mode;
+
+ if (fallbackMode === 'solid') {
+ return (
+
+ );
+ }
+
+ if (fallbackMode === 'gradient') {
+ return ;
+ }
+
+ if (fallbackMode === 'brand') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/resources/js/guest/components/LiveShowStage.tsx b/resources/js/guest/components/LiveShowStage.tsx
new file mode 100644
index 0000000..368a27e
--- /dev/null
+++ b/resources/js/guest/components/LiveShowStage.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import type { LiveShowLayoutMode, LiveShowPhoto } from '../services/liveShowApi';
+
+const BASE_TILE =
+ 'relative overflow-hidden rounded-[28px] bg-black/70 shadow-[0_24px_70px_rgba(0,0,0,0.55)]';
+
+function PhotoTile({
+ photo,
+ fit,
+ label,
+ className = '',
+}: {
+ photo: LiveShowPhoto;
+ fit: 'cover' | 'contain';
+ label: string;
+ className?: string;
+}) {
+ const src = photo.full_url || photo.thumb_url || '';
+ return (
+
+ {src ? (
+

+ ) : (
+
+ {label}
+
+ )}
+
+ );
+}
+
+export default function LiveShowStage({
+ layout,
+ photos,
+ title,
+}: {
+ layout: LiveShowLayoutMode;
+ photos: LiveShowPhoto[];
+ title: string;
+}) {
+ if (photos.length === 0) {
+ return null;
+ }
+
+ if (layout === 'single') {
+ return (
+
+ );
+ }
+
+ if (layout === 'split') {
+ return (
+
+ {photos.slice(0, 2).map((photo) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {photos.slice(0, 4).map((photo) => (
+
+ ))}
+
+ );
+}
diff --git a/resources/js/guest/hooks/__tests__/useLiveShowPlayback.test.tsx b/resources/js/guest/hooks/__tests__/useLiveShowPlayback.test.tsx
new file mode 100644
index 0000000..d7813d4
--- /dev/null
+++ b/resources/js/guest/hooks/__tests__/useLiveShowPlayback.test.tsx
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest';
+import {
+ buildFramePhotos,
+ resolveIntervalMs,
+ resolveItemsPerFrame,
+ resolvePlaybackQueue,
+} from '../useLiveShowPlayback';
+import type { LiveShowPhoto, LiveShowSettings } from '../../services/liveShowApi';
+
+const baseSettings: LiveShowSettings = {
+ retention_window_hours: 12,
+ moderation_mode: 'manual',
+ playback_mode: 'newest_first',
+ pace_mode: 'auto',
+ fixed_interval_seconds: 8,
+ layout_mode: 'single',
+ effect_preset: 'film_cut',
+ effect_intensity: 70,
+ background_mode: 'blur_last',
+};
+
+const photos: LiveShowPhoto[] = [
+ {
+ id: 1,
+ full_url: '/one.jpg',
+ thumb_url: '/one-thumb.jpg',
+ approved_at: '2025-01-01T10:00:00Z',
+ is_featured: false,
+ live_priority: 0,
+ },
+ {
+ id: 2,
+ full_url: '/two.jpg',
+ thumb_url: '/two-thumb.jpg',
+ approved_at: '2025-01-01T12:00:00Z',
+ is_featured: true,
+ live_priority: 2,
+ },
+ {
+ id: 3,
+ full_url: '/three.jpg',
+ thumb_url: '/three-thumb.jpg',
+ approved_at: '2025-01-01T11:00:00Z',
+ is_featured: false,
+ live_priority: 0,
+ },
+];
+
+describe('useLiveShowPlayback helpers', () => {
+ it('resolves items per frame per layout', () => {
+ expect(resolveItemsPerFrame('single')).toBe(1);
+ expect(resolveItemsPerFrame('split')).toBe(2);
+ expect(resolveItemsPerFrame('grid_burst')).toBe(4);
+ });
+
+ it('builds a curated queue when configured', () => {
+ const queue = resolvePlaybackQueue(photos, {
+ ...baseSettings,
+ playback_mode: 'curated',
+ });
+
+ expect(queue[0].id).toBe(2);
+ expect(queue.every((photo) => photo.id === 2 || photo.live_priority > 0 || photo.is_featured)).toBe(true);
+ });
+
+ it('builds frame photos without duplicates when list is smaller', () => {
+ const frame = buildFramePhotos([photos[0]], 0, 4);
+ expect(frame).toHaveLength(1);
+ expect(frame[0].id).toBe(1);
+ });
+
+ it('uses fixed interval when configured', () => {
+ const interval = resolveIntervalMs(
+ {
+ ...baseSettings,
+ pace_mode: 'fixed',
+ fixed_interval_seconds: 12,
+ },
+ photos.length
+ );
+
+ expect(interval).toBe(12_000);
+ });
+});
diff --git a/resources/js/guest/hooks/useLiveShowPlayback.ts b/resources/js/guest/hooks/useLiveShowPlayback.ts
new file mode 100644
index 0000000..0e78aac
--- /dev/null
+++ b/resources/js/guest/hooks/useLiveShowPlayback.ts
@@ -0,0 +1,229 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import type { LiveShowLayoutMode, LiveShowPhoto, LiveShowSettings } from '../services/liveShowApi';
+
+const MIN_FIXED_SECONDS = 3;
+const MAX_FIXED_SECONDS = 20;
+
+function resolveApprovedAt(photo: LiveShowPhoto): number {
+ if (!photo.approved_at) {
+ return 0;
+ }
+ const parsed = Date.parse(photo.approved_at);
+ return Number.isNaN(parsed) ? 0 : parsed;
+}
+
+function resolvePriority(photo: LiveShowPhoto): number {
+ return Number.isFinite(photo.live_priority) ? photo.live_priority : 0;
+}
+
+export function resolveItemsPerFrame(layout: LiveShowLayoutMode): number {
+ switch (layout) {
+ case 'split':
+ return 2;
+ case 'grid_burst':
+ return 4;
+ case 'single':
+ default:
+ return 1;
+ }
+}
+
+export function resolveIntervalMs(settings: LiveShowSettings, totalCount: number): number {
+ if (settings.pace_mode === 'fixed') {
+ const safeSeconds = Math.min(MAX_FIXED_SECONDS, Math.max(MIN_FIXED_SECONDS, settings.fixed_interval_seconds));
+ return safeSeconds * 1000;
+ }
+
+ if (totalCount >= 60) return 4500;
+ if (totalCount >= 30) return 5500;
+ if (totalCount >= 15) return 6500;
+ if (totalCount >= 6) return 7500;
+ return 9000;
+}
+
+export function resolvePlaybackQueue(photos: LiveShowPhoto[], settings: LiveShowSettings): LiveShowPhoto[] {
+ if (photos.length === 0) {
+ return [];
+ }
+
+ const newestFirst = [...photos].sort((a, b) => {
+ const timeDiff = resolveApprovedAt(b) - resolveApprovedAt(a);
+ if (timeDiff !== 0) return timeDiff;
+ return b.id - a.id;
+ });
+
+ if (settings.playback_mode === 'newest_first') {
+ return newestFirst;
+ }
+
+ if (settings.playback_mode === 'curated') {
+ const curated = photos.filter((photo) => photo.is_featured || resolvePriority(photo) > 0);
+ const base = curated.length > 0 ? curated : photos;
+ return [...base].sort((a, b) => {
+ const priorityDiff = resolvePriority(b) - resolvePriority(a);
+ if (priorityDiff !== 0) return priorityDiff;
+ const timeDiff = resolveApprovedAt(b) - resolveApprovedAt(a);
+ if (timeDiff !== 0) return timeDiff;
+ return b.id - a.id;
+ });
+ }
+
+ const oldestFirst = [...photos].sort((a, b) => {
+ const timeDiff = resolveApprovedAt(a) - resolveApprovedAt(b);
+ if (timeDiff !== 0) return timeDiff;
+ return a.id - b.id;
+ });
+
+ const balanced: LiveShowPhoto[] = [];
+ const seen = new Set();
+ let newestIndex = 0;
+ let oldestIndex = 0;
+ let newestStreak = 0;
+
+ while (balanced.length < photos.length) {
+ let added = false;
+
+ if (newestIndex < newestFirst.length && newestStreak < 2) {
+ const candidate = newestFirst[newestIndex++];
+ if (!seen.has(candidate.id)) {
+ balanced.push(candidate);
+ seen.add(candidate.id);
+ newestStreak += 1;
+ added = true;
+ }
+ }
+
+ if (!added) {
+ while (oldestIndex < oldestFirst.length && seen.has(oldestFirst[oldestIndex].id)) {
+ oldestIndex += 1;
+ }
+ if (oldestIndex < oldestFirst.length) {
+ const candidate = oldestFirst[oldestIndex++];
+ balanced.push(candidate);
+ seen.add(candidate.id);
+ newestStreak = 0;
+ added = true;
+ }
+ }
+
+ if (!added) {
+ while (newestIndex < newestFirst.length && seen.has(newestFirst[newestIndex].id)) {
+ newestIndex += 1;
+ }
+ if (newestIndex < newestFirst.length) {
+ const candidate = newestFirst[newestIndex++];
+ balanced.push(candidate);
+ seen.add(candidate.id);
+ newestStreak += 1;
+ added = true;
+ }
+ }
+
+ if (!added) {
+ break;
+ }
+ }
+
+ return balanced;
+}
+
+export function buildFramePhotos(
+ queue: LiveShowPhoto[],
+ startIndex: number,
+ itemsPerFrame: number
+): LiveShowPhoto[] {
+ if (queue.length === 0) {
+ return [];
+ }
+
+ const safeCount = Math.min(itemsPerFrame, queue.length);
+ const result: LiveShowPhoto[] = [];
+ for (let offset = 0; offset < safeCount; offset += 1) {
+ const idx = (startIndex + offset) % queue.length;
+ result.push(queue[idx]);
+ }
+
+ return result;
+}
+
+export type LiveShowPlaybackState = {
+ frame: LiveShowPhoto[];
+ layout: LiveShowLayoutMode;
+ intervalMs: number;
+ frameKey: string;
+ nextFrame: LiveShowPhoto[];
+};
+
+export function useLiveShowPlayback(
+ photos: LiveShowPhoto[],
+ settings: LiveShowSettings,
+ options: { paused?: boolean } = {}
+): LiveShowPlaybackState {
+ const queue = useMemo(() => resolvePlaybackQueue(photos, settings), [photos, settings]);
+ const layout = settings.layout_mode;
+ const itemsPerFrame = resolveItemsPerFrame(layout);
+ const [index, setIndex] = useState(0);
+ const currentIdRef = useRef(null);
+ const paused = Boolean(options.paused);
+
+ useEffect(() => {
+ if (queue.length === 0) {
+ setIndex(0);
+ currentIdRef.current = null;
+ return;
+ }
+
+ if (currentIdRef.current !== null) {
+ const existingIndex = queue.findIndex((photo) => photo.id === currentIdRef.current);
+ if (existingIndex >= 0) {
+ setIndex(existingIndex);
+ return;
+ }
+ }
+
+ setIndex((prev) => prev % queue.length);
+ }, [queue]);
+
+ const frame = useMemo(() => {
+ const framePhotos = buildFramePhotos(queue, index, itemsPerFrame);
+ currentIdRef.current = framePhotos[0]?.id ?? null;
+ return framePhotos;
+ }, [queue, index, itemsPerFrame]);
+
+ const frameKey = useMemo(() => {
+ if (frame.length === 0) {
+ return `empty-${layout}`;
+ }
+
+ return frame.map((photo) => photo.id).join('-');
+ }, [frame, layout]);
+
+ const nextFrame = useMemo(() => {
+ if (queue.length === 0) {
+ return [];
+ }
+ return buildFramePhotos(queue, index + itemsPerFrame, itemsPerFrame);
+ }, [index, itemsPerFrame, queue]);
+
+ const intervalMs = resolveIntervalMs(settings, queue.length);
+
+ useEffect(() => {
+ if (queue.length === 0 || paused) {
+ return undefined;
+ }
+
+ const timer = window.setInterval(() => {
+ setIndex((prev) => (prev + itemsPerFrame) % queue.length);
+ }, intervalMs);
+
+ return () => window.clearInterval(timer);
+ }, [intervalMs, itemsPerFrame, queue.length]);
+
+ return {
+ frame,
+ layout,
+ intervalMs,
+ frameKey,
+ nextFrame,
+ };
+}
diff --git a/resources/js/guest/hooks/useLiveShowState.ts b/resources/js/guest/hooks/useLiveShowState.ts
new file mode 100644
index 0000000..36e5aa8
--- /dev/null
+++ b/resources/js/guest/hooks/useLiveShowState.ts
@@ -0,0 +1,317 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ buildLiveShowStreamUrl,
+ DEFAULT_LIVE_SHOW_SETTINGS,
+ fetchLiveShowState,
+ fetchLiveShowUpdates,
+ LiveShowCursor,
+ LiveShowEvent,
+ LiveShowPhoto,
+ LiveShowSettings,
+ LiveShowState,
+ LiveShowError,
+} from '../services/liveShowApi';
+
+export type LiveShowStatus = 'loading' | 'ready' | 'error';
+export type LiveShowConnection = 'idle' | 'sse' | 'polling';
+
+const MAX_PHOTOS = 200;
+const POLL_INTERVAL_MS = 12_000;
+const POLL_HIDDEN_INTERVAL_MS = 30_000;
+
+function mergePhotos(existing: LiveShowPhoto[], incoming: LiveShowPhoto[]): LiveShowPhoto[] {
+ if (incoming.length === 0) {
+ return existing;
+ }
+
+ const byId = new Map();
+ existing.forEach((photo) => byId.set(photo.id, photo));
+ incoming.forEach((photo) => {
+ if (!byId.has(photo.id)) {
+ byId.set(photo.id, photo);
+ }
+ });
+
+ return Array.from(byId.values()).slice(-MAX_PHOTOS);
+}
+
+function resolveErrorMessage(error: unknown): string {
+ if (error instanceof LiveShowError) {
+ return error.message || 'Live Show konnte nicht geladen werden.';
+ }
+
+ if (error instanceof Error) {
+ return error.message || 'Live Show konnte nicht geladen werden.';
+ }
+
+ return 'Live Show konnte nicht geladen werden.';
+}
+
+function safeParseJson(value: string): T | null {
+ try {
+ return JSON.parse(value) as T;
+ } catch (error) {
+ console.warn('Live show event payload parse failed:', error);
+ return null;
+ }
+}
+
+export type LiveShowStateResult = {
+ status: LiveShowStatus;
+ connection: LiveShowConnection;
+ error: string | null;
+ event: LiveShowEvent | null;
+ settings: LiveShowSettings;
+ settingsVersion: string;
+ photos: LiveShowPhoto[];
+ cursor: LiveShowCursor | null;
+};
+
+export function useLiveShowState(token: string | null, limit = 50): LiveShowStateResult {
+ const [status, setStatus] = useState('loading');
+ const [connection, setConnection] = useState('idle');
+ const [error, setError] = useState(null);
+ const [event, setEvent] = useState(null);
+ const [settings, setSettings] = useState(DEFAULT_LIVE_SHOW_SETTINGS);
+ const [settingsVersion, setSettingsVersion] = useState('');
+ const [photos, setPhotos] = useState([]);
+ const [cursor, setCursor] = useState(null);
+ const [visible, setVisible] = useState(
+ typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
+ );
+ const cursorRef = useRef(null);
+ const settingsVersionRef = useRef('');
+ const eventSourceRef = useRef(null);
+ const pollingTimerRef = useRef(null);
+ const pollInFlight = useRef(false);
+
+ const updateCursor = useCallback((next: LiveShowCursor | null) => {
+ cursorRef.current = next;
+ setCursor(next);
+ }, []);
+
+ const applySettings = useCallback((nextSettings: LiveShowSettings, nextVersion: string) => {
+ setSettings(nextSettings);
+ setSettingsVersion(nextVersion);
+ settingsVersionRef.current = nextVersion;
+ }, []);
+
+ const applyPhotos = useCallback(
+ (incoming: LiveShowPhoto[], nextCursor?: LiveShowCursor | null) => {
+ if (incoming.length === 0) {
+ return;
+ }
+
+ setPhotos((existing) => mergePhotos(existing, incoming));
+ if (nextCursor) {
+ updateCursor(nextCursor);
+ }
+ },
+ [updateCursor]
+ );
+
+ const closeEventSource = useCallback(() => {
+ if (eventSourceRef.current) {
+ eventSourceRef.current.close();
+ eventSourceRef.current = null;
+ }
+ }, []);
+
+ const clearPolling = useCallback(() => {
+ if (pollingTimerRef.current !== null) {
+ window.clearInterval(pollingTimerRef.current);
+ pollingTimerRef.current = null;
+ }
+ }, []);
+
+ const pollUpdates = useCallback(async () => {
+ if (!token || pollInFlight.current) {
+ return;
+ }
+
+ pollInFlight.current = true;
+ try {
+ const update = await fetchLiveShowUpdates(token, {
+ cursor: cursorRef.current,
+ settingsVersion: settingsVersionRef.current,
+ limit,
+ });
+
+ if (update.settings) {
+ applySettings(update.settings, update.settings_version);
+ } else if (update.settings_version && update.settings_version !== settingsVersionRef.current) {
+ settingsVersionRef.current = update.settings_version;
+ setSettingsVersion(update.settings_version);
+ }
+
+ if (update.photos.length > 0) {
+ applyPhotos(update.photos, update.cursor ?? cursorRef.current);
+ } else if (update.cursor) {
+ updateCursor(update.cursor);
+ }
+ } catch (err) {
+ console.warn('Live show polling error:', err);
+ } finally {
+ pollInFlight.current = false;
+ }
+ }, [applyPhotos, applySettings, limit, token, updateCursor]);
+
+ const startPolling = useCallback(() => {
+ clearPolling();
+ setConnection('polling');
+ void pollUpdates();
+ const interval = visible ? POLL_INTERVAL_MS : POLL_HIDDEN_INTERVAL_MS;
+ pollingTimerRef.current = window.setInterval(() => {
+ void pollUpdates();
+ }, interval);
+ }, [clearPolling, pollUpdates, visible]);
+
+ const startSse = useCallback(() => {
+ if (!token || typeof EventSource === 'undefined') {
+ return false;
+ }
+
+ closeEventSource();
+
+ const url = buildLiveShowStreamUrl(token, {
+ cursor: cursorRef.current,
+ settingsVersion: settingsVersionRef.current,
+ limit,
+ });
+
+ try {
+ const stream = new EventSource(url);
+ eventSourceRef.current = stream;
+ setConnection('sse');
+
+ stream.addEventListener('settings.updated', (event) => {
+ const payload = safeParseJson<{
+ settings?: LiveShowSettings;
+ settings_version?: string;
+ }>((event as MessageEvent).data);
+ if (!payload) {
+ return;
+ }
+ if (payload.settings && payload.settings_version) {
+ applySettings(payload.settings, payload.settings_version);
+ }
+ });
+
+ stream.addEventListener('photo.approved', (event) => {
+ const payload = safeParseJson<{
+ photo?: LiveShowPhoto;
+ cursor?: LiveShowCursor | null;
+ }>((event as MessageEvent).data);
+ if (!payload) {
+ return;
+ }
+ if (payload.photo) {
+ applyPhotos([payload.photo], payload.cursor ?? null);
+ } else if (payload.cursor) {
+ updateCursor(payload.cursor);
+ }
+ });
+
+ stream.addEventListener('error', () => {
+ closeEventSource();
+ startPolling();
+ });
+
+ return true;
+ } catch (err) {
+ console.warn('Live show SSE failed:', err);
+ closeEventSource();
+ return false;
+ }
+ }, [applyPhotos, applySettings, closeEventSource, limit, startPolling, token, updateCursor]);
+
+ const startStreaming = useCallback(() => {
+ clearPolling();
+
+ if (!startSse()) {
+ startPolling();
+ }
+ }, [clearPolling, startPolling, startSse]);
+
+ useEffect(() => {
+ const onVisibility = () => setVisible(document.visibilityState === 'visible');
+ document.addEventListener('visibilitychange', onVisibility);
+
+ return () => document.removeEventListener('visibilitychange', onVisibility);
+ }, []);
+
+ useEffect(() => {
+ if (connection !== 'polling') {
+ return;
+ }
+
+ startPolling();
+ }, [connection, startPolling, visible]);
+
+ useEffect(() => {
+ if (!token) {
+ setStatus('error');
+ setError('Live Show konnte nicht geladen werden.');
+ setEvent(null);
+ setPhotos([]);
+ updateCursor(null);
+ setSettings(DEFAULT_LIVE_SHOW_SETTINGS);
+ setSettingsVersion('');
+ setConnection('idle');
+ return;
+ }
+
+ let cancelled = false;
+
+ const load = async () => {
+ setStatus('loading');
+ setError(null);
+ setConnection('idle');
+
+ try {
+ const data: LiveShowState = await fetchLiveShowState(token, limit);
+ if (cancelled) {
+ return;
+ }
+
+ setEvent(data.event);
+ setPhotos(data.photos);
+ updateCursor(data.cursor);
+ applySettings(data.settings, data.settings_version);
+ setStatus('ready');
+ startStreaming();
+ } catch (err) {
+ if (cancelled) {
+ return;
+ }
+
+ setStatus('error');
+ setError(resolveErrorMessage(err));
+ setConnection('idle');
+ }
+ };
+
+ load();
+
+ return () => {
+ cancelled = true;
+ closeEventSource();
+ clearPolling();
+ setConnection('idle');
+ };
+ }, [applySettings, clearPolling, closeEventSource, limit, startStreaming, token, updateCursor]);
+
+ return useMemo(
+ () => ({
+ status,
+ connection,
+ error,
+ event,
+ settings,
+ settingsVersion,
+ photos,
+ cursor,
+ }),
+ [status, connection, error, event, settings, settingsVersion, photos, cursor]
+ );
+}
diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts
index 5c4d7d6..6a8291f 100644
--- a/resources/js/guest/i18n/messages.ts
+++ b/resources/js/guest/i18n/messages.ts
@@ -45,6 +45,30 @@ export const messages: Record = {
tabStatus: 'Upload-Status',
},
},
+ liveShowPlayer: {
+ title: 'Live Show',
+ loading: 'Live Show wird geladen...',
+ connection: {
+ live: 'Live',
+ sync: 'Sync',
+ },
+ controls: {
+ play: 'Weiter',
+ pause: 'Pause',
+ fullscreen: 'Vollbild',
+ exitFullscreen: 'Vollbild verlassen',
+ offline: 'Offline',
+ paused: 'Pausiert',
+ },
+ empty: {
+ title: 'Noch keine Live-Fotos',
+ description: 'Warte auf die ersten Uploads...',
+ },
+ error: {
+ title: 'Live Show nicht erreichbar',
+ description: 'Bitte überprüfe den Live-Link.',
+ },
+ },
eventAccess: {
loading: {
title: 'Wir prüfen deinen Zugang...',
@@ -753,6 +777,30 @@ export const messages: Record = {
tabStatus: 'Upload status',
},
},
+ liveShowPlayer: {
+ title: 'Live Show',
+ loading: 'Loading Live Show...',
+ connection: {
+ live: 'Live',
+ sync: 'Sync',
+ },
+ controls: {
+ play: 'Play',
+ pause: 'Pause',
+ fullscreen: 'Fullscreen',
+ exitFullscreen: 'Exit fullscreen',
+ offline: 'Offline',
+ paused: 'Paused',
+ },
+ empty: {
+ title: 'No live photos yet',
+ description: 'Waiting for the first uploads...',
+ },
+ error: {
+ title: 'Live Show unavailable',
+ description: 'Please check the live link.',
+ },
+ },
eventAccess: {
loading: {
title: 'Checking your access...',
diff --git a/resources/js/guest/lib/__tests__/liveShowEffects.test.ts b/resources/js/guest/lib/__tests__/liveShowEffects.test.ts
new file mode 100644
index 0000000..cc29fd0
--- /dev/null
+++ b/resources/js/guest/lib/__tests__/liveShowEffects.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { resolveLiveShowEffect } from '../liveShowEffects';
+
+describe('resolveLiveShowEffect', () => {
+ it('adds flash overlay for shutter flash preset', () => {
+ const effect = resolveLiveShowEffect('shutter_flash', 80, false);
+ expect(effect.flash).toBeDefined();
+ expect(effect.frame.initial).toBeDefined();
+ expect(effect.frame.animate).toBeDefined();
+ });
+
+ it('keeps light effects simple without flash', () => {
+ const effect = resolveLiveShowEffect('light_effects', 80, false);
+ expect(effect.flash).toBeUndefined();
+ });
+
+ it('honors reduced motion with basic fade', () => {
+ const effect = resolveLiveShowEffect('film_cut', 80, true);
+ expect(effect.flash).toBeUndefined();
+ expect(effect.frame.initial).toEqual({ opacity: 0 });
+ });
+});
diff --git a/resources/js/guest/lib/liveShowEffects.ts b/resources/js/guest/lib/liveShowEffects.ts
new file mode 100644
index 0000000..4c2d042
--- /dev/null
+++ b/resources/js/guest/lib/liveShowEffects.ts
@@ -0,0 +1,107 @@
+import type { MotionProps, Transition } from 'framer-motion';
+import { IOS_EASE, IOS_EASE_SOFT } from './motion';
+import type { LiveShowEffectPreset } from '../services/liveShowApi';
+
+export type LiveShowEffectSpec = {
+ frame: MotionProps;
+ flash?: MotionProps;
+};
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
+function resolveIntensity(intensity: number): number {
+ const safe = Number.isFinite(intensity) ? intensity : 70;
+ return clamp(safe / 100, 0, 1);
+}
+
+function buildTransition(duration: number, ease: Transition['ease']): Transition {
+ return {
+ duration,
+ ease,
+ };
+}
+
+export function resolveLiveShowEffect(
+ preset: LiveShowEffectPreset,
+ intensity: number,
+ reducedMotion: boolean
+): LiveShowEffectSpec {
+ const strength = reducedMotion ? 0 : resolveIntensity(intensity);
+ const baseDuration = reducedMotion ? 0.2 : clamp(0.9 - strength * 0.35, 0.45, 1);
+ const exitDuration = reducedMotion ? 0.15 : clamp(baseDuration * 0.6, 0.25, 0.6);
+
+ if (reducedMotion) {
+ return {
+ frame: {
+ initial: { opacity: 0 },
+ animate: { opacity: 1 },
+ exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
+ transition: buildTransition(baseDuration, IOS_EASE_SOFT),
+ },
+ };
+ }
+
+ switch (preset) {
+ case 'shutter_flash': {
+ const scale = 1 + strength * 0.05;
+ return {
+ frame: {
+ initial: { opacity: 0, scale, y: 12 * strength },
+ animate: { opacity: 1, scale: 1, y: 0 },
+ exit: { opacity: 0, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
+ transition: buildTransition(baseDuration, IOS_EASE),
+ },
+ flash: {
+ initial: { opacity: 0 },
+ animate: { opacity: [0, 0.85, 0], transition: { duration: 0.5, times: [0, 0.2, 1] } },
+ },
+ };
+ }
+ case 'polaroid_toss': {
+ const rotation = 3 + strength * 5;
+ return {
+ frame: {
+ initial: { opacity: 0, rotate: -rotation, scale: 0.9 },
+ animate: { opacity: 1, rotate: 0, scale: 1 },
+ exit: { opacity: 0, rotate: rotation * 0.5, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
+ transition: buildTransition(baseDuration, IOS_EASE),
+ },
+ };
+ }
+ case 'parallax_glide': {
+ const scale = 1 + strength * 0.06;
+ return {
+ frame: {
+ initial: { opacity: 0, scale, y: 24 * strength },
+ animate: { opacity: 1, scale: 1, y: 0 },
+ exit: { opacity: 0, scale: 1.02, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
+ transition: buildTransition(baseDuration + 0.2, IOS_EASE_SOFT),
+ },
+ };
+ }
+ case 'light_effects': {
+ return {
+ frame: {
+ initial: { opacity: 0 },
+ animate: { opacity: 1 },
+ exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
+ transition: buildTransition(baseDuration * 0.8, IOS_EASE_SOFT),
+ },
+ };
+ }
+ case 'film_cut':
+ default: {
+ const scale = 1 + strength * 0.03;
+ return {
+ frame: {
+ initial: { opacity: 0, scale },
+ animate: { opacity: 1, scale: 1 },
+ exit: { opacity: 0, scale: 0.99, transition: buildTransition(exitDuration, IOS_EASE) },
+ transition: buildTransition(baseDuration, IOS_EASE),
+ },
+ };
+ }
+ }
+}
diff --git a/resources/js/guest/pages/LiveShowPlayerPage.tsx b/resources/js/guest/pages/LiveShowPlayerPage.tsx
new file mode 100644
index 0000000..7235ffc
--- /dev/null
+++ b/resources/js/guest/pages/LiveShowPlayerPage.tsx
@@ -0,0 +1,240 @@
+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(null);
+ const preloadRef = React.useRef>(new Set());
+ const stageRef = React.useRef(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 (
+
+
+
+
+ {stageTitle}
+
+
+ {connection === 'sse'
+ ? t('liveShowPlayer.connection.live', 'Live')
+ : t('liveShowPlayer.connection.sync', 'Sync')}
+
+
+
+ {status === 'loading' && (
+
+
+
{t('liveShowPlayer.loading', 'Live Show wird geladen...')}
+
+ )}
+
+ {status === 'error' && (
+
+
+ {t('liveShowPlayer.error.title', 'Live Show nicht erreichbar')}
+
+
+ {error ?? t('liveShowPlayer.error.description', 'Bitte überprüfe den Live-Link.')}
+
+
+ )}
+
+
+ {showStage && (
+
+
+
+ )}
+
+
+ {showStage && effect.flash && (
+
+ )}
+
+
+ {controlsVisible && (
+
+
+
+ {!isOnline && (
+
+
+ {t('liveShowPlayer.controls.offline', 'Offline')}
+
+ )}
+
+ )}
+
+
+ {paused && showStage && (
+
+
+ {t('liveShowPlayer.controls.paused', 'Paused')}
+
+
+ )}
+
+ {showEmpty && (
+
+
+ {t('liveShowPlayer.empty.title', 'Noch keine Live-Fotos')}
+
+
{t('liveShowPlayer.empty.description', 'Warte auf die ersten Uploads...')}
+
+ )}
+
+ );
+}
diff --git a/resources/js/guest/pages/__tests__/LiveShowPlayerPage.test.tsx b/resources/js/guest/pages/__tests__/LiveShowPlayerPage.test.tsx
new file mode 100644
index 0000000..475ed91
--- /dev/null
+++ b/resources/js/guest/pages/__tests__/LiveShowPlayerPage.test.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import LiveShowPlayerPage from '../LiveShowPlayerPage';
+
+vi.mock('../../hooks/useLiveShowState', () => ({
+ useLiveShowState: () => ({
+ status: 'ready',
+ connection: 'polling',
+ error: null,
+ event: { id: 1, name: 'Showcase' },
+ photos: [],
+ settings: {
+ retention_window_hours: 12,
+ moderation_mode: 'manual',
+ playback_mode: 'newest_first',
+ pace_mode: 'auto',
+ fixed_interval_seconds: 8,
+ layout_mode: 'single',
+ effect_preset: 'film_cut',
+ effect_intensity: 70,
+ background_mode: 'gradient',
+ },
+ }),
+}));
+
+vi.mock('../../hooks/useLiveShowPlayback', () => ({
+ useLiveShowPlayback: () => ({
+ frame: [],
+ nextFrame: [],
+ layout: 'single',
+ frameKey: 'empty',
+ }),
+}));
+
+vi.mock('../../i18n/useTranslation', () => ({
+ useTranslation: () => ({
+ t: (_key: string, fallback: string) => fallback,
+ }),
+}));
+
+describe('LiveShowPlayerPage', () => {
+ it('renders empty state when no photos', () => {
+ render(
+
+
+ } />
+
+
+ );
+
+ expect(screen.getByText('Noch keine Live-Fotos')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx
index 5f7255a..1de7faa 100644
--- a/resources/js/guest/router.tsx
+++ b/resources/js/guest/router.tsx
@@ -29,6 +29,7 @@ const GalleryPage = React.lazy(() => import('./pages/GalleryPage'));
const PhotoLightbox = React.lazy(() => import('./pages/PhotoLightbox'));
const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage'));
const SlideshowPage = React.lazy(() => import('./pages/SlideshowPage'));
+const LiveShowPlayerPage = React.lazy(() => import('./pages/LiveShowPlayerPage'));
const SettingsPage = React.lazy(() => import('./pages/SettingsPage'));
const LegalPage = React.lazy(() => import('./pages/LegalPage'));
const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage'));
@@ -62,6 +63,7 @@ function HomeLayout() {
export const router = createBrowserRouter([
{ path: '/event', element: , errorElement: },
{ path: '/share/:slug', element: , errorElement: },
+ { path: '/show/:token', element: , errorElement: },
{
path: '/setup/:token',
element: ,
diff --git a/resources/js/guest/services/__tests__/liveShowApi.test.ts b/resources/js/guest/services/__tests__/liveShowApi.test.ts
new file mode 100644
index 0000000..beb8d08
--- /dev/null
+++ b/resources/js/guest/services/__tests__/liveShowApi.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import {
+ buildLiveShowStreamUrl,
+ DEFAULT_LIVE_SHOW_SETTINGS,
+ normalizeLiveShowSettings,
+} from '../liveShowApi';
+
+describe('liveShowApi', () => {
+ it('merges live show settings with defaults', () => {
+ const result = normalizeLiveShowSettings({
+ fixed_interval_seconds: 12,
+ effect_intensity: 15,
+ });
+
+ expect(result.fixed_interval_seconds).toBe(12);
+ expect(result.effect_intensity).toBe(15);
+ expect(result.layout_mode).toBe(DEFAULT_LIVE_SHOW_SETTINGS.layout_mode);
+ });
+
+ it('builds stream url with query params', () => {
+ const url = buildLiveShowStreamUrl('demo-token', {
+ cursor: { approved_at: '2025-01-01T00:00:00Z', id: 42 },
+ settingsVersion: 'abc',
+ limit: 60,
+ });
+
+ const parsed = new URL(url, 'http://example.test');
+ expect(parsed.pathname).toBe('/api/v1/live-show/demo-token/stream');
+ expect(parsed.searchParams.get('after_approved_at')).toBe('2025-01-01T00:00:00Z');
+ expect(parsed.searchParams.get('after_id')).toBe('42');
+ expect(parsed.searchParams.get('settings_version')).toBe('abc');
+ expect(parsed.searchParams.get('limit')).toBe('60');
+ });
+});
diff --git a/resources/js/guest/services/liveShowApi.ts b/resources/js/guest/services/liveShowApi.ts
new file mode 100644
index 0000000..9360022
--- /dev/null
+++ b/resources/js/guest/services/liveShowApi.ts
@@ -0,0 +1,302 @@
+export type LiveShowModerationMode = 'off' | 'manual' | 'trusted_only';
+export type LiveShowPlaybackMode = 'newest_first' | 'balanced' | 'curated';
+export type LiveShowPaceMode = 'auto' | 'fixed';
+export type LiveShowLayoutMode = 'single' | 'split' | 'grid_burst';
+export type LiveShowEffectPreset =
+ | 'film_cut'
+ | 'shutter_flash'
+ | 'polaroid_toss'
+ | 'parallax_glide'
+ | 'light_effects';
+export type LiveShowBackgroundMode = 'blur_last' | 'gradient' | 'solid' | 'brand';
+
+export type LiveShowSettings = {
+ retention_window_hours: number;
+ moderation_mode: LiveShowModerationMode;
+ playback_mode: LiveShowPlaybackMode;
+ pace_mode: LiveShowPaceMode;
+ fixed_interval_seconds: number;
+ layout_mode: LiveShowLayoutMode;
+ effect_preset: LiveShowEffectPreset;
+ effect_intensity: number;
+ background_mode: LiveShowBackgroundMode;
+};
+
+export type LiveShowCursor = {
+ approved_at: string | null;
+ id: number;
+};
+
+export type LiveShowEvent = {
+ id: number;
+ slug?: string | null;
+ name: string;
+ default_locale?: string | null;
+};
+
+export type LiveShowPhoto = {
+ id: number;
+ full_url: string;
+ thumb_url: string | null;
+ approved_at: string | null;
+ width?: number | null;
+ height?: number | null;
+ is_featured: boolean;
+ live_priority: number;
+};
+
+export type LiveShowState = {
+ event: LiveShowEvent;
+ settings: LiveShowSettings;
+ settings_version: string;
+ photos: LiveShowPhoto[];
+ cursor: LiveShowCursor | null;
+};
+
+export type LiveShowUpdates = {
+ settings: LiveShowSettings | null;
+ settings_version: string;
+ photos: LiveShowPhoto[];
+ cursor: LiveShowCursor | null;
+};
+
+export type LiveShowErrorCode = 'not_found' | 'invalid_cursor' | 'rate_limited' | 'unknown';
+
+export class LiveShowError extends Error {
+ readonly code: LiveShowErrorCode;
+ readonly status?: number;
+
+ constructor(code: LiveShowErrorCode, message: string, status?: number) {
+ super(message);
+ this.name = 'LiveShowError';
+ this.code = code;
+ this.status = status;
+ }
+}
+
+export const DEFAULT_LIVE_SHOW_SETTINGS: LiveShowSettings = {
+ retention_window_hours: 12,
+ moderation_mode: 'manual',
+ playback_mode: 'newest_first',
+ pace_mode: 'auto',
+ fixed_interval_seconds: 8,
+ layout_mode: 'single',
+ effect_preset: 'film_cut',
+ effect_intensity: 70,
+ background_mode: 'blur_last',
+};
+
+const DEFAULT_EVENT_NAME = 'Fotospiel Live Show';
+
+function coerceLocalized(value: unknown, fallback: string): string {
+ if (typeof value === 'string' && value.trim() !== '') {
+ return value;
+ }
+
+ if (value && typeof value === 'object') {
+ const obj = value as Record;
+ const preferredKeys = ['de', 'en'];
+
+ for (const key of preferredKeys) {
+ const candidate = obj[key];
+ if (typeof candidate === 'string' && candidate.trim() !== '') {
+ return candidate;
+ }
+ }
+
+ const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== '');
+ if (typeof firstString === 'string') {
+ return firstString;
+ }
+ }
+
+ return fallback;
+}
+
+function toNumber(value: unknown, fallback: number): number {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+export function normalizeLiveShowSettings(raw?: Partial | null): LiveShowSettings {
+ const merged = {
+ ...DEFAULT_LIVE_SHOW_SETTINGS,
+ ...(raw ?? {}),
+ };
+
+ return {
+ retention_window_hours: toNumber(merged.retention_window_hours, DEFAULT_LIVE_SHOW_SETTINGS.retention_window_hours),
+ moderation_mode: merged.moderation_mode,
+ playback_mode: merged.playback_mode,
+ pace_mode: merged.pace_mode,
+ fixed_interval_seconds: toNumber(merged.fixed_interval_seconds, DEFAULT_LIVE_SHOW_SETTINGS.fixed_interval_seconds),
+ layout_mode: merged.layout_mode,
+ effect_preset: merged.effect_preset,
+ effect_intensity: toNumber(merged.effect_intensity, DEFAULT_LIVE_SHOW_SETTINGS.effect_intensity),
+ background_mode: merged.background_mode,
+ };
+}
+
+function normalizeLiveShowEvent(raw: Record): LiveShowEvent {
+ return {
+ id: Number(raw.id ?? 0),
+ slug: typeof raw.slug === 'string' ? raw.slug : null,
+ name: coerceLocalized(raw.name, DEFAULT_EVENT_NAME),
+ default_locale: typeof raw.default_locale === 'string' ? raw.default_locale : null,
+ };
+}
+
+function normalizeLiveShowPhoto(raw: Record): LiveShowPhoto {
+ return {
+ id: Number(raw.id ?? 0),
+ full_url: String(raw.full_url ?? ''),
+ thumb_url: typeof raw.thumb_url === 'string' ? raw.thumb_url : null,
+ approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null,
+ width: typeof raw.width === 'number' ? raw.width : null,
+ height: typeof raw.height === 'number' ? raw.height : null,
+ is_featured: Boolean(raw.is_featured),
+ live_priority: Number(raw.live_priority ?? 0),
+ };
+}
+
+function normalizeCursor(raw: Record | null): LiveShowCursor | null {
+ if (!raw) {
+ return null;
+ }
+
+ return {
+ approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null,
+ id: Number(raw.id ?? 0),
+ };
+}
+
+function resolveErrorCode(status: number, payload: Record | null): LiveShowErrorCode {
+ const error = typeof payload?.error === 'string' ? payload.error : null;
+
+ if (error === 'live_show_not_found' || status === 404) {
+ return 'not_found';
+ }
+
+ if (error === 'invalid_cursor' || status === 422) {
+ return 'invalid_cursor';
+ }
+
+ if (status === 429) {
+ return 'rate_limited';
+ }
+
+ return 'unknown';
+}
+
+async function handleResponse(response: Response): Promise {
+ if (response.status === 204) {
+ return {} as T;
+ }
+
+ const data = await response.json().catch(() => null);
+
+ if (!response.ok) {
+ const payload = data as Record | null;
+ const code = resolveErrorCode(response.status, payload);
+ const message =
+ typeof payload?.message === 'string'
+ ? payload.message
+ : typeof payload?.error === 'string'
+ ? payload.error
+ : 'Live show request failed';
+ throw new LiveShowError(code, message, response.status);
+ }
+
+ return data as T;
+}
+
+function buildParams(options: {
+ cursor?: LiveShowCursor | null;
+ settingsVersion?: string;
+ limit?: number;
+} = {}): URLSearchParams {
+ const params = new URLSearchParams();
+ if (options.limit) {
+ params.set('limit', String(options.limit));
+ }
+ if (options.settingsVersion) {
+ params.set('settings_version', options.settingsVersion);
+ }
+ if (options.cursor?.approved_at) {
+ params.set('after_approved_at', options.cursor.approved_at);
+ params.set('after_id', String(options.cursor.id));
+ }
+ return params;
+}
+
+export function buildLiveShowStreamUrl(
+ token: string,
+ options: {
+ cursor?: LiveShowCursor | null;
+ settingsVersion?: string;
+ limit?: number;
+ } = {}
+): string {
+ const params = buildParams(options);
+ const base = `/api/v1/live-show/${encodeURIComponent(token)}/stream`;
+ const query = params.toString();
+ return query ? `${base}?${query}` : base;
+}
+
+export async function fetchLiveShowState(token: string, limit = 50): Promise {
+ const params = new URLSearchParams();
+ if (limit) {
+ params.set('limit', String(limit));
+ }
+
+ const response = await fetch(
+ `/api/v1/live-show/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`,
+ {
+ headers: { Accept: 'application/json' },
+ credentials: 'omit',
+ }
+ );
+
+ const data = await handleResponse>(response);
+ const rawEvent = (data.event as Record) ?? {};
+
+ return {
+ event: normalizeLiveShowEvent(rawEvent),
+ settings: normalizeLiveShowSettings(data.settings as Partial | null),
+ settings_version: String(data.settings_version ?? ''),
+ photos: Array.isArray(data.photos)
+ ? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record))
+ : [],
+ cursor: normalizeCursor((data.cursor as Record) ?? null),
+ };
+}
+
+export async function fetchLiveShowUpdates(
+ token: string,
+ options: {
+ cursor?: LiveShowCursor | null;
+ settingsVersion?: string;
+ limit?: number;
+ } = {}
+): Promise {
+ const params = buildParams(options);
+
+ const response = await fetch(
+ `/api/v1/live-show/${encodeURIComponent(token)}/updates${params.toString() ? `?${params.toString()}` : ''}`,
+ {
+ headers: { Accept: 'application/json' },
+ credentials: 'omit',
+ }
+ );
+
+ const data = await handleResponse>(response);
+
+ return {
+ settings: data.settings ? normalizeLiveShowSettings(data.settings as Partial) : null,
+ settings_version: String(data.settings_version ?? ''),
+ photos: Array.isArray(data.photos)
+ ? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record))
+ : [],
+ cursor: normalizeCursor((data.cursor as Record) ?? null),
+ };
+}
diff --git a/routes/web.php b/routes/web.php
index a8f5e80..3c46f1e 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -340,6 +340,7 @@ Route::prefix('event-admin')->group(function () {
});
Route::view('/event', 'guest')->name('guest.pwa.landing');
Route::view('/g/{token}', 'guest')->where('token', '.*')->name('guest.gallery');
+Route::view('/show/{token}', 'guest')->where('token', '.*')->name('guest.live-show');
Route::view('/e/{token}/{path?}', 'guest')
->where('token', '.*')
->where('path', '.*')
diff --git a/tests/Feature/GuestLiveShowRouteTest.php b/tests/Feature/GuestLiveShowRouteTest.php
new file mode 100644
index 0000000..e3c2aa1
--- /dev/null
+++ b/tests/Feature/GuestLiveShowRouteTest.php
@@ -0,0 +1,16 @@
+get('/show/demo-live-show');
+
+ $response->assertStatus(200);
+ $response->assertViewIs('guest');
+ }
+}