Files
fotospiel-app/resources/js/guest/hooks/useLiveShowState.ts
Codex Agent 53eb560aa5
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add live show player playback and effects
2026-01-05 18:31:01 +01:00

318 lines
8.8 KiB
TypeScript

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<number, LiveShowPhoto>();
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<T>(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<LiveShowStatus>('loading');
const [connection, setConnection] = useState<LiveShowConnection>('idle');
const [error, setError] = useState<string | null>(null);
const [event, setEvent] = useState<LiveShowEvent | null>(null);
const [settings, setSettings] = useState<LiveShowSettings>(DEFAULT_LIVE_SHOW_SETTINGS);
const [settingsVersion, setSettingsVersion] = useState('');
const [photos, setPhotos] = useState<LiveShowPhoto[]>([]);
const [cursor, setCursor] = useState<LiveShowCursor | null>(null);
const [visible, setVisible] = useState(
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
);
const cursorRef = useRef<LiveShowCursor | null>(null);
const settingsVersionRef = useRef<string>('');
const eventSourceRef = useRef<EventSource | null>(null);
const pollingTimerRef = useRef<number | null>(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<string>).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<string>).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]
);
}