diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 3ce0db680..0ff8d6fdb 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -257,7 +257,7 @@ describe('ChicoryTV SPA scaffold', () => { expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); expect(screen.getByText('5.1')).toBeInTheDocument(); expect(screen.queryByText('News 24')).not.toBeInTheDocument(); - expect(screen.getByText('Saturday Morning Cartoons')).toBeInTheDocument(); + expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0); expect(screen.getByText('1 on air')).toBeInTheDocument(); expect(screen.getAllByText('1 warning')).toHaveLength(2); expect(screen.getAllByText('2')).toHaveLength(2); @@ -327,7 +327,7 @@ describe('ChicoryTV SPA scaffold', () => { expect(await screen.findByRole('heading', { name: 'Channels' })).toBeInTheDocument(); expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument(); expect(screen.getByText('Retro Cartoons')).toBeInTheDocument(); - expect(screen.getByText('Saturday Morning Cartoons')).toBeInTheDocument(); + expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0); expect(screen.getByText('News 24')).toBeInTheDocument(); expect(screen.getByText('Off air')).toBeInTheDocument(); expect(screen.getByTitle('Disabled')).toHaveTextContent('D'); @@ -960,6 +960,224 @@ describe('ChicoryTV SPA scaffold', () => { expect(await screen.findByText('Item is in use by an active playout')).toBeInTheDocument(); }); + it('renders the Playouts monitor from live playout and channel state APIs', async () => { + mockDashboardApi({ + channelStates: [ + { + channelId: 1, + channelNumber: '5.1', + onAir: true, + nowPlaying: { + finishUtc: '2026-07-05T20:30:00Z', + startUtc: '2026-07-05T20:00:00Z', + title: 'Saturday Morning Cartoons' + } + } + ], + playoutDetails: playout({ id: 20, playoutMode: 'Continuous', scheduleFile: '/config/schedules/retro.json' }), + playoutItems: [ + playoutItem({ title: 'Saturday Morning Cartoons', start: '2026-07-05T20:00:00Z', finish: '2026-07-05T20:30:00Z' }), + playoutItem({ title: 'Station ID', start: '2026-07-05T20:30:00Z', finish: '2026-07-05T20:31:00Z', duration: '00:01:00', fillerKind: 'PreRoll' }), + playoutItem({ title: 'Moon Patrol', start: '2026-07-05T20:31:00Z', finish: '2026-07-05T21:00:00Z' }) + ], + playoutWarningsCount: 3, + playouts: { + page: [ + playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleName: 'Prime Time Cartoons' }), + playout({ id: 21, channelName: 'News 24', channelNumber: '24', scheduleName: 'News Rotation' }) + ], + totalCount: 2 + } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + expect(screen.getByText('2 playouts loaded')).toBeInTheDocument(); + expect(screen.getByText('3 warnings')).toBeInTheDocument(); + expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0); + expect(screen.getAllByText('Moon Patrol').length).toBeGreaterThan(0); + expect(screen.getAllByText('Station ID').length).toBeGreaterThan(0); + expect(screen.getByText('Filler')).toBeInTheDocument(); + expect(screen.getByText('Metadata preview only')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Continuous')).toBeInTheDocument(); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20/items', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/warnings/count', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object)); + }); + + it('fetches the Playouts screen data exactly once on mount and only polls channel state', async () => { + const intervalHandlers: Array<() => void> = []; + vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler) => { + if (typeof handler === 'function') { + intervalHandlers.push(handler as () => void); + } + + return intervalHandlers.length; + }); + vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined); + mockDashboardApi({ + channelStates: [ + { channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null } + ], + playoutItems: [playoutItem()], + playouts: { page: [playout({ id: 20 })], totalCount: 1 } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + + expect(fetchCount('/api/playouts')).toBe(2); + expect(fetchCount('/api/playouts/20')).toBe(1); + expect(fetchCount('/api/playouts/20/items')).toBe(1); + const stateFetchesBeforePoll = fetchCount('/api/channels/state'); + + intervalHandlers.forEach((handler) => handler()); + + await waitFor(() => { + expect(fetchCount('/api/channels/state')).toBeGreaterThan(stateFetchesBeforePoll); + }); + expect(fetchCount('/api/playouts')).toBe(2); + expect(fetchCount('/api/playouts/20/items')).toBe(1); + }); + + it('switches selected playouts and fetches only the selected playout detail and items', async () => { + mockDashboardApi({ + playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }), + playoutItems: [playoutItem({ title: 'Saturday Morning Cartoons' })], + playouts: { + page: [ + playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }), + playout({ id: 21, channelName: 'News 24', channelNumber: '24' }) + ], + totalCount: 2 + } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /News 24/ })); + + expect(await screen.findByRole('heading', { name: 'News 24' })).toBeInTheDocument(); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/21', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/21/items', expect.any(Object)); + expect(fetchCount('/api/playouts')).toBe(2); + }); + + it('shows the Playouts loading state', async () => { + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const path = input.toString(); + + if (path === '/api/playouts') { + return new Promise(() => {}); + } + + if (path === '/api/channels/state') { + return Promise.resolve(jsonResponse([])); + } + + if (path === '/api/playouts/warnings/count') { + return Promise.resolve(jsonResponse(0)); + } + + return Promise.resolve(jsonResponse([])); + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + + expect((await screen.findAllByText('Loading playouts')).length).toBeGreaterThan(0); + }); + + it('shows Playouts API errors and retries 404 parent handling', async () => { + mockDashboardApi({ + playoutItemsFailure: { + detail: 'Playout 20 was not found', + status: 404, + title: 'Not found' + }, + playoutItemsFailuresBeforeSuccess: 1, + playouts: { page: [playout({ id: 20 })], totalCount: 1 } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + + expect(await screen.findByText('Playout 20 was not found')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + }); + + it('shows an empty Playouts state when no playouts exist', async () => { + mockDashboardApi({ playouts: { page: [], totalCount: 0 } }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + + expect((await screen.findAllByText('No playouts returned')).length).toBeGreaterThan(0); + }); + + it('resets all playouts with confirmation and refetches playout-scoped data', async () => { + mockDashboardApi({ + confirm: true, + playoutItems: [playoutItem()], + playouts: { page: [playout({ id: 20 })], totalCount: 1 } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + + const playoutFetchesBeforeReset = fetchCount('/api/playouts'); + const itemFetchesBeforeReset = fetchCount('/api/playouts/20/items'); + + fireEvent.click(screen.getByRole('button', { name: 'Reset all playouts' })); + + await waitFor(() => { + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/reset-all', expect.objectContaining({ method: 'POST' })); + }); + expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforeReset + 1); + expect(fetchCount('/api/playouts/20/items')).toBe(itemFetchesBeforeReset + 1); + }); + + it('shows ProblemDetails when resetting all playouts fails', async () => { + mockDashboardApi({ + confirm: true, + mutationFailures: { + '/api/playouts/reset-all': { + detail: 'Playout rebuild is already running', + status: 409, + title: 'Conflict' + } + }, + playoutItems: [playoutItem()], + playouts: { page: [playout({ id: 20 })], totalCount: 1 } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Reset all playouts' })); + + expect(await screen.findByText('Playout rebuild is already running')).toBeInTheDocument(); + }); + it('shows the dashboard loading state while requests are pending', async () => { vi.spyOn(window, 'fetch').mockImplementation(() => new Promise(() => {})); @@ -1137,6 +1355,36 @@ function collection(overrides: Record = {}): Record = {}): Record { + return { + buildStatus: { + lastBuild: '2026-07-05T19:00:00Z', + message: null, + success: true + }, + channelName: 'Retro Cartoons', + channelNumber: '5.1', + dailyRebuildTime: '04:00:00', + id: 20, + playoutMode: 'Continuous', + scheduleFile: null, + scheduleKind: 'Classic', + scheduleName: 'Prime Time Cartoons', + ...overrides + }; +} + +function playoutItem(overrides: Record = {}): Record { + return { + duration: '00:30:00', + fillerKind: null, + finish: '2026-07-05T20:30:00Z', + start: '2026-07-05T20:00:00Z', + title: 'Saturday Morning Cartoons', + ...overrides + }; +} + function scheduleItem(overrides: Record = {}): Record { const collection = { id: 2, name: 'Saturday Cartoons', ...(overrides.collection as object | undefined) }; @@ -1250,6 +1498,11 @@ function mockDashboardApi({ mediaSources = [], multiCollections = [], mutationFailures = {}, + playoutDetails = null, + playoutItems = [], + playoutItemsFailure = null, + playoutItemsFailuresBeforeSuccess = 0, + playoutWarningsCount = 0, playouts = { page: [], totalCount: 0 }, prompt = null, replaceScheduleItemsResponse = null, @@ -1273,6 +1526,11 @@ function mockDashboardApi({ mediaSources?: unknown[]; multiCollections?: unknown[]; mutationFailures?: Record; + playoutDetails?: unknown; + playoutItems?: unknown[]; + playoutItemsFailure?: unknown; + playoutItemsFailuresBeforeSuccess?: number; + playoutWarningsCount?: number; playouts?: unknown; prompt?: string | null; replaceScheduleItemsResponse?: unknown; @@ -1290,6 +1548,7 @@ function mockDashboardApi({ vi.spyOn(window, 'prompt').mockReturnValue(prompt); let currentScheduleItems = scheduleItems; let remainingScheduleItemFailures = scheduleItemFailuresBeforeSuccess; + let remainingPlayoutItemsFailures = playoutItemsFailuresBeforeSuccess; vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { const path = input.toString(); @@ -1371,6 +1630,34 @@ function mockDashboardApi({ return Promise.resolve(jsonResponse(playouts)); } + if (path === '/api/playouts/warnings/count') { + return Promise.resolve(jsonResponse(playoutWarningsCount)); + } + + if (path === '/api/playouts/reset-all') { + if (path in mutationFailures) { + return Promise.resolve(jsonResponse(mutationFailures[path], 422)); + } + + return Promise.resolve(new Response(null, { status: 204 })); + } + + if (path.match(/^\/api\/playouts\/\d+\/items$/)) { + if (remainingPlayoutItemsFailures > 0) { + remainingPlayoutItemsFailures -= 1; + return Promise.resolve(jsonResponse(playoutItemsFailure, 404)); + } + + return Promise.resolve(jsonResponse({ + page: playoutItems, + totalCount: playoutItems.length + })); + } + + if (path.match(/^\/api\/playouts\/\d+$/)) { + return Promise.resolve(jsonResponse(playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) }))); + } + if (path === '/api/health') { return Promise.resolve(jsonResponse(health)); } diff --git a/web/src/App.tsx b/web/src/App.tsx index fa0a600e2..1bb4dd9ae 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,8 +16,10 @@ import { Check, ChevronDown, CircleHelp, + Clock, ClipboardCopy, Copy, + Film, Folder, FolderInput, FolderTree, @@ -36,6 +38,7 @@ import { Search, Settings, Shuffle, + Sparkles, Stethoscope, Timer, Trash2, @@ -70,7 +73,9 @@ import { messageFromError, addScheduleItem, deleteScheduleItem, + resetAllPlayouts, replaceScheduleItems, + usePlayoutsScreenQuery, useScheduleScreenQuery, useDashboardHealthQuery, useDashboardQuery, @@ -84,6 +89,8 @@ import { type MediaCollection, type ProgramSchedule, type ProgramScheduleItem, + type PlayoutItem, + type PlayoutSummary, type ScheduleItemRequest } from './api'; import { @@ -1813,6 +1820,326 @@ function formatScheduleCollectionType(value: string | undefined): string { return formatScheduleEnum(value ?? 'Collection'); } +function PlayoutsLoadingState() { + return ( + Loading playouts} subtitle="Fetching playouts, channel state, and upcoming items."> +
+ + Loading playouts +
+
+ ); +} + +function PlayoutsErrorState({ error, refresh }: { error: string; refresh: () => void }) { + return ( + Playouts unavailable} + subtitle="The API returned an error while loading the monitor." + actions={} + > +
+
+
+ ); +} + +function PlayoutsEmptyState() { + return ( + No playouts returned} subtitle="Create a channel playout before using the runtime monitor."> +
No playouts returned
+
+ ); +} + +function PlayoutsScreen() { + const query = usePlayoutsScreenQuery(); + const [filter, setFilter] = useState(''); + const [mutationError, setMutationError] = useState(null); + const [mutating, setMutating] = useState(false); + const mutatingRef = useRef(false); + + if (query.status === 'loading') { + return ; + } + + if (query.status === 'error') { + return ; + } + + const { channelStates, items, playout, playouts, selectedPlayoutId, totalCount, warningsCount } = query.data; + const selectedSummary = playouts.find((candidate) => candidate.id === selectedPlayoutId) ?? playouts[0] ?? null; + + if (!selectedSummary) { + return ; + } + + const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber); + const nowPlaying = selectedState?.nowPlaying ?? null; + const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null; + const nextItem = nextPlayoutItem(items, nowItem); + const filteredPlayouts = filterPlayouts(playouts, filter); + + const setMutatingState = (value: boolean) => { + mutatingRef.current = value; + setMutating(value); + }; + + const resetAll = () => { + if (mutatingRef.current || !window.confirm('Reset all playouts?')) { + return; + } + + setMutationError(null); + setMutatingState(true); + resetAllPlayouts() + .then(() => { + query.refresh(); + }) + .catch((error: unknown) => { + setMutationError(messageFromError(error)); + }) + .finally(() => { + setMutatingState(false); + }); + }; + + return ( +
+
+ + + 0 ? 'warn' : 'neutral'} dot={warningsCount > 0}>{warningsCount} warning{warningsCount === 1 ? '' : 's'} + +
+ + {mutationError && ( +
+
+ )} + +
+ + +
+
+ +
+ {selectedSummary.channelNumber} {selectedState?.onAir && On air} +

