318 lines
8.8 KiB
TypeScript
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]
|
|
);
|
|
}
|