This commit was merged in pull request #127.
This commit is contained in:
+402
-2
@@ -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,313 @@ 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' }),
|
||||
playoutItem({ title: 'Moon Patrol', start: '2026-07-05T20:31:00Z', finish: '2026-07-05T21:00:00Z' })
|
||||
],
|
||||
playoutWarningsCount: 3,
|
||||
playouts: {
|
||||
page: [
|
||||
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleName: 'Prime Time Cartoons' }),
|
||||
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24', scheduleName: 'News Rotation' })
|
||||
],
|
||||
totalCount: 2
|
||||
}
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
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);
|
||||
// Real items carry fillerKind 'None' and must not be badged as filler.
|
||||
expect(screen.queryByText('Filler')).not.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('toggles Show filler to refetch playout items with showFiller=true and badge filler rows', async () => {
|
||||
mockDashboardApi({
|
||||
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' })
|
||||
],
|
||||
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
// Toggle off: the filler item is not requested, so no Filler badge renders.
|
||||
expect(screen.queryByText('Station ID')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Filler')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Show filler' }));
|
||||
|
||||
expect((await screen.findAllByText('Station ID')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Filler')).toBeInTheDocument();
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20/items?showFiller=true', 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: [listPlayout({ id: 20 })], totalCount: 1 }
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
// The Dashboard also fetches /api/playouts on load; measure the Playouts
|
||||
// screen's own contribution as a delta instead of absorbing that prefetch.
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/playouts')).toBeGreaterThan(0);
|
||||
});
|
||||
const playoutFetchesBeforeNavigation = fetchCount('/api/playouts');
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforeNavigation + 1);
|
||||
expect(fetchCount('/api/playouts/20')).toBe(1);
|
||||
expect(fetchCount('/api/playouts/20/items')).toBe(1);
|
||||
const playoutFetchesBeforePoll = fetchCount('/api/playouts');
|
||||
const stateFetchesBeforePoll = fetchCount('/api/channels/state');
|
||||
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/channels/state')).toBeGreaterThan(stateFetchesBeforePoll);
|
||||
});
|
||||
expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforePoll);
|
||||
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: [
|
||||
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
|
||||
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24' })
|
||||
],
|
||||
totalCount: 2
|
||||
}
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
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<Response>(() => {});
|
||||
}
|
||||
|
||||
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(<App />);
|
||||
|
||||
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: [listPlayout({ id: 20 })], totalCount: 1 }
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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: [listPlayout({ id: 20 })], totalCount: 1 }
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
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 the reset-all transport fails with a 5xx', async () => {
|
||||
// Backend contract: POST /api/playouts/reset-all is an unconditional 202 — there is no
|
||||
// application-level error path today. This test guards client robustness only, against
|
||||
// transport/server failures (proxy errors, unhandled exceptions).
|
||||
mockDashboardApi({
|
||||
confirm: true,
|
||||
mutationFailures: {
|
||||
'/api/playouts/reset-all': {
|
||||
detail: 'The playout worker crashed while queueing rebuilds',
|
||||
status: 500,
|
||||
title: 'Internal Server Error'
|
||||
}
|
||||
},
|
||||
playoutItems: [playoutItem()],
|
||||
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
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('The playout worker crashed while queueing rebuilds')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters the playout selector rail by channel name', async () => {
|
||||
mockDashboardApi({
|
||||
playoutItems: [playoutItem()],
|
||||
playouts: {
|
||||
page: [
|
||||
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
|
||||
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24', scheduleName: 'News Rotation' })
|
||||
],
|
||||
totalCount: 2
|
||||
}
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
const rail = screen.getByRole('complementary', { name: 'Playout selector' });
|
||||
expect(within(rail).getByRole('button', { name: /Retro Cartoons/ })).toBeInTheDocument();
|
||||
expect(within(rail).getByRole('button', { name: /News 24/ })).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Filter playouts'), { target: { value: 'News' } });
|
||||
|
||||
expect(within(rail).queryByRole('button', { name: /Retro Cartoons/ })).not.toBeInTheDocument();
|
||||
expect(within(rail).getByRole('button', { name: /News 24/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('1 of 2 playouts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders honestly when totalCount exceeds the returned page length', async () => {
|
||||
mockDashboardApi({
|
||||
playoutItems: [playoutItem()],
|
||||
playoutItemsTotalCount: 500,
|
||||
playouts: {
|
||||
page: [
|
||||
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
|
||||
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24' })
|
||||
],
|
||||
totalCount: 150
|
||||
}
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
// Only 2 of 150 rows were returned; the count label must not claim all 150 loaded.
|
||||
expect(screen.getByText('2 of 150 playouts')).toBeInTheDocument();
|
||||
// Items page is also partial (1 of 500); the monitor must still render without crashing.
|
||||
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows the dashboard loading state while requests are pending', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation(() => new Promise<Response>(() => {}));
|
||||
|
||||
@@ -1137,6 +1444,45 @@ function collection(overrides: Record<string, unknown> = {}): Record<string, unk
|
||||
};
|
||||
}
|
||||
|
||||
// PlayoutListItemResponseModel: rail rows have no detail-only fields (playoutMode/scheduleFile).
|
||||
function listPlayout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
buildStatus: {
|
||||
lastBuild: '2026-07-05T19:00:00Z',
|
||||
message: null,
|
||||
success: true
|
||||
},
|
||||
channelName: 'Retro Cartoons',
|
||||
channelNumber: '5.1',
|
||||
dailyRebuildTime: '04:00:00',
|
||||
id: 20,
|
||||
scheduleKind: 'Classic',
|
||||
scheduleName: 'Prime Time Cartoons',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// PlayoutResponseModel: the detail shape adds playoutMode/scheduleFile on top of the list row.
|
||||
function playout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return listPlayout({
|
||||
playoutMode: 'Continuous',
|
||||
scheduleFile: null,
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
// The backend always emits fillerKind: real content items carry 'None' (never null).
|
||||
function playoutItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
duration: '00:30:00',
|
||||
fillerKind: 'None',
|
||||
finish: '2026-07-05T20:30:00Z',
|
||||
start: '2026-07-05T20:00:00Z',
|
||||
title: 'Saturday Morning Cartoons',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const collection = { id: 2, name: 'Saturday Cartoons', ...(overrides.collection as object | undefined) };
|
||||
|
||||
@@ -1250,6 +1596,12 @@ function mockDashboardApi({
|
||||
mediaSources = [],
|
||||
multiCollections = [],
|
||||
mutationFailures = {},
|
||||
playoutDetails = null,
|
||||
playoutItems = [],
|
||||
playoutItemsFailure = null,
|
||||
playoutItemsFailuresBeforeSuccess = 0,
|
||||
playoutItemsTotalCount = null,
|
||||
playoutWarningsCount = 0,
|
||||
playouts = { page: [], totalCount: 0 },
|
||||
prompt = null,
|
||||
replaceScheduleItemsResponse = null,
|
||||
@@ -1273,6 +1625,12 @@ function mockDashboardApi({
|
||||
mediaSources?: unknown[];
|
||||
multiCollections?: unknown[];
|
||||
mutationFailures?: Record<string, unknown>;
|
||||
playoutDetails?: unknown;
|
||||
playoutItems?: unknown[];
|
||||
playoutItemsFailure?: unknown;
|
||||
playoutItemsFailuresBeforeSuccess?: number;
|
||||
playoutItemsTotalCount?: number | null;
|
||||
playoutWarningsCount?: number;
|
||||
playouts?: unknown;
|
||||
prompt?: string | null;
|
||||
replaceScheduleItemsResponse?: unknown;
|
||||
@@ -1290,6 +1648,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 +1730,47 @@ 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) {
|
||||
const failure = mutationFailures[path] as { status?: number };
|
||||
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
|
||||
}
|
||||
|
||||
// Backend contract: POST /api/playouts/reset-all is an unconditional 202 Accepted.
|
||||
return Promise.resolve(new Response(null, { status: 202 }));
|
||||
}
|
||||
|
||||
const itemsMatch = path.match(/^\/api\/playouts\/\d+\/items(\?(?<query>.*))?$/);
|
||||
|
||||
if (itemsMatch) {
|
||||
if (remainingPlayoutItemsFailures > 0) {
|
||||
remainingPlayoutItemsFailures -= 1;
|
||||
return Promise.resolve(jsonResponse(playoutItemsFailure, 404));
|
||||
}
|
||||
|
||||
// Mirror the backend: filler items are only included when ?showFiller=true is sent.
|
||||
const showFiller = new URLSearchParams(itemsMatch.groups?.query ?? '').get('showFiller') === 'true';
|
||||
const page = showFiller
|
||||
? playoutItems
|
||||
: playoutItems.filter((item) => {
|
||||
const fillerKind = (item as { fillerKind?: null | string }).fillerKind;
|
||||
return fillerKind == null || fillerKind === 'None';
|
||||
});
|
||||
|
||||
return Promise.resolve(jsonResponse({
|
||||
page,
|
||||
totalCount: playoutItemsTotalCount ?? page.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));
|
||||
}
|
||||
|
||||
+385
@@ -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,
|
||||
@@ -58,6 +61,7 @@ import {
|
||||
Spinner,
|
||||
Stat,
|
||||
StatusDot,
|
||||
Switch,
|
||||
Tabs
|
||||
} from './components';
|
||||
import {
|
||||
@@ -70,7 +74,9 @@ import {
|
||||
messageFromError,
|
||||
addScheduleItem,
|
||||
deleteScheduleItem,
|
||||
resetAllPlayouts,
|
||||
replaceScheduleItems,
|
||||
usePlayoutsScreenQuery,
|
||||
useScheduleScreenQuery,
|
||||
useDashboardHealthQuery,
|
||||
useDashboardQuery,
|
||||
@@ -84,6 +90,8 @@ import {
|
||||
type MediaCollection,
|
||||
type ProgramSchedule,
|
||||
type ProgramScheduleItem,
|
||||
type PlayoutItem,
|
||||
type PlayoutSummary,
|
||||
type ScheduleItemRequest
|
||||
} from './api';
|
||||
import {
|
||||
@@ -1813,6 +1821,379 @@ function formatScheduleCollectionType(value: string | undefined): string {
|
||||
return formatScheduleEnum(value ?? 'Collection');
|
||||
}
|
||||
|
||||
function PlayoutsLoadingState() {
|
||||
return (
|
||||
<Card title={<h2>Loading playouts</h2>} subtitle="Fetching playouts, channel state, and upcoming items.">
|
||||
<div className="ctv-dashboard-state">
|
||||
<Spinner tone="muted" />
|
||||
<span>Loading playouts</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutsErrorState({ error, refresh }: { error: string; refresh: () => void }) {
|
||||
return (
|
||||
<Card
|
||||
title={<h2>Playouts unavailable</h2>}
|
||||
subtitle="The API returned an error while loading the monitor."
|
||||
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Retry</Button>}
|
||||
>
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutsEmptyState() {
|
||||
return (
|
||||
<Card title={<h2>No playouts returned</h2>} subtitle="Create a channel playout before using the runtime monitor.">
|
||||
<div className="ctv-schedule-empty">No playouts returned</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutsScreen() {
|
||||
const query = usePlayoutsScreenQuery();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const mutatingRef = useRef(false);
|
||||
|
||||
if (query.status === 'loading') {
|
||||
return <PlayoutsLoadingState />;
|
||||
}
|
||||
|
||||
if (query.status === 'error') {
|
||||
return <PlayoutsErrorState error={query.error} refresh={query.refresh} />;
|
||||
}
|
||||
|
||||
const { channelStates, items, playout, playouts, selectedPlayoutId, totalCount, warningsCount } = query.data;
|
||||
const { itemsLoading, setShowFiller, showFiller } = query;
|
||||
const selectedSummary = playouts.find((candidate) => candidate.id === selectedPlayoutId) ?? playouts[0] ?? null;
|
||||
|
||||
if (!selectedSummary) {
|
||||
return <PlayoutsEmptyState />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="ctv-playouts-screen">
|
||||
<section className="ctv-playouts-header">
|
||||
<Button disabled={mutating} onClick={resetAll} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Reset all playouts</Button>
|
||||
<span className="ctv-playouts-header-spacer" />
|
||||
<Badge tone={warningsCount > 0 ? 'warn' : 'neutral'} dot={warningsCount > 0}>{warningsCount} warning{warningsCount === 1 ? '' : 's'}</Badge>
|
||||
<Button disabled startIcon={<Plus aria-hidden="true" size={15} />} variant="primary">Add Playout</Button>
|
||||
</section>
|
||||
|
||||
{mutationError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{mutationError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ctv-playouts-grid">
|
||||
<aside className="ctv-playouts-rail" aria-label="Playout selector">
|
||||
<div className="ctv-playouts-filter">
|
||||
<Input
|
||||
label="Filter playouts"
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="Filter playouts..."
|
||||
size="sm"
|
||||
value={filter}
|
||||
/>
|
||||
<span>{playoutsCountLabel(filteredPlayouts.length, totalCount)}</span>
|
||||
</div>
|
||||
<div className="ctv-playouts-list">
|
||||
{filteredPlayouts.map((candidate) => {
|
||||
const state = channelStates.find((entry) => entry.channelNumber === candidate.channelNumber);
|
||||
const active = candidate.id === selectedSummary.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? 'true' : undefined}
|
||||
className={`ctv-playout-option${active ? ' ctv-playout-option-active' : ''}`}
|
||||
disabled={mutating}
|
||||
key={candidate.id}
|
||||
onClick={() => query.setActivePlayout(candidate.id)}
|
||||
type="button"
|
||||
>
|
||||
<ChannelLogo name={candidate.channelName} size={30} />
|
||||
<span>
|
||||
<code>{candidate.channelNumber}</code>
|
||||
<strong>{candidate.channelName}</strong>
|
||||
<small>{state?.nowPlaying?.title ?? candidate.scheduleName}</small>
|
||||
</span>
|
||||
{state?.onAir && <StatusDot status="live" size={7} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="ctv-playouts-monitor" aria-label="Selected playout monitor">
|
||||
<div className="ctv-playouts-title">
|
||||
<ChannelLogo name={selectedSummary.channelName} size={38} />
|
||||
<div>
|
||||
<span><code>{selectedSummary.channelNumber}</code> {selectedState?.onAir && <Badge tone="accent" dot>On air</Badge>}</span>
|
||||
<h2>{selectedSummary.channelName}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ctv-playout-now">
|
||||
<div className="ctv-playout-preview" aria-label="Live preview disabled">
|
||||
<Play aria-hidden="true" size={24} />
|
||||
<Badge tone="neutral">Metadata preview only</Badge>
|
||||
</div>
|
||||
<div className="ctv-playout-now-copy">
|
||||
<span>On air now</span>
|
||||
<h3>{nowPlaying?.title ?? nowItem?.title ?? 'No current item reported'}</h3>
|
||||
<ProgressBar value={playoutProgress(nowPlaying?.startUtc ?? nowItem?.start, nowPlaying?.finishUtc ?? nowItem?.finish)} />
|
||||
<div>
|
||||
<code>{formatDateTime(nowPlaying?.startUtc ?? nowItem?.start)}</code>
|
||||
<code>{formatDateTime(nowPlaying?.finishUtc ?? nowItem?.finish)}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ctv-playouts-cards">
|
||||
<Card title="Up next">
|
||||
<div className="ctv-playout-next">
|
||||
<Film aria-hidden="true" size={18} />
|
||||
<span>
|
||||
<strong>{nextItem?.title ?? 'No upcoming item'}</strong>
|
||||
<small>{nextItem ? `${formatDateTime(nextItem.start)} · ${nextItem.duration ?? 'duration unknown'}` : 'Upcoming list is empty'}</small>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Playout">
|
||||
<div className="ctv-playout-detail-grid">
|
||||
<Input disabled label="Mode" value={playout?.playoutMode ?? 'Unknown'} />
|
||||
<Input disabled label="Schedule" value={playout?.scheduleName ?? selectedSummary.scheduleName} />
|
||||
<Input disabled label="Kind" value={formatScheduleEnum(playout?.scheduleKind ?? selectedSummary.scheduleKind)} />
|
||||
<Input disabled label="Rebuild" value={formatDailyRebuild(playout?.dailyRebuildTime ?? selectedSummary.dailyRebuildTime)} />
|
||||
</div>
|
||||
<div className="ctv-playout-detail-actions" title="No per-playout reset endpoint exists yet">
|
||||
<Button disabled size="sm" startIcon={<RefreshCw aria-hidden="true" size={13} />} variant="secondary">Reset</Button>
|
||||
<Button disabled size="sm" startIcon={<Clock aria-hidden="true" size={13} />} variant="ghost">Schedule reset</Button>
|
||||
<small>Per-playout reset is deferred — the API has no per-playout reset endpoint yet.</small>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Timeline" subtitle="Selected playout items" actions={itemsLoading ? <Spinner size={13} tone="muted" /> : undefined}>
|
||||
<PlayoutTimeline items={items} itemsLoading={itemsLoading} nowItem={nowItem} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Upcoming"
|
||||
subtitle="Next items"
|
||||
padded={false}
|
||||
actions={
|
||||
<span className="ctv-playout-upcoming-actions">
|
||||
{itemsLoading && <Spinner size={13} tone="muted" />}
|
||||
<Switch
|
||||
checked={showFiller}
|
||||
disabled={itemsLoading || mutating}
|
||||
label="Show filler"
|
||||
onChange={setShowFiller}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="ctv-playout-upcoming" role="list" aria-label="Upcoming playout items">
|
||||
{items.length === 0 ? (
|
||||
<div className="ctv-schedule-empty">{itemsLoading ? <Spinner size={15} tone="muted" /> : 'No upcoming items'}</div>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<div className={item === nowItem ? 'ctv-playout-upcoming-now' : ''} key={`${item.start}-${index}`} role="listitem">
|
||||
<code>{formatDateTime(item.start)}</code>
|
||||
{isFillerItem(item) ? <Sparkles aria-hidden="true" size={13} /> : <Film aria-hidden="true" size={13} />}
|
||||
<span>{item.title ?? 'Untitled item'}</span>
|
||||
{isFillerItem(item) && <Badge tone="neutral">Filler</Badge>}
|
||||
<small>{item.duration ?? 'unknown'}</small>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutTimeline({ items, itemsLoading, nowItem }: { items: PlayoutItem[]; itemsLoading: boolean; nowItem: PlayoutItem | null }) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="ctv-schedule-empty">
|
||||
{itemsLoading ? <Spinner size={15} tone="muted" /> : 'No timeline items'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const firstStart = Date.parse(items[0].start);
|
||||
const lastFinish = Date.parse(items[items.length - 1].finish);
|
||||
const span = Math.max(lastFinish - firstStart, 1);
|
||||
|
||||
return (
|
||||
<div className="ctv-playout-timeline">
|
||||
<div>
|
||||
{items.map((item, index) => {
|
||||
const width = Math.max(((Date.parse(item.finish) - Date.parse(item.start)) / span) * 100, 2);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={isFillerItem(item) ? 'ctv-playout-timeline-filler' : ''}
|
||||
key={`${item.start}-${index}`}
|
||||
style={{ width: `${width}%` }}
|
||||
title={item.title ?? 'Untitled item'}
|
||||
>
|
||||
{width > 12 && (item.title ?? 'Untitled item')}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{nowItem && <i style={{ left: `${timelinePosition(nowItem.start, firstStart, span)}%` }} />}
|
||||
</div>
|
||||
<p>
|
||||
<code>{formatDateTime(items[0].start)}</code>
|
||||
<span><Clock aria-hidden="true" size={12} /> now</span>
|
||||
<code>{formatDateTime(items[items.length - 1].finish)}</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
// The backend always emits fillerKind; real content items carry 'None', so only a
|
||||
// concrete filler kind (PreRoll, MidRoll, ...) marks an item as filler.
|
||||
function isFillerItem(item: PlayoutItem): boolean {
|
||||
return item.fillerKind != null && item.fillerKind !== 'None';
|
||||
}
|
||||
|
||||
function playoutsCountLabel(shownCount: number, totalCount: number): string {
|
||||
if (shownCount === totalCount) {
|
||||
return `${totalCount} playout${totalCount === 1 ? '' : 's'} loaded`;
|
||||
}
|
||||
|
||||
return `${shownCount} of ${totalCount} playout${totalCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function itemMatchingNow(items: PlayoutItem[], title: string | null | undefined): PlayoutItem | null {
|
||||
const nowMs = Date.now();
|
||||
const windowMatch = items.find((item) => {
|
||||
const startMs = Date.parse(item.start);
|
||||
const finishMs = Date.parse(item.finish);
|
||||
|
||||
return Number.isFinite(startMs) && Number.isFinite(finishMs) && startMs <= nowMs && nowMs < finishMs;
|
||||
});
|
||||
|
||||
if (windowMatch) {
|
||||
return windowMatch;
|
||||
}
|
||||
|
||||
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 +2274,10 @@ function ScreenContent({
|
||||
return <ScheduleScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'playouts') {
|
||||
return <PlayoutsScreen />;
|
||||
}
|
||||
|
||||
return <PlaceholderScreen route={route} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
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;
|
||||
}
|
||||
|
||||
type PlayoutsScreenBase = Omit<PlayoutsScreenData, 'items' | 'playout'>;
|
||||
|
||||
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<PlayoutsPage> {
|
||||
return request<PlayoutsPage>('/api/playouts');
|
||||
}
|
||||
|
||||
export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
|
||||
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
|
||||
}
|
||||
|
||||
export function getPlayoutItems(playoutId: number, showFiller = false): Promise<PlayoutItemsPage> {
|
||||
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
|
||||
}
|
||||
|
||||
export function getPlayoutWarningsCount(): Promise<number> {
|
||||
return request<number>('/api/playouts/warnings/count');
|
||||
}
|
||||
|
||||
export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
|
||||
return request<PlayoutChannelState[]>('/api/channels/state');
|
||||
}
|
||||
|
||||
export function resetAllPlayouts(): Promise<void> {
|
||||
return request<void>('/api/playouts/reset-all', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
|
||||
const [state, setState] = useState<PlayoutsScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const [showFiller, setShowFillerState] = useState(false);
|
||||
const activeRef = useRef(true);
|
||||
const selectedPlayoutIdRef = useRef<number | null>(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<PlayoutsScreenBase>({
|
||||
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;
|
||||
}
|
||||
+346
-1
@@ -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(--surface-card);
|
||||
}
|
||||
|
||||
.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, 16px);
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -1376,7 +1718,10 @@
|
||||
}
|
||||
|
||||
.ctv-schedule-grid,
|
||||
.ctv-schedule-form-grid {
|
||||
.ctv-schedule-form-grid,
|
||||
.ctv-playouts-grid,
|
||||
.ctv-playout-detail-grid,
|
||||
.ctv-playouts-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user