{selectedSummary.channelName}

+
+
+ +
+
+
+
+ On air now +

{nowPlaying?.title ?? nowItem?.title ?? 'No current item reported'}

+ +
+ {formatDateTime(nowPlaying?.startUtc ?? nowItem?.start)} + {formatDateTime(nowPlaying?.finishUtc ?? nowItem?.finish)} +
+
+
+ +
+ +
+
+
+ +
+ + + + +
+
+
+ + + + + + +
+ {items.length === 0 ? ( +
No upcoming items
+ ) : ( + items.map((item, index) => ( +
+ {formatDateTime(item.start)} + {item.fillerKind ?
+ )) + )} +
+
+
+
+
+ ); +} + +function PlayoutTimeline({ items, nowItem }: { items: PlayoutItem[]; nowItem: PlayoutItem | null }) { + if (items.length === 0) { + return
No timeline items
; + } + + const firstStart = Date.parse(items[0].start); + const lastFinish = Date.parse(items[items.length - 1].finish); + const span = Math.max(lastFinish - firstStart, 1); + + return ( +
+
+ {items.map((item, index) => { + const width = Math.max(((Date.parse(item.finish) - Date.parse(item.start)) / span) * 100, 2); + + return ( + + {width > 12 && (item.title ?? 'Untitled item')} + + ); + })} + {nowItem && } +
+

+ {formatDateTime(items[0].start)} + + {formatDateTime(items[items.length - 1].finish)} +

+
+ ); +} + +function filterPlayouts(playouts: PlayoutSummary[], filter: string): PlayoutSummary[] { + const normalized = filter.trim().toLowerCase(); + + if (!normalized) { + return playouts; + } + + return playouts.filter((playout) => + playout.channelName.toLowerCase().includes(normalized) || + playout.channelNumber.toLowerCase().includes(normalized) || + playout.scheduleName.toLowerCase().includes(normalized) + ); +} + +function itemMatchingNow(items: PlayoutItem[], title: string | null | undefined): PlayoutItem | null { + if (!title) { + return null; + } + + return items.find((item) => item.title === title) ?? null; +} + +function nextPlayoutItem(items: PlayoutItem[], nowItem: PlayoutItem | null): PlayoutItem | null { + const index = nowItem ? items.indexOf(nowItem) : -1; + return items[index + 1] ?? items[1] ?? null; +} + +function playoutProgress(start: string | null | undefined, finish: string | null | undefined): number { + if (!start || !finish) { + return 0; + } + + const startMs = Date.parse(start); + const finishMs = Date.parse(finish); + const nowMs = Date.now(); + + if (!Number.isFinite(startMs) || !Number.isFinite(finishMs) || finishMs <= startMs) { + return 0; + } + + return Math.min(100, Math.max(0, ((nowMs - startMs) / (finishMs - startMs)) * 100)); +} + +function timelinePosition(start: string, firstStart: number, span: number): number { + const startMs = Date.parse(start); + + if (!Number.isFinite(startMs)) { + return 0; + } + + return Math.min(100, Math.max(0, ((startMs - firstStart) / span) * 100)); +} + +function formatDateTime(value: string | null | undefined): string { + if (!value) { + return 'unknown'; + } + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return value.slice(0, 5); + } + + return parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function formatDailyRebuild(value: string | null | undefined): string { + return value ? value.slice(0, 5) : 'manual'; +} + function pickerOptions(items: MediaCollection[]): Array<{ label: string; value: string }> { return items.map((item) => ({ label: item.name ?? `Collection ${item.id}`, value: `${item.id}` })); } @@ -1893,6 +2220,10 @@ function ScreenContent({ return ; } + if (route.id === 'playouts') { + return ; + } + return ; } diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 60e0b0df0..f96fc41cf 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -2,5 +2,6 @@ export * from './auth'; export * from './channels'; export * from './client'; export * from './dashboard'; +export * from './playouts'; export * from './schedules'; export * from './useChannelsQuery'; diff --git a/web/src/api/playouts.ts b/web/src/api/playouts.ts new file mode 100644 index 000000000..0fe644070 --- /dev/null +++ b/web/src/api/playouts.ts @@ -0,0 +1,285 @@ +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 interface PlayoutsScreenData { + channelStates: PlayoutChannelState[]; + items: PlayoutItem[]; + playout: PlayoutDetail | null; + playouts: PlayoutSummary[]; + selectedPlayoutId: number | null; + totalCount: number; + warningsCount: number; +} + +export type PlayoutsScreenQueryState = + | { + data: PlayoutsScreenData; + error: null; + refresh: () => void; + setActivePlayout: (playoutId: number) => void; + status: 'success'; + } + | { data: null; error: string; refresh: () => void; status: 'error' } + | { data: null; error: null; refresh: () => void; status: 'loading' }; + +type PlayoutsScreenState = + | { data: PlayoutsScreenData; error: null; 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): Promise { + return request(`/api/playouts/${playoutId}/items`); +} + +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 usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState { + const [state, setState] = useState({ + data: null, + error: null, + status: 'loading' + }); + const activeRef = useRef(true); + const selectedPlayoutIdRef = useRef(null); + + useEffect(() => { + activeRef.current = true; + + return () => { + activeRef.current = false; + }; + }, []); + + const loadSelectedPlayout = useCallback((playoutId: number, base: Omit) => { + Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId)]) + .then(([playout, itemsPage]) => { + if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) { + return; + } + + setState({ + data: { + ...base, + items: itemsPage.page ?? [], + playout, + selectedPlayoutId: playoutId + }, + error: null, + status: 'success' + }); + }) + .catch((error: unknown) => { + if (activeRef.current && selectedPlayoutIdRef.current === playoutId) { + setState({ data: null, error: messageFromPlayoutError(error), status: 'error' }); + } + }); + }, []); + + const load = useCallback((showLoading = true) => { + if (showLoading) { + setState({ data: null, error: null, status: 'loading' }); + } + + 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 = { + channelStates, + playouts, + selectedPlayoutId, + totalCount: playoutsPage.totalCount, + warningsCount + }; + + if (selectedPlayoutId == null) { + setState({ + data: { + ...base, + items: [], + playout: null + }, + error: null, + 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; + } + + setState((current) => { + if (current.status !== 'success') { + return current; + } + + return { + data: { ...current.data, channelStates }, + error: null, + status: 'success' + }; + }); + }) + .catch(() => { + // Keep the monitor visible on background polling failures. + }); + }, []); + + useEffect(() => { + Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()]) + .then(([playoutsPage, warningsCount, channelStates]) => { + if (!activeRef.current) { + return; + } + + const playouts = playoutsPage.page ?? []; + const selectedPlayoutId = playouts[0]?.id ?? null; + selectedPlayoutIdRef.current = selectedPlayoutId; + + const base = { + channelStates, + playouts, + selectedPlayoutId, + totalCount: playoutsPage.totalCount, + warningsCount + }; + + if (selectedPlayoutId == null) { + setState({ + data: { + ...base, + items: [], + playout: null + }, + error: null, + status: 'success' + }); + return; + } + + loadSelectedPlayout(selectedPlayoutId, base); + }) + .catch((error: unknown) => { + if (activeRef.current) { + setState({ data: null, error: messageFromPlayoutError(error), status: 'error' }); + } + }); + + const intervalId = window.setInterval(loadChannelStates, pollMs); + + return () => { + window.clearInterval(intervalId); + }; + }, [loadChannelStates, loadSelectedPlayout, pollMs]); + + const refresh = useCallback(() => { + 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, + status: 'success' + }; + }); + + setState((current) => { + if (current.status !== 'success') { + return current; + } + + const base = { + channelStates: current.data.channelStates, + playouts: current.data.playouts, + selectedPlayoutId: playoutId, + totalCount: current.data.totalCount, + warningsCount: current.data.warningsCount + }; + + loadSelectedPlayout(playoutId, base); + return current; + }); + }, [loadSelectedPlayout]); + + if (state.status === 'success') { + return { data: state.data, error: null, refresh, setActivePlayout, 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; +} diff --git a/web/src/shell.css b/web/src/shell.css index 0f3d98be8..c8c3a8b6f 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -1308,6 +1308,348 @@ line-height: 1.4; } +.ctv-playouts-screen { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + gap: var(--space-6, 12px); +} + +.ctv-playouts-header { + display: flex; + align-items: center; + gap: var(--space-5, 10px); + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md, 7px); + background: var(--surface-card); + padding: var(--space-6, 12px) var(--space-8, 20px); +} + +.ctv-playouts-header-spacer { + flex: 1; +} + +.ctv-playouts-grid { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: var(--space-8, 20px); + flex: 1; + min-height: 0; +} + +.ctv-playouts-rail, +.ctv-playouts-monitor { + min-height: 0; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md, 7px); + background: var(--surface-card); +} + +.ctv-playouts-rail { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ctv-playouts-filter { + display: grid; + gap: var(--space-4, 8px); + border-bottom: 1px solid var(--border-hairline); + padding: var(--space-6, 12px); +} + +.ctv-playouts-filter > span { + color: var(--text-disabled); + font-size: var(--text-2xs, 11px); +} + +.ctv-playouts-list { + display: grid; + align-content: start; + gap: var(--space-2, 4px); + overflow: auto; + padding: var(--space-4, 8px); +} + +.ctv-playout-option { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) 10px; + align-items: center; + gap: var(--space-5, 10px); + width: 100%; + border: 0; + border-radius: var(--radius-sm, 5px); + background: transparent; + color: inherit; + cursor: pointer; + padding: var(--space-4, 8px); + text-align: left; +} + +.ctv-playout-option:hover, +.ctv-playout-option-active { + background: var(--ctv-accent-soft); +} + +.ctv-playout-option > span { + display: grid; + min-width: 0; + gap: var(--space-2, 4px); +} + +.ctv-playout-option code, +.ctv-playouts-title code, +.ctv-playout-now-copy code, +.ctv-playout-upcoming code, +.ctv-playout-upcoming small, +.ctv-playout-timeline code { + font-family: var(--font-mono, ui-monospace, monospace); + font-variant-numeric: tabular-nums; +} + +.ctv-playout-option code { + color: var(--status-live); + font-size: var(--text-2xs, 11px); +} + +.ctv-playout-option strong, +.ctv-playout-next strong { + overflow: hidden; + color: var(--text-primary); + font-size: var(--text-sm, 13px); + font-weight: var(--weight-semibold, 600); + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-playout-option small, +.ctv-playout-next small { + overflow: hidden; + color: var(--text-secondary); + font-size: var(--text-2xs, 11px); + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-playouts-monitor { + display: flex; + flex-direction: column; + gap: var(--space-7, 16px); + overflow: auto; + padding: var(--space-8, 20px); +} + +.ctv-playouts-title { + display: flex; + align-items: center; + gap: var(--space-6, 12px); +} + +.ctv-playouts-title > div { + display: grid; + min-width: 0; + gap: var(--space-2, 4px); +} + +.ctv-playouts-title span { + display: flex; + align-items: center; + gap: var(--space-4, 8px); + color: var(--text-secondary); + font-size: var(--text-xs, 12px); +} + +.ctv-playouts-title h2, +.ctv-playout-now-copy h3 { + margin: 0; + color: var(--text-primary); + font-weight: var(--weight-semibold, 600); + line-height: 1.2; +} + +.ctv-playouts-title h2 { + font-size: var(--text-md, 14px); +} + +.ctv-playout-now { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + overflow: hidden; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md, 7px); + background: var(--ctv-surface-1); +} + +.ctv-playout-preview { + display: flex; + position: relative; + align-items: center; + justify-content: center; + min-height: 150px; + border-right: 1px solid var(--border-hairline); + background: var(--ctv-bg-sunken); + color: var(--ctv-accent); +} + +.ctv-playout-preview .ctv-badge { + position: absolute; + left: var(--space-5, 10px); + bottom: var(--space-5, 10px); +} + +.ctv-playout-now-copy { + display: flex; + justify-content: center; + flex-direction: column; + gap: var(--space-5, 10px); + min-width: 0; + padding: var(--space-8, 20px); +} + +.ctv-playout-now-copy > span { + color: var(--status-live); + font-size: var(--text-2xs, 11px); + font-weight: var(--weight-semibold, 600); + text-transform: uppercase; +} + +.ctv-playout-now-copy h3 { + font-size: var(--text-lg, 18px); +} + +.ctv-playout-now-copy > div:last-child { + display: flex; + justify-content: space-between; + gap: var(--space-6, 12px); + color: var(--text-disabled); + font-size: var(--text-2xs, 11px); +} + +.ctv-playouts-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-7, 16px); +} + +.ctv-playout-next { + display: flex; + align-items: center; + gap: var(--space-6, 12px); +} + +.ctv-playout-next > svg { + color: var(--ctv-accent); + flex: 0 0 auto; +} + +.ctv-playout-next > span { + display: grid; + min-width: 0; + gap: var(--space-2, 4px); +} + +.ctv-playout-detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-5, 10px); +} + +.ctv-playout-timeline { + display: grid; + gap: var(--space-4, 8px); +} + +.ctv-playout-timeline > div { + display: flex; + position: relative; + overflow: hidden; + height: 34px; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-sm, 5px); + background: var(--ctv-bg-sunken); +} + +.ctv-playout-timeline span { + display: flex; + align-items: center; + overflow: hidden; + border-right: 1px solid var(--ctv-bg); + background: var(--ctv-accent-soft); + color: var(--ctv-accent); + font-size: var(--text-2xs, 11px); + font-weight: var(--weight-medium, 500); + padding: 0 var(--space-4, 8px); + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-playout-timeline .ctv-playout-timeline-filler { + background: var(--ctv-surface-3); + color: var(--text-secondary); +} + +.ctv-playout-timeline i { + position: absolute; + top: -3px; + bottom: -3px; + width: 2px; + background: var(--status-live); + box-shadow: 0 0 8px var(--status-live); +} + +.ctv-playout-timeline p { + display: flex; + justify-content: space-between; + margin: 0; + color: var(--text-disabled); + font-size: var(--text-2xs, 11px); +} + +.ctv-playout-timeline p span { + display: inline-flex; + align-items: center; + gap: var(--space-2, 4px); + color: var(--status-live); +} + +.ctv-playout-upcoming > div { + display: grid; + grid-template-columns: 58px 14px minmax(0, 1fr) auto 64px; + align-items: center; + gap: var(--space-5, 10px); + border-top: 1px solid var(--border-hairline); + padding: var(--space-5, 10px) var(--space-7, 16px); +} + +.ctv-playout-upcoming > div:first-child { + border-top: 0; +} + +.ctv-playout-upcoming-now { + background: var(--ctv-live-soft); +} + +.ctv-playout-upcoming svg { + color: var(--ctv-accent); +} + +.ctv-playout-upcoming span { + overflow: hidden; + color: var(--text-primary); + font-size: var(--text-sm, 13px); + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-playout-upcoming code, +.ctv-playout-upcoming small { + color: var(--text-disabled); + font-size: var(--text-2xs, 11px); +} + @media (max-width: 980px) { .ctv-app-shell { grid-template-columns: 1fr;