Merge pull request 'feat(web): Playouts screen (#87)' (#127) from feat/87-playouts into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled

This commit was merged in pull request #127.
This commit is contained in:
2026-07-05 15:50:57 +00:00
5 changed files with 1455 additions and 3 deletions
+402 -2
View File
@@ -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
View File
@@ -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} />;
}
+1
View File
@@ -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';
+321
View File
@@ -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
View File
@@ -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;
}