import { useCallback, useEffect, useRef, useState } from 'react'; import { ApiError, request } from './client'; import type { components } from './generated/v1'; export type PlayoutSummary = components['schemas']['PlayoutListItemResponseModel']; export type PlayoutDetail = components['schemas']['PlayoutResponseModel']; export type PlayoutItem = components['schemas']['PlayoutItemResponseModel']; export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel']; export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel']; export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel']; export type PlayoutScheduleKind = components['schemas']['PlayoutScheduleKind']; export type CreatePlayoutRequest = components['schemas']['CreatePlayoutRequest']; export type UpdatePlayoutDetailsRequest = components['schemas']['UpdatePlayoutDetailsRequest']; export interface PlayoutsScreenData { channelStates: PlayoutChannelState[]; items: PlayoutItem[]; playout: PlayoutDetail | null; playouts: PlayoutSummary[]; selectedPlayoutId: number | null; totalCount: number; warningsCount: number; } type PlayoutsScreenBase = Omit; export type PlayoutsScreenQueryState = | { data: PlayoutsScreenData; error: null; itemsLoading: boolean; refresh: () => void; setActivePlayout: (playoutId: number) => void; setShowFiller: (showFiller: boolean) => void; showFiller: boolean; status: 'success'; } | { data: null; error: string; refresh: () => void; status: 'error' } | { data: null; error: null; refresh: () => void; status: 'loading' }; type PlayoutsScreenState = | { data: PlayoutsScreenData; error: null; itemsLoading: boolean; status: 'success' } | { data: null; error: string; status: 'error' } | { data: null; error: null; status: 'loading' }; export function getPlayouts(): Promise { return request('/api/playouts'); } export function getPlayout(playoutId: number): Promise { return request(`/api/playouts/${playoutId}`); } export function getPlayoutItems(playoutId: number, showFiller = false): Promise { return request(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`); } export function getPlayoutWarningsCount(): Promise { return request('/api/playouts/warnings/count'); } export function getPlayoutChannelStates(): Promise { return request('/api/channels/state'); } export function resetAllPlayouts(): Promise { return request('/api/playouts/reset-all', { method: 'POST' }); } export function createPlayout(body: CreatePlayoutRequest): Promise { return request('/api/playouts', { body, method: 'POST' }); } export function updatePlayoutDetails(playoutId: number, body: UpdatePlayoutDetailsRequest): Promise { return request(`/api/playouts/${playoutId}`, { body, method: 'PUT' }); } export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState { const [state, setState] = useState({ data: null, error: null, status: 'loading' }); const [showFiller, setShowFillerState] = useState(false); const activeRef = useRef(true); const selectedPlayoutIdRef = useRef(null); const showFillerRef = useRef(false); // Snapshot of the last-known base fields (everything but the selected playout's // detail/items), kept in sync whenever load()/loadChannelStates() succeed. Reading // this lets setActivePlayout/setShowFiller kick off their fetch without doing the // fetch from inside a setState updater (impure; StrictMode double-invokes updaters). const baseRef = useRef({ channelStates: [], playouts: [], selectedPlayoutId: null, totalCount: 0, warningsCount: 0 }); useEffect(() => { activeRef.current = true; return () => { activeRef.current = false; }; }, []); const loadSelectedPlayout = useCallback((playoutId: number, base: PlayoutsScreenBase) => { baseRef.current = base; Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId, showFillerRef.current)]) .then(([playout, itemsPage]) => { if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) { return; } setState((current) => { // Merge onto whatever channel state the background poll may have landed // while this request was in flight, rather than clobbering it with the // snapshot captured when the request started. const channelStates = current.status === 'success' ? current.data.channelStates : base.channelStates; return { data: { ...base, channelStates, items: itemsPage.page ?? [], playout, selectedPlayoutId: playoutId }, error: null, itemsLoading: false, status: 'success' }; }); }) .catch((error: unknown) => { if (activeRef.current && selectedPlayoutIdRef.current === playoutId) { setState({ data: null, error: messageFromPlayoutError(error), status: 'error' }); } }); }, []); const load = useCallback(() => { Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()]) .then(([playoutsPage, warningsCount, channelStates]) => { if (!activeRef.current) { return; } const playouts = playoutsPage.page ?? []; const selectedPlayoutId = selectedPlayoutIdRef.current && playouts.some((playout) => playout.id === selectedPlayoutIdRef.current) ? selectedPlayoutIdRef.current : playouts[0]?.id ?? null; selectedPlayoutIdRef.current = selectedPlayoutId; const base: PlayoutsScreenBase = { channelStates, playouts, selectedPlayoutId, totalCount: playoutsPage.totalCount, warningsCount }; baseRef.current = base; if (selectedPlayoutId == null) { setState({ data: { ...base, items: [], playout: null }, error: null, itemsLoading: false, status: 'success' }); return; } loadSelectedPlayout(selectedPlayoutId, base); }) .catch((error: unknown) => { if (activeRef.current) { setState({ data: null, error: messageFromPlayoutError(error), status: 'error' }); } }); }, [loadSelectedPlayout]); const loadChannelStates = useCallback(() => { getPlayoutChannelStates() .then((channelStates) => { if (!activeRef.current) { return; } baseRef.current = { ...baseRef.current, channelStates }; setState((current) => { if (current.status !== 'success') { return current; } return { data: { ...current.data, channelStates }, error: null, itemsLoading: current.itemsLoading, status: 'success' }; }); }) .catch(() => { // Keep the monitor visible on background polling failures. }); }, []); useEffect(() => { load(); const intervalId = window.setInterval(loadChannelStates, pollMs); return () => { window.clearInterval(intervalId); }; }, [load, loadChannelStates, pollMs]); const refresh = useCallback(() => { setState({ data: null, error: null, status: 'loading' }); load(); }, [load]); const setActivePlayout = useCallback((playoutId: number) => { selectedPlayoutIdRef.current = playoutId; setState((current) => { if (current.status !== 'success') { return current; } return { data: { ...current.data, items: [], playout: null, selectedPlayoutId: playoutId }, error: null, itemsLoading: true, status: 'success' }; }); const base: PlayoutsScreenBase = { ...baseRef.current, selectedPlayoutId: playoutId }; loadSelectedPlayout(playoutId, base); }, [loadSelectedPlayout]); const setShowFiller = useCallback((next: boolean) => { showFillerRef.current = next; setShowFillerState(next); const playoutId = selectedPlayoutIdRef.current; if (playoutId == null) { return; } setState((current) => { if (current.status !== 'success') { return current; } return { ...current, itemsLoading: true }; }); getPlayoutItems(playoutId, next) .then((itemsPage) => { if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) { return; } setState((current) => { if (current.status !== 'success') { return current; } return { data: { ...current.data, items: itemsPage.page ?? [] }, error: null, itemsLoading: false, status: 'success' }; }); }) .catch((error: unknown) => { if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) { return; } setState({ data: null, error: messageFromPlayoutError(error), status: 'error' }); }); }, []); if (state.status === 'success') { return { data: state.data, error: null, itemsLoading: state.itemsLoading, refresh, setActivePlayout, setShowFiller, showFiller, status: 'success' }; } if (state.status === 'error') { return { data: null, error: state.error, refresh, status: 'error' }; } return { data: null, error: null, refresh, status: 'loading' }; } function messageFromPlayoutError(error: unknown, fallback = 'Unable to load playouts'): string { if (error instanceof ApiError) { return error.detail ?? error.message; } if (error instanceof Error) { return error.message; } return fallback; }