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] ); }