Files
fotospiel-app/resources/js/guest/services/liveShowApi.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

303 lines
8.8 KiB
TypeScript

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<string, unknown>;
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<LiveShowSettings> | 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<string, unknown>): 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<string, unknown>): 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<string, unknown> | 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<string, unknown> | 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<T>(response: Response): Promise<T> {
if (response.status === 204) {
return {} as T;
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const payload = data as Record<string, unknown> | 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<LiveShowState> {
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<Record<string, unknown>>(response);
const rawEvent = (data.event as Record<string, unknown>) ?? {};
return {
event: normalizeLiveShowEvent(rawEvent),
settings: normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings> | null),
settings_version: String(data.settings_version ?? ''),
photos: Array.isArray(data.photos)
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
: [],
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
};
}
export async function fetchLiveShowUpdates(
token: string,
options: {
cursor?: LiveShowCursor | null;
settingsVersion?: string;
limit?: number;
} = {}
): Promise<LiveShowUpdates> {
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<Record<string, unknown>>(response);
return {
settings: data.settings ? normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings>) : null,
settings_version: String(data.settings_version ?? ''),
photos: Array.isArray(data.photos)
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
: [],
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
};
}