From 53eb560aa597a11feea95b9bf1d965abd5f44e0d Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 5 Jan 2026 18:31:01 +0100 Subject: [PATCH] Add live show player playback and effects --- .beads/issues.jsonl | 6 +- .beads/last-touched | 2 +- .../js/guest/components/LiveShowBackdrop.tsx | 69 ++++ .../js/guest/components/LiveShowStage.tsx | 80 +++++ .../__tests__/useLiveShowPlayback.test.tsx | 84 +++++ .../js/guest/hooks/useLiveShowPlayback.ts | 229 +++++++++++++ resources/js/guest/hooks/useLiveShowState.ts | 317 ++++++++++++++++++ resources/js/guest/i18n/messages.ts | 48 +++ .../lib/__tests__/liveShowEffects.test.ts | 22 ++ resources/js/guest/lib/liveShowEffects.ts | 107 ++++++ .../js/guest/pages/LiveShowPlayerPage.tsx | 240 +++++++++++++ .../__tests__/LiveShowPlayerPage.test.tsx | 55 +++ resources/js/guest/router.tsx | 2 + .../services/__tests__/liveShowApi.test.ts | 34 ++ resources/js/guest/services/liveShowApi.ts | 302 +++++++++++++++++ routes/web.php | 1 + tests/Feature/GuestLiveShowRouteTest.php | 16 + 17 files changed, 1612 insertions(+), 2 deletions(-) create mode 100644 resources/js/guest/components/LiveShowBackdrop.tsx create mode 100644 resources/js/guest/components/LiveShowStage.tsx create mode 100644 resources/js/guest/hooks/__tests__/useLiveShowPlayback.test.tsx create mode 100644 resources/js/guest/hooks/useLiveShowPlayback.ts create mode 100644 resources/js/guest/hooks/useLiveShowState.ts create mode 100644 resources/js/guest/lib/__tests__/liveShowEffects.test.ts create mode 100644 resources/js/guest/lib/liveShowEffects.ts create mode 100644 resources/js/guest/pages/LiveShowPlayerPage.tsx create mode 100644 resources/js/guest/pages/__tests__/LiveShowPlayerPage.test.tsx create mode 100644 resources/js/guest/services/__tests__/liveShowApi.test.ts create mode 100644 resources/js/guest/services/liveShowApi.ts create mode 100644 tests/Feature/GuestLiveShowRouteTest.php 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} + ) : ( +
+ {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'); + } +}