refactor(spa): extract ChannelsScreen from App.tsx (#244, epic #243 phase 1) #249

Merged
timothy merged 1 commits from feat/244-channels-extract into main 2026-07-11 13:38:48 +02:00
6 changed files with 1144 additions and 1058 deletions
+26
View File
@@ -433,3 +433,29 @@ semantics: these are exclusive in-progress flags; two holders is the failure mod
Call-site fixes this model prescribes land separately: #232 (scan lifecycle — enqueue only after a
successful lock, compensating unlock on enqueue failure, one release per acquisition in batches) and
#234 (BuildPlayout/subtitles gate their `finally` unlock on the captured acquire result).
## 2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1)
First bounded extraction under the App.tsx modularization epic (#243): the Channels domain moved
verbatim out of `web/src/App.tsx` into `web/src/screens/ChannelsScreen.tsx` (zero-prop, self-sufficient,
mirroring the SchedulesScreen extraction), with its behavior tests moved to a colocated
`ChannelsScreen.test.tsx` that owns its own scoped fetch mock (spa-conventions §6). Pure structural
move: no API/route/CSS/visual change; `App.tsx` retains only the import + the `<ChannelsScreen />`
dispatch clause.
**Decision — no `web/src/channels/` sibling helper directory** (unlike Schedules' `web/src/schedules/`).
Channels' pure logic (`stateByChannelId`, `sortedChannels`, `groupedChannels`, `progressFromChannelState`,
`formatChannelNumber`) totals ~30 lines with no independent business-rule layer comparable to Schedules'
`itemRules.ts` (~470 lines, separately unit-tested). Keeping it inside the single screen file matches the
Blocks/Decos/Templates precedent. Revisit only if a later #243 phase adds substantial pure Channels logic
worth isolating.
**One cross-domain helper inlined, not shared:** the Dashboard-owned `progressFromNowPlaying` (still in
`App.tsx`, used by `OnAirCard`) was structurally reused by the Channels `progressFromChannelState`. Rather
than export it from App or create a shared module, its ~5-line start/finish/now percentage math was inlined
into the moved `progressFromChannelState` (behavior-identical — `ChannelState.nowPlaying` carries the same
`startUtc`/`finishUtc` shape), so `ChannelsScreen.tsx` has no import back into `App.tsx`.
**#238 (TopBar `primaryAction` dead button) left untouched** — the `channels` route's inert `ctv:primary-action`
dispatch is #238's owned bug and out of scope for a behavior-preserving extraction; the shell/action redesign
is deferred to #247 (epic phase 4).
+10
View File
@@ -188,6 +188,16 @@ session — if a preference needs to be shared across devices or is really serve
contain "System". Anchor (`^`/`$`) or use exact strings in `getByRole` name matchers whenever a
new label could be a substring of (or share a substring with) an existing one — check
`App.tsx`'s nav `label:` list for collisions before picking a new label.
- **Extracted-screen tests own their own fetch mock** (Schedules #207, Channels #244): when a
screen is pulled out of `App.tsx` into `web/src/screens/<Name>Screen.tsx`, its colocated
`<Name>Screen.test.tsx` builds a **self-contained** `vi.spyOn(window, 'fetch')` mock scoped to
that screen's own endpoints (plus local `jsonResponse`/fixture-factory helpers) and renders the
screen component **directly** — it must not import from `App.test.tsx` or reuse the monolithic
`mockDashboardApi()`. `App.test.tsx` keeps only a thin **nav-smoke** test for the extracted
screen (route to it, assert it mounted and hit its own endpoint) plus genuinely cross-cutting
shell concerns (route table, sub-path ownership §2, the unsaved-changes/popstate guard §8, the
TopBar action wiring). Detailed screen behavior lives in the screen's own test file. See
`ChannelsScreen.test.tsx` / `SchedulesScreen.test.tsx` for the shape.
## 7. Verification gate — run before every commit touching `web/`
+6 -514
View File
@@ -422,7 +422,10 @@ describe('ChicoryTV SPA scaffold', () => {
expect(window.fetch).not.toHaveBeenCalledWith('/api/schedules', expect.any(Object));
});
it('renders the Channels screen from live channel and state APIs', async () => {
it('renders the extracted Channels screen from live channel APIs (#244)', async () => {
// Thin route/composition smoke test: the full Channels behavioral suite lives colocated in
// web/src/screens/ChannelsScreen.test.tsx. This only asserts the nav wires the extracted screen
// into the shell and it mounts + hits its own endpoint.
mockDashboardApi({
channels: [
{
@@ -436,38 +439,9 @@ describe('ChicoryTV SPA scaffold', () => {
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
},
{
ffmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: false,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: false,
sortNumber: 24,
streamingMode: 'MPEG-TS'
}
],
channelStates: [
{
channelId: 1,
channelNumber: '5.1',
onAir: true,
nowPlaying: {
finishUtc: '2026-07-04T21:30:00Z',
startUtc: '2026-07-04T21:00:00Z',
title: 'Saturday Morning Cartoons'
}
},
{
channelId: 2,
channelNumber: '24',
onAir: false,
nowPlaying: null
}
]
channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }]
});
render(<App />);
@@ -477,492 +451,10 @@ 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.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');
expect(screen.getByTitle('Hidden from EPG')).toHaveTextContent('H');
expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument();
expect(screen.getByText('2 of 2 channels')).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/channels');
expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object));
});
it('polls only channel state after the Channels screen initial load', async () => {
const intervalHandlers: Array<() => void> = [];
vi.spyOn(window, 'setInterval').mockImplementation((handler) => {
if (typeof handler === 'function') {
intervalHandlers.push(handler as () => void);
}
return 1;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
mockDashboardApi({
channels: [
{
ffmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Segmenter'
}
],
channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
const channelFetchesBeforePoll = fetchCount('/api/channels');
const stateFetchesBeforePoll = fetchCount('/api/channels/state');
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(fetchCount('/api/channels/state')).toBeGreaterThan(stateFetchesBeforePoll);
});
expect(fetchCount('/api/channels')).toBe(channelFetchesBeforePoll);
});
it('keeps preview disabled because the list DTO lacks legacy preview support data', async () => {
mockDashboardApi({
channels: [
{
ffmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Segmenter'
}
],
channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Preview unavailable for Retro Cartoons' })).toBeDisabled();
});
it('filters the Channels screen by on-air and disabled rows', async () => {
mockDashboardApi({
channels: [
{
ffmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
},
{
ffmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: false,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: false,
sortNumber: 24,
streamingMode: 'MPEG-TS'
}
],
channelStates: [
{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null },
{ channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null }
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'On air 1' }));
expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.queryByText('News 24')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Disabled 1' }));
expect(screen.queryByText('Retro Cartoons')).not.toBeInTheDocument();
expect(screen.getByText('News 24')).toBeInTheDocument();
});
it('runs channel row delete and refetches the Channels screen', async () => {
mockDashboardApi({
channels: [
{
ffmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
}
],
confirm: true
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete Retro Cartoons' }));
await screen.findByText('Retro Cartoons');
expect(window.fetch).toHaveBeenCalledWith('/api/channels/1', expect.objectContaining({ method: 'DELETE' }));
expect(fetchCount('/api/channels')).toBeGreaterThan(2);
});
it('runs bulk move and delete actions with ProblemDetails error display', async () => {
mockDashboardApi({
channels: [
{
ffmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
}
],
confirm: true,
mutationFailures: {
'/api/channels/bulk/delete': {
detail: 'Channel 1 cannot be deleted while active',
status: 422,
title: 'Validation failed'
}
},
prompt: 'Movies'
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('button', { name: 'Move to group' }));
expect(window.fetch).toHaveBeenCalledWith('/api/channels/bulk/group', expect.objectContaining({
body: JSON.stringify({ channelIds: [1], group: 'Movies' }),
method: 'POST'
}));
expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute(
'aria-checked',
'false'
);
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('button', { name: 'Delete selected' }));
expect(await screen.findByText('Channel 1 cannot be deleted while active')).toBeInTheDocument();
});
it('runs bulk renumber and clears the selection afterward', async () => {
mockDashboardApi({
channels: [
{
ffmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
},
{
ffmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: false,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: false,
sortNumber: 24,
streamingMode: 'MPEG-TS'
}
],
prompt: '10'
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' }));
fireEvent.click(screen.getByRole('button', { name: 'Renumber' }));
expect(window.fetch).toHaveBeenCalledWith('/api/channels/bulk/renumber', expect.objectContaining({
body: JSON.stringify({ channels: [{ id: 1, number: '10' }, { id: 2, number: '11' }] }),
method: 'POST'
}));
expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute(
'aria-checked',
'false'
);
expect(screen.getByRole('checkbox', { name: 'Select News 24' })).toHaveAttribute(
'aria-checked',
'false'
);
});
it('creates a blank channel with Blazor-parity defaults and navigates to its editor', async () => {
mockDashboardApi({
channels: [
{
ffmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
},
{
ffmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: true,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: false,
sortNumber: 24,
streamingMode: 'MPEG-TS'
}
],
createChannelResponse: { id: 42 },
ffmpegSettings: defaultFfmpegSettings({ defaultFFmpegProfileId: 7 })
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
await waitFor(() =>
expect(window.fetch).toHaveBeenCalledWith('/api/channels', expect.objectContaining({ method: 'POST' }))
);
const body = requestBodyFor('/api/channels');
expect(body).toMatchObject({
categories: null,
fallbackFillerId: null,
ffmpegProfileId: 7,
group: 'ErsatzTV',
idleBehavior: 'StopOnDisconnect',
isEnabled: true,
logo: { path: null, contentType: null },
mirrorSourceChannelId: null,
musicVideoCreditsMode: 'None',
musicVideoCreditsTemplate: null,
name: 'New Channel',
number: '25',
playoutMode: 'Continuous',
playoutOffset: null,
playoutSource: 'Generated',
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
preferredSubtitleLanguageCode: null,
showInEpg: true,
slugSeconds: null,
songVideoMode: 'Default',
streamingMode: 'TransportStreamHybrid',
streamSelector: null,
streamSelectorMode: 'Default',
subtitleMode: 'None',
transcodeMode: 'OnDemand',
watermarkId: null
});
await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/42'));
});
it('surfaces a 422 error from bare-channel create without navigating', async () => {
mockDashboardApi({
channels: [
{
ffmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
}
],
createChannelFailure: { detail: 'A channel with that number already exists', status: 422, title: 'Validation failed' }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
expect(await screen.findByText('A channel with that number already exists')).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/channels');
});
it('bare-creates the first channel from an empty lineup (#212 regression)', async () => {
// Fresh install: GET /api/channels returns []. The empty state must still offer bare-create — the
// old early return rendered a dead-end card before the action bar, so no channel could be created.
mockDashboardApi({
channels: [],
createChannelResponse: { id: 7 },
ffmpegSettings: defaultFfmpegSettings({ defaultFFmpegProfileId: 3 })
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('No channels yet')).toBeInTheDocument();
// Both create affordances are reachable on an empty lineup (the empty-state "Add Channel" is in
// addition to the always-present TopBar one, so there are two).
expect(screen.getAllByRole('button', { name: 'Add Channel' }).length).toBeGreaterThanOrEqual(2);
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
await waitFor(() =>
expect(window.fetch).toHaveBeenCalledWith('/api/channels', expect.objectContaining({ method: 'POST' }))
);
const body = requestBodyFor('/api/channels');
expect(body).toMatchObject({
ffmpegProfileId: 3,
group: 'ErsatzTV',
name: 'New Channel',
// max(number)+1 degenerates to 1 on an empty lineup.
number: '1'
});
await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/7'));
});
it('surfaces a bare-create failure on an empty lineup (#242 review follow-up)', async () => {
// The fresh-install path #212 exists for: a new install may have no default ffmpeg profile, so
// bare-create can 4xx. The empty state must render that error, not swallow it behind a spinner
// that just re-enables. (Regression for the empty branch dropping mutationError.)
mockDashboardApi({
channels: [],
createChannelFailure: { detail: 'No FFmpeg profile is configured', status: 422, title: 'Validation failed' },
ffmpegSettings: defaultFfmpegSettings({ defaultFFmpegProfileId: 0 })
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('No channels yet')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
expect(await screen.findByText('No FFmpeg profile is configured')).toBeInTheDocument();
// Still on the empty lineup — no navigation, and the create affordances remain reachable.
expect(window.location.pathname).toBe('/app/channels');
expect(screen.getByRole('button', { name: 'New blank channel' })).toBeInTheDocument();
});
it('clears the selection when the channel view filter changes', async () => {
mockDashboardApi({
channels: [
{
ffmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
},
{
ffmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: true,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: true,
sortNumber: 24,
streamingMode: 'MPEG-TS'
}
],
channelStates: [
{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null },
{ channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null }
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' }));
expect(screen.getByRole('button', { name: 'Delete selected' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'On air 1' }));
expect(screen.queryByText('News 24')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.queryByRole('button', { name: 'Delete selected' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument();
});
it('renders the extracted Schedules editor from live schedule APIs (#207)', async () => {
mockDashboardApi({
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons', collectionName: 'Saturday Cartoons' })],
+1 -544
View File
@@ -26,9 +26,7 @@ import {
FileImage,
Eraser,
Folder,
FolderInput,
FolderTree,
Hash,
HardDrive,
Info,
Layers,
@@ -70,6 +68,7 @@ import { MediaBrowseScreen } from './screens/MediaBrowseScreen';
import { MultiCollectionsScreen } from './screens/MultiCollectionsScreen';
import { PlaylistsScreen } from './screens/PlaylistsScreen';
import { RerunCollectionsScreen } from './screens/RerunCollectionsScreen';
import { ChannelsScreen } from './screens/ChannelsScreen';
import { SchedulesScreen } from './screens/SchedulesScreen';
import { canLeaveCurrentScreen } from './navigationGuard';
import {
@@ -101,7 +100,6 @@ import {
Badge,
Button,
Card,
Checkbox,
ChannelLogo,
Dialog,
IconButton,
@@ -117,20 +115,13 @@ import {
} from './components';
import {
useChannelsQuery,
useChannelsScreenQuery,
bulkDeleteChannels,
bulkMoveChannelsToGroup,
bulkRenumberChannels,
createPlayout,
deletePlayout,
erasePlayoutItems,
erasePlayoutItemsAndHistory,
getPlayoutItemSchedulingContext,
resetChannelPlayout,
createChannel,
deleteChannel,
getDecos,
getFfmpegSettings,
ApiError,
messageFromError,
getSchedules,
@@ -146,10 +137,8 @@ import {
useGuideScreenQuery,
useLibrariesScreenQuery,
type ChannelState,
type ChannelSummary,
type ChannelGuideChannel,
type ChannelGuideProgramme,
type CreateChannelRequest,
type CreatePlayoutRequest,
type DashboardChannel,
type DecoListItem,
@@ -1181,538 +1170,6 @@ function DashboardScreen({
);
}
type ChannelViewFilter = 'all' | 'onair' | 'disabled';
const streamingModeBadgeTones: Record<string, 'accent' | 'neutral'> = {
'HLS Direct': 'neutral',
'HLS Segmenter': 'accent',
'MPEG-TS': 'accent',
'MPEG-TS (Legacy)': 'neutral'
};
function stateByChannelId(states: ChannelState[]): Map<number, ChannelState> {
return new Map(states.map((state) => [state.channelId, state]));
}
function sortedChannels(channels: ChannelSummary[]): ChannelSummary[] {
return [...channels].sort((left, right) => left.sortNumber - right.sortNumber || left.number.localeCompare(right.number));
}
function groupedChannels(channels: ChannelSummary[]): Array<{ group: string; rows: ChannelSummary[] }> {
const groups: Array<{ group: string; rows: ChannelSummary[] }> = [];
const indexes = new Map<string, number>();
for (const channel of channels) {
const group = channel.group || 'Ungrouped';
const index = indexes.get(group);
if (index == null) {
indexes.set(group, groups.length);
groups.push({ group, rows: [channel] });
} else {
groups[index].rows.push(channel);
}
}
return groups;
}
function progressFromChannelState(state: ChannelState): number | null {
return state.nowPlaying ? progressFromNowPlaying(state.nowPlaying) : null;
}
function Marker({ children, title }: { children: ReactNode; title: string }) {
return (
<span className="ctv-channel-marker" title={title}>
{children}
</span>
);
}
function ChannelsLoadingState() {
return (
<Card>
<div className="ctv-dashboard-state">
<Spinner size={20} tone="accent" />
<span>Loading channels</span>
</div>
</Card>
);
}
function ChannelsErrorState({ error, refresh }: { error: string; refresh: () => void }) {
return (
<Card
title={<h2>Channels unavailable</h2>}
subtitle="Live API request failed"
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={14} />} variant="secondary">Retry</Button>}
>
<div className="ctv-dashboard-error">
<span>API request failed</span>
<strong>{error}</strong>
</div>
</Card>
);
}
// Empty lineup (fresh install) must still let the user create their first channel — both the
// library-to-lineup ChannelBuilder flow ("Add Channel") and the bare-create flow ("New blank
// channel"). The bare-create handler is passed in from ChannelsScreen so the defaults (number =
// max+1, which degenerates to 1 on an empty lineup; group "ErsatzTV"; default ffmpeg profile) are
// identical to the action bar's. Regression for #212 (reopened): the old early return rendered a
// dead-end card before the action bar, so a fresh install could never create a channel.
// mutationError is rendered here too: a fresh install may have no default ffmpeg profile, so
// bare-create can 4xx — the non-empty screen shows that alert, and this branch must not swallow it
// (a silent spinner on the one screen that exists to bootstrap the first channel is the worst place
// to hide the failure).
function ChannelsEmptyState({
creatingBlank,
mutationError,
onCreateBlank
}: {
creatingBlank: boolean;
mutationError: string | null;
onCreateBlank: () => void;
}) {
return (
<Card title={<h2>No channels</h2>} subtitle="No channels yet — create your first one.">
<div className="ctv-channels-empty">
<p className="ctv-dashboard-empty">No channels yet</p>
{mutationError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{mutationError}</span>
</div>
)}
<div className="ctv-channels-empty-actions">
<Button
disabled={creatingBlank}
loading={creatingBlank}
onClick={onCreateBlank}
startIcon={<Plus aria-hidden="true" size={14} />}
variant="secondary"
>
New blank channel
</Button>
<Button
startIcon={<Plus aria-hidden="true" size={14} />}
onClick={() => navigateToPath('/app/new-channel')}
>
Add Channel
</Button>
</div>
</div>
</Card>
);
}
function ChannelsScreen() {
const query = useChannelsScreenQuery();
const [filter, setFilter] = useState<ChannelViewFilter>('all');
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
const [creatingBlank, setCreatingBlank] = useState(false);
if (query.status === 'loading') {
return <ChannelsLoadingState />;
}
if (query.status === 'error') {
return <ChannelsErrorState error={query.error} refresh={query.refresh} />;
}
const channels = sortedChannels(query.data.channels);
const statesById = stateByChannelId(query.data.channelStates);
const onAirCount = query.data.channelStates.filter((state) => state.onAir).length;
const disabledCount = channels.filter((channel) => !channel.isEnabled).length;
const visibleChannels = channels.filter((channel) => {
if (filter === 'onair') {
return statesById.get(channel.id)?.onAir === true;
}
if (filter === 'disabled') {
return !channel.isEnabled;
}
return true;
});
const groups = groupedChannels(visibleChannels);
const visibleIds = visibleChannels.map((channel) => channel.id);
const selectedVisibleCount = visibleIds.filter((id) => selectedIds.has(id)).length;
const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length;
const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected;
const selectedChannels = visibleChannels.filter((channel) => selectedIds.has(channel.id));
const refreshAfterMutation = async (operation: () => Promise<void>) => {
setMutationError(null);
setMutating(true);
try {
await operation();
setSelectedIds(new Set());
query.refresh();
} catch (error: unknown) {
setMutationError(messageFromError(error));
} finally {
setMutating(false);
}
};
const toggleAll = () => {
setSelectedIds((current) => {
const next = new Set(current);
if (allVisibleSelected) {
visibleIds.forEach((id) => next.delete(id));
} else {
visibleIds.forEach((id) => next.add(id));
}
return next;
});
};
const toggleOne = (channelId: number) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(channelId)) {
next.delete(channelId);
} else {
next.add(channelId);
}
return next;
});
};
const moveSelectedToGroup = () => {
const group = window.prompt('Move selected channels to group', selectedChannels[0]?.group ?? '');
if (group == null) {
return;
}
void refreshAfterMutation(() => bulkMoveChannelsToGroup({
channelIds: selectedChannels.map((channel) => channel.id),
group
}));
};
const renumberSelected = () => {
const firstNumber = window.prompt('First channel number', selectedChannels[0]?.number ?? '1');
if (firstNumber == null) {
return;
}
const start = Number.parseFloat(firstNumber);
if (!Number.isFinite(start)) {
setMutationError('First channel number must be numeric');
return;
}
void refreshAfterMutation(() => bulkRenumberChannels({
channels: selectedChannels.map((channel, index) => ({
id: channel.id,
number: formatChannelNumber(start + index)
}))
}));
};
const deleteSelected = () => {
if (!window.confirm(`Delete ${selectedChannels.length} selected channel${selectedChannels.length === 1 ? '' : 's'}?`)) {
return;
}
void refreshAfterMutation(() => bulkDeleteChannels({ channelIds: selectedChannels.map((channel) => channel.id) }));
};
const changeFilter = (nextFilter: ChannelViewFilter) => {
setFilter(nextFilter);
setSelectedIds(new Set());
};
const deleteOne = (channel: ChannelSummary) => {
if (!window.confirm(`Delete ${channel.name}?`)) {
return;
}
void refreshAfterMutation(() => deleteChannel(channel.id));
};
// Blazor parity (ChannelEditor.razor "add" branch): a blank channel is created with these
// defaults, then the editor opens for the rest of the fields. Distinct from "Add Channel",
// which is the library-to-lineup ChannelBuilder flow (untouched here).
const createBlankChannel = async () => {
if (creatingBlank) {
return;
}
setMutationError(null);
setCreatingBlank(true);
try {
const ffmpegSettings = await getFfmpegSettings();
const maxNumber = channels.reduce((max, channel) => {
const parsed = Number.parseInt(channel.number.split('.')[0] ?? '', 10);
return Number.isFinite(parsed) ? Math.max(max, parsed) : max;
}, 0);
const body: CreateChannelRequest = {
categories: null,
fallbackFillerId: null,
ffmpegProfileId: ffmpegSettings.defaultFFmpegProfileId,
group: 'ErsatzTV',
idleBehavior: 'StopOnDisconnect',
isEnabled: true,
logo: { path: null, contentType: null },
mirrorSourceChannelId: null,
musicVideoCreditsMode: 'None',
musicVideoCreditsTemplate: null,
name: 'New Channel',
number: String(maxNumber + 1),
playoutMode: 'Continuous',
playoutOffset: null,
playoutSource: 'Generated',
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
preferredSubtitleLanguageCode: null,
showInEpg: true,
slugSeconds: null,
songVideoMode: 'Default',
streamingMode: 'TransportStreamHybrid',
streamSelector: null,
streamSelectorMode: 'Default',
subtitleMode: 'None',
transcodeMode: 'OnDemand',
watermarkId: null
};
const created = await createChannel(body);
navigateToPath(`/app/edit-channel/${created.id}`);
} catch (error: unknown) {
setMutationError(messageFromError(error));
} finally {
setCreatingBlank(false);
}
};
if (channels.length === 0) {
return (
<ChannelsEmptyState
creatingBlank={creatingBlank}
mutationError={mutationError}
onCreateBlank={() => void createBlankChannel()}
/>
);
}
return (
<div className="ctv-channels-screen">
<div className="ctv-channels-actionbar">
{selectedChannels.length > 0 ? (
<>
<span className="ctv-channels-selected"><code>{selectedChannels.length}</code> selected</span>
<span className="ctv-topbar-divider" />
<Button disabled={mutating} onClick={renumberSelected} size="sm" startIcon={<Hash aria-hidden="true" size={14} />} variant="secondary">Renumber</Button>
<Button disabled={mutating} onClick={moveSelectedToGroup} size="sm" startIcon={<FolderInput aria-hidden="true" size={14} />} variant="secondary">Move to group</Button>
<Button disabled={mutating} onClick={deleteSelected} size="sm" startIcon={<Trash2 aria-hidden="true" size={14} />} variant="danger">Delete selected</Button>
<span className="ctv-channels-spacer" />
<Button disabled={mutating} onClick={() => setSelectedIds(new Set())} size="sm" variant="ghost">Clear</Button>
</>
) : (
<>
<div className="ctv-segmented" role="group" aria-label="Channel view">
<button type="button" aria-pressed={filter === 'all'} onClick={() => changeFilter('all')}>All <code>{channels.length}</code></button>
<button type="button" aria-pressed={filter === 'onair'} onClick={() => changeFilter('onair')}>On air <code>{onAirCount}</code></button>
<button type="button" aria-pressed={filter === 'disabled'} onClick={() => changeFilter('disabled')}>Disabled <code>{disabledCount}</code></button>
</div>
<span className="ctv-channels-spacer" />
<span className="ctv-channels-live"><StatusDot status="live" size={7} /><code>{onAirCount}</code> on air</span>
<Button
disabled={creatingBlank}
loading={creatingBlank}
onClick={() => void createBlankChannel()}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
variant="secondary"
>
New blank channel
</Button>
<Button size="sm" startIcon={<Plus aria-hidden="true" size={14} />} onClick={() => navigateToPath('/app/new-channel')}>Add Channel</Button>
</>
)}
</div>
{mutationError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{mutationError}</span>
</div>
)}
<div className="ctv-channels-table-frame">
<div className="ctv-channels-table-scroll">
<table className="ctv-channels-table" aria-label="Channels lineup">
<thead>
<tr>
<th className="ctv-channel-check"><Checkbox checked={allVisibleSelected} indeterminate={someVisibleSelected} label="Select visible channels" onChange={toggleAll} /></th>
<th>No.</th>
<th>Channel</th>
<th>Now playing</th>
<th>Streaming</th>
<th>FFmpeg</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{groups.map(({ group, rows }) => (
<ChannelGroupRows
channels={rows}
group={group}
key={group}
mutating={mutating}
onDelete={deleteOne}
onToggle={toggleOne}
selectedIds={selectedIds}
statesById={statesById}
/>
))}
</tbody>
</table>
</div>
<div className="ctv-channels-footer">
<span>{visibleChannels.length} of {channels.length} channels</span>
<span><code>{groups.length}</code> groups</span>
</div>
</div>
</div>
);
}
function ChannelGroupRows({
channels,
group,
mutating,
onDelete,
onToggle,
selectedIds,
statesById
}: {
channels: ChannelSummary[];
group: string;
mutating: boolean;
onDelete: (channel: ChannelSummary) => void;
onToggle: (channelId: number) => void;
selectedIds: Set<number>;
statesById: Map<number, ChannelState>;
}) {
return (
<>
<tr className="ctv-channel-group-row">
<td colSpan={7}>
<span><Folder aria-hidden="true" size={13} />{group}<code>{channels.length}</code></span>
</td>
</tr>
{channels.map((channel) => (
<ChannelTableRow
channel={channel}
key={channel.id}
mutating={mutating}
onDelete={onDelete}
onToggle={onToggle}
selected={selectedIds.has(channel.id)}
state={statesById.get(channel.id) ?? null}
/>
))}
</>
);
}
function formatChannelNumber(value: number): string {
return Number.isInteger(value) ? `${value}` : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}
function ChannelTableRow({
channel,
mutating,
onDelete,
onToggle,
selected,
state
}: {
channel: ChannelSummary;
mutating: boolean;
onDelete: (channel: ChannelSummary) => void;
onToggle: (channelId: number) => void;
selected: boolean;
state: ChannelState | null;
}) {
const live = state?.onAir === true;
const dim = !channel.isEnabled;
const progress = state ? progressFromChannelState(state) : null;
const streamingTone = streamingModeBadgeTones[channel.streamingMode] ?? 'neutral';
return (
<tr className={`${live ? 'ctv-channel-row-live ' : ''}${selected ? 'ctv-channel-row-selected ' : ''}${dim ? 'ctv-channel-row-dim' : ''}`}>
<td className="ctv-channel-check">
<Checkbox checked={selected} label={`Select ${channel.name}`} onChange={() => onToggle(channel.id)} />
</td>
<td className="ctv-channel-number">
<span>{live && <StatusDot status="live" size={7} />}<code>{channel.number}</code></span>
</td>
<td>
<div className="ctv-channel-identity">
<ChannelLogo name={channel.name} size={34} />
<div>
<strong>{channel.name}</strong>
<span className="ctv-channel-markers">
{!channel.isEnabled && <Marker title="Disabled">D</Marker>}
{!channel.showInEpg && <Marker title="Hidden from EPG">H</Marker>}
</span>
<small>{channel.language || 'Language unset'}</small>
</div>
</div>
</td>
<td className="ctv-channel-now">
{state?.nowPlaying ? (
<div>
<span>{state.nowPlaying.title}</span>
<ProgressBar height={3} value={progress} />
</div>
) : (
<span>{channel.isEnabled ? 'Idle' : 'Off air'}</span>
)}
</td>
<td><Badge tone={streamingTone}>{channel.streamingMode}</Badge></td>
<td className="ctv-channel-ffmpeg">{channel.ffmpegProfile || 'Unassigned'}</td>
<td>
<div className="ctv-channel-actions">
<IconButton disabled size="sm" title={`Preview unavailable for ${channel.name}`}>
<Play aria-hidden="true" size={15} />
</IconButton>
<IconButton onClick={() => navigateToPath(`/app/edit-channel/${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
<Pencil aria-hidden="true" size={15} />
</IconButton>
<IconButton
onClick={() => navigateToPath(`/app/troubleshooting/playback?channel=${channel.id}`)}
size="sm"
title={`Troubleshoot ${channel.name}`}
>
<Stethoscope aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled={mutating} onClick={() => onDelete(channel)} size="sm" title={`Delete ${channel.name}`}>
<Trash2 aria-hidden="true" size={15} />
</IconButton>
</div>
</td>
</tr>
);
}
function formatScheduleEnum(value: string): string {
return value.replace(/([a-z])([A-Z])/g, '$1 $2');
}
+524
View File
@@ -0,0 +1,524 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChannelsScreen } from './ChannelsScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
// ChannelResponseModel row (runtime/Newtonsoft casing — see spa-conventions §4).
function channelRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
ffmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct',
...overrides
};
}
// ChannelStateResponseModel row.
function stateRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
channelId: 1,
channelNumber: '5.1',
nowPlaying: null,
onAir: false,
...overrides
};
}
function ffmpegSettingsFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
defaultFFmpegProfileId: 1,
...overrides
};
}
interface MockOptions {
channels?: unknown[];
channelStates?: unknown[];
createChannelResponse?: { id: number } | null;
createChannelFailure?: { detail?: string; status?: number; title?: string } | null;
ffmpegSettings?: Record<string, unknown>;
// path -> ProblemDetails body returned with a 422 for a bulk mutation.
mutationFailures?: Record<string, { detail?: string; status?: number; title?: string }>;
}
interface MockHandle {
fetchSpy: ReturnType<typeof vi.spyOn>;
requests: Array<{ url: string; method: string; body: unknown }>;
}
function mockApi(options: MockOptions = {}): MockHandle {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const channels = options.channels ?? [channelRow()];
const channelStates = options.channelStates ?? [];
const ffmpegSettings = options.ffmpegSettings ?? ffmpegSettingsFixture();
const mutationFailures = options.mutationFailures ?? {};
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = (init?.method ?? 'GET').toUpperCase();
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
requests.push({ url, method, body });
if (url === '/api/channels' && method === 'POST') {
if (options.createChannelFailure) {
return Promise.resolve(jsonResponse(options.createChannelFailure, options.createChannelFailure.status ?? 422));
}
return Promise.resolve(jsonResponse(options.createChannelResponse ?? { id: 99 }, 201));
}
if (url === '/api/channels' && method === 'GET') return Promise.resolve(jsonResponse(channels));
if (url === '/api/channels/state' && method === 'GET') return Promise.resolve(jsonResponse(channelStates));
if (url === '/api/settings/ffmpeg' && method === 'GET') return Promise.resolve(jsonResponse(ffmpegSettings));
if (
url === '/api/channels/bulk/renumber' ||
url === '/api/channels/bulk/group' ||
url === '/api/channels/bulk/delete'
) {
if (url in mutationFailures) {
return Promise.resolve(jsonResponse(mutationFailures[url], mutationFailures[url].status ?? 422));
}
return Promise.resolve(new Response(null, { status: 204 }));
}
if (/^\/api\/channels\/\d+$/.test(url) && method === 'DELETE') {
if (url in mutationFailures) {
return Promise.resolve(jsonResponse(mutationFailures[url], mutationFailures[url].status ?? 422));
}
return Promise.resolve(new Response(null, { status: 204 }));
}
return Promise.resolve(jsonResponse(null, 404));
});
return { fetchSpy, requests };
}
function fetchCount(handle: MockHandle, path: string): number {
return handle.requests.filter((r) => r.url === path).length;
}
function requestBodyFor(handle: MockHandle, path: string): Record<string, unknown> {
const call = handle.requests.find((r) => r.url === path && r.body != null);
if (!call) {
throw new Error(`no request captured for ${path}`);
}
return call.body as Record<string, unknown>;
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.history.replaceState(null, '', '/app/channels');
});
beforeEach(() => {
window.history.replaceState(null, '', '/app/channels');
vi.spyOn(window, 'confirm').mockReturnValue(true);
vi.spyOn(window, 'prompt').mockReturnValue(null);
});
describe('ChannelsScreen — load + render', () => {
it('renders channels from live channel and state APIs', async () => {
const handle = mockApi({
channels: [
channelRow({ id: 1, group: 'Kids', name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1, streamingMode: 'HLS Direct' }),
channelRow({
ffmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: false,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: false,
sortNumber: 24,
streamingMode: 'MPEG-TS'
})
],
channelStates: [
stateRow({
channelId: 1,
channelNumber: '5.1',
nowPlaying: {
finishUtc: '2026-07-04T21:30:00Z',
startUtc: '2026-07-04T21:00:00Z',
title: 'Saturday Morning Cartoons'
},
onAir: true
}),
stateRow({ channelId: 2, channelNumber: '24', nowPlaying: null, onAir: false })
]
});
render(<ChannelsScreen />);
expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument();
expect(screen.getByText('Retro 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');
expect(screen.getByTitle('Hidden from EPG')).toHaveTextContent('H');
expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument();
expect(screen.getByText('2 of 2 channels')).toBeInTheDocument();
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels/state', expect.any(Object));
});
it('polls only channel state after the initial load', async () => {
const intervalHandlers: Array<() => void> = [];
vi.spyOn(window, 'setInterval').mockImplementation((handler) => {
if (typeof handler === 'function') {
intervalHandlers.push(handler as () => void);
}
return 1;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
const handle = mockApi({
channels: [channelRow({ streamingMode: 'HLS Segmenter' })],
channelStates: [stateRow({ onAir: true })]
});
render(<ChannelsScreen />);
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
const channelFetchesBeforePoll = fetchCount(handle, '/api/channels');
const stateFetchesBeforePoll = fetchCount(handle, '/api/channels/state');
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(fetchCount(handle, '/api/channels/state')).toBeGreaterThan(stateFetchesBeforePoll);
});
expect(fetchCount(handle, '/api/channels')).toBe(channelFetchesBeforePoll);
});
it('keeps preview disabled because the list DTO lacks legacy preview support data', async () => {
mockApi({
channels: [channelRow({ streamingMode: 'HLS Segmenter' })],
channelStates: [stateRow({ onAir: true })]
});
render(<ChannelsScreen />);
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Preview unavailable for Retro Cartoons' })).toBeDisabled();
});
});
describe('ChannelsScreen — filtering + selection', () => {
it('filters by on-air and disabled rows', async () => {
mockApi({
channels: [
channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }),
channelRow({
ffmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: false,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: false,
sortNumber: 24,
streamingMode: 'MPEG-TS'
})
],
channelStates: [
stateRow({ channelId: 1, channelNumber: '5.1', onAir: true }),
stateRow({ channelId: 2, channelNumber: '24', onAir: false })
]
});
render(<ChannelsScreen />);
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'On air 1' }));
expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.queryByText('News 24')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Disabled 1' }));
expect(screen.queryByText('Retro Cartoons')).not.toBeInTheDocument();
expect(screen.getByText('News 24')).toBeInTheDocument();
});
it('clears the selection when the channel view filter changes', async () => {
mockApi({
channels: [
channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }),
channelRow({
ffmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: true,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: true,
sortNumber: 24,
streamingMode: 'MPEG-TS'
})
],
channelStates: [
stateRow({ channelId: 1, channelNumber: '5.1', onAir: true }),
stateRow({ channelId: 2, channelNumber: '24', onAir: false })
]
});
render(<ChannelsScreen />);
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' }));
expect(screen.getByRole('button', { name: 'Delete selected' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'On air 1' }));
expect(screen.queryByText('News 24')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.queryByRole('button', { name: 'Delete selected' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument();
});
});
describe('ChannelsScreen — mutations', () => {
it('runs channel row delete and refetches', async () => {
const handle = mockApi({ channels: [channelRow()] });
render(<ChannelsScreen />);
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
const channelFetchesBeforeDelete = fetchCount(handle, '/api/channels');
fireEvent.click(screen.getByRole('button', { name: 'Delete Retro Cartoons' }));
await screen.findByText('Retro Cartoons');
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels/1', expect.objectContaining({ method: 'DELETE' }));
await waitFor(() => expect(fetchCount(handle, '/api/channels')).toBeGreaterThan(channelFetchesBeforeDelete));
});
it('runs bulk move then surfaces a ProblemDetails error on bulk delete', async () => {
vi.spyOn(window, 'prompt').mockReturnValue('Movies');
const handle = mockApi({
channels: [channelRow()],
mutationFailures: {
'/api/channels/bulk/delete': {
detail: 'Channel 1 cannot be deleted while active',
status: 422,
title: 'Validation failed'
}
}
});
render(<ChannelsScreen />);
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('button', { name: 'Move to group' }));
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels/bulk/group', expect.objectContaining({
body: JSON.stringify({ channelIds: [1], group: 'Movies' }),
method: 'POST'
}));
expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute(
'aria-checked',
'false'
);
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('button', { name: 'Delete selected' }));
expect(await screen.findByText('Channel 1 cannot be deleted while active')).toBeInTheDocument();
});
it('runs bulk renumber and clears the selection afterward', async () => {
vi.spyOn(window, 'prompt').mockReturnValue('10');
const handle = mockApi({
channels: [
channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }),
channelRow({
ffmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: false,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: false,
sortNumber: 24,
streamingMode: 'MPEG-TS'
})
]
});
render(<ChannelsScreen />);
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' }));
fireEvent.click(screen.getByRole('button', { name: 'Renumber' }));
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels/bulk/renumber', expect.objectContaining({
body: JSON.stringify({ channels: [{ id: 1, number: '10' }, { id: 2, number: '11' }] }),
method: 'POST'
}));
expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute(
'aria-checked',
'false'
);
expect(screen.getByRole('checkbox', { name: 'Select News 24' })).toHaveAttribute(
'aria-checked',
'false'
);
});
});
describe('ChannelsScreen — bare create', () => {
it('creates a blank channel with Blazor-parity defaults and navigates to its editor', async () => {
const handle = mockApi({
channels: [
channelRow({ id: 1, name: 'Retro Cartoons', number: '5.1', sortNumber: 5.1 }),
channelRow({
ffmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: true,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: false,
sortNumber: 24,
streamingMode: 'MPEG-TS'
})
],
createChannelResponse: { id: 42 },
ffmpegSettings: ffmpegSettingsFixture({ defaultFFmpegProfileId: 7 })
});
render(<ChannelsScreen />);
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
await waitFor(() =>
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels', expect.objectContaining({ method: 'POST' }))
);
const body = requestBodyFor(handle, '/api/channels');
expect(body).toMatchObject({
categories: null,
fallbackFillerId: null,
ffmpegProfileId: 7,
group: 'ErsatzTV',
idleBehavior: 'StopOnDisconnect',
isEnabled: true,
logo: { path: null, contentType: null },
mirrorSourceChannelId: null,
musicVideoCreditsMode: 'None',
musicVideoCreditsTemplate: null,
name: 'New Channel',
number: '25',
playoutMode: 'Continuous',
playoutOffset: null,
playoutSource: 'Generated',
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
preferredSubtitleLanguageCode: null,
showInEpg: true,
slugSeconds: null,
songVideoMode: 'Default',
streamingMode: 'TransportStreamHybrid',
streamSelector: null,
streamSelectorMode: 'Default',
subtitleMode: 'None',
transcodeMode: 'OnDemand',
watermarkId: null
});
await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/42'));
});
it('surfaces a 422 error from bare-channel create without navigating', async () => {
mockApi({
channels: [channelRow()],
createChannelFailure: { detail: 'A channel with that number already exists', status: 422, title: 'Validation failed' }
});
render(<ChannelsScreen />);
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
expect(await screen.findByText('A channel with that number already exists')).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/channels');
});
it('bare-creates the first channel from an empty lineup (#212 regression)', async () => {
// Fresh install: GET /api/channels returns []. The empty state must still offer bare-create — the
// old early return rendered a dead-end card before the action bar, so no channel could be created.
const handle = mockApi({
channels: [],
createChannelResponse: { id: 7 },
ffmpegSettings: ffmpegSettingsFixture({ defaultFFmpegProfileId: 3 })
});
render(<ChannelsScreen />);
expect(await screen.findByText('No channels yet')).toBeInTheDocument();
// Both create affordances are reachable on an empty lineup.
expect(screen.getByRole('button', { name: 'Add Channel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'New blank channel' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
await waitFor(() =>
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/channels', expect.objectContaining({ method: 'POST' }))
);
const body = requestBodyFor(handle, '/api/channels');
expect(body).toMatchObject({
ffmpegProfileId: 3,
group: 'ErsatzTV',
name: 'New Channel',
// max(number)+1 degenerates to 1 on an empty lineup.
number: '1'
});
await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/7'));
});
it('surfaces a bare-create failure on an empty lineup (#242 review follow-up)', async () => {
// The fresh-install path #212 exists for: a new install may have no default ffmpeg profile, so
// bare-create can 4xx. The empty state must render that error, not swallow it behind a spinner
// that just re-enables. (Regression for the empty branch dropping mutationError.)
mockApi({
channels: [],
createChannelFailure: { detail: 'No FFmpeg profile is configured', status: 422, title: 'Validation failed' },
ffmpegSettings: ffmpegSettingsFixture({ defaultFFmpegProfileId: 0 })
});
render(<ChannelsScreen />);
expect(await screen.findByText('No channels yet')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
expect(await screen.findByText('No FFmpeg profile is configured')).toBeInTheDocument();
// Still on the empty lineup — no navigation, and the create affordances remain reachable.
expect(window.location.pathname).toBe('/app/channels');
expect(screen.getByRole('button', { name: 'New blank channel' })).toBeInTheDocument();
});
});
+577
View File
@@ -0,0 +1,577 @@
import { useState, type ReactNode } from 'react';
import { Folder, FolderInput, Hash, Pencil, Play, Plus, RefreshCw, Stethoscope, Trash2, TriangleAlert } from 'lucide-react';
import {
Badge,
Button,
Card,
ChannelLogo,
Checkbox,
IconButton,
ProgressBar,
Spinner,
StatusDot
} from '../components';
import {
bulkDeleteChannels,
bulkMoveChannelsToGroup,
bulkRenumberChannels,
createChannel,
deleteChannel,
getFfmpegSettings,
messageFromError,
useChannelsScreenQuery,
type ChannelState,
type ChannelSummary,
type CreateChannelRequest
} from '../api';
import { navigateToPath } from '../routing';
type ChannelViewFilter = 'all' | 'onair' | 'disabled';
const streamingModeBadgeTones: Record<string, 'accent' | 'neutral'> = {
'HLS Direct': 'neutral',
'HLS Segmenter': 'accent',
'MPEG-TS': 'accent',
'MPEG-TS (Legacy)': 'neutral'
};
function stateByChannelId(states: ChannelState[]): Map<number, ChannelState> {
return new Map(states.map((state) => [state.channelId, state]));
}
function sortedChannels(channels: ChannelSummary[]): ChannelSummary[] {
return [...channels].sort((left, right) => left.sortNumber - right.sortNumber || left.number.localeCompare(right.number));
}
function groupedChannels(channels: ChannelSummary[]): Array<{ group: string; rows: ChannelSummary[] }> {
const groups: Array<{ group: string; rows: ChannelSummary[] }> = [];
const indexes = new Map<string, number>();
for (const channel of channels) {
const group = channel.group || 'Ungrouped';
const index = indexes.get(group);
if (index == null) {
indexes.set(group, groups.length);
groups.push({ group, rows: [channel] });
} else {
groups[index].rows.push(channel);
}
}
return groups;
}
// Inlined from App.tsx's Dashboard-owned progressFromNowPlaying during the #244 extraction:
// the Channels domain reuses the identical start/finish/now percentage math, and ChannelState's
// nowPlaying carries the same startUtc/finishUtc shape, so inlining keeps the screen self-contained
// with no cross-import back into App.tsx (behavior-identical).
function progressFromChannelState(state: ChannelState): number | null {
const nowPlaying = state.nowPlaying;
if (!nowPlaying) {
return null;
}
const start = new Date(nowPlaying.startUtc).getTime();
const finish = new Date(nowPlaying.finishUtc).getTime();
const now = Date.now();
if (!Number.isFinite(start) || !Number.isFinite(finish) || finish <= start) {
return null;
}
return Math.round(((now - start) / (finish - start)) * 100);
}
function Marker({ children, title }: { children: ReactNode; title: string }) {
return (
<span className="ctv-channel-marker" title={title}>
{children}
</span>
);
}
function ChannelsLoadingState() {
return (
<Card>
<div className="ctv-dashboard-state">
<Spinner size={20} tone="accent" />
<span>Loading channels</span>
</div>
</Card>
);
}
function ChannelsErrorState({ error, refresh }: { error: string; refresh: () => void }) {
return (
<Card
title={<h2>Channels unavailable</h2>}
subtitle="Live API request failed"
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={14} />} variant="secondary">Retry</Button>}
>
<div className="ctv-dashboard-error">
<span>API request failed</span>
<strong>{error}</strong>
</div>
</Card>
);
}
// Empty lineup (fresh install) must still let the user create their first channel — both the
// library-to-lineup ChannelBuilder flow ("Add Channel") and the bare-create flow ("New blank
// channel"). The bare-create handler is passed in from ChannelsScreen so the defaults (number =
// max+1, which degenerates to 1 on an empty lineup; group "ErsatzTV"; default ffmpeg profile) are
// identical to the action bar's. Regression for #212 (reopened): the old early return rendered a
// dead-end card before the action bar, so a fresh install could never create a channel.
// mutationError is rendered here too: a fresh install may have no default ffmpeg profile, so
// bare-create can 4xx — the non-empty screen shows that alert, and this branch must not swallow it
// (a silent spinner on the one screen that exists to bootstrap the first channel is the worst place
// to hide the failure).
function ChannelsEmptyState({
creatingBlank,
mutationError,
onCreateBlank
}: {
creatingBlank: boolean;
mutationError: string | null;
onCreateBlank: () => void;
}) {
return (
<Card title={<h2>No channels</h2>} subtitle="No channels yet — create your first one.">
<div className="ctv-channels-empty">
<p className="ctv-dashboard-empty">No channels yet</p>
{mutationError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{mutationError}</span>
</div>
)}
<div className="ctv-channels-empty-actions">
<Button
disabled={creatingBlank}
loading={creatingBlank}
onClick={onCreateBlank}
startIcon={<Plus aria-hidden="true" size={14} />}
variant="secondary"
>
New blank channel
</Button>
<Button
startIcon={<Plus aria-hidden="true" size={14} />}
onClick={() => navigateToPath('/app/new-channel')}
>
Add Channel
</Button>
</div>
</div>
</Card>
);
}
export function ChannelsScreen() {
const query = useChannelsScreenQuery();
const [filter, setFilter] = useState<ChannelViewFilter>('all');
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
const [creatingBlank, setCreatingBlank] = useState(false);
if (query.status === 'loading') {
return <ChannelsLoadingState />;
}
if (query.status === 'error') {
return <ChannelsErrorState error={query.error} refresh={query.refresh} />;
}
const channels = sortedChannels(query.data.channels);
const statesById = stateByChannelId(query.data.channelStates);
const onAirCount = query.data.channelStates.filter((state) => state.onAir).length;
const disabledCount = channels.filter((channel) => !channel.isEnabled).length;
const visibleChannels = channels.filter((channel) => {
if (filter === 'onair') {
return statesById.get(channel.id)?.onAir === true;
}
if (filter === 'disabled') {
return !channel.isEnabled;
}
return true;
});
const groups = groupedChannels(visibleChannels);
const visibleIds = visibleChannels.map((channel) => channel.id);
const selectedVisibleCount = visibleIds.filter((id) => selectedIds.has(id)).length;
const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length;
const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected;
const selectedChannels = visibleChannels.filter((channel) => selectedIds.has(channel.id));
const refreshAfterMutation = async (operation: () => Promise<void>) => {
setMutationError(null);
setMutating(true);
try {
await operation();
setSelectedIds(new Set());
query.refresh();
} catch (error: unknown) {
setMutationError(messageFromError(error));
} finally {
setMutating(false);
}
};
const toggleAll = () => {
setSelectedIds((current) => {
const next = new Set(current);
if (allVisibleSelected) {
visibleIds.forEach((id) => next.delete(id));
} else {
visibleIds.forEach((id) => next.add(id));
}
return next;
});
};
const toggleOne = (channelId: number) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(channelId)) {
next.delete(channelId);
} else {
next.add(channelId);
}
return next;
});
};
const moveSelectedToGroup = () => {
const group = window.prompt('Move selected channels to group', selectedChannels[0]?.group ?? '');
if (group == null) {
return;
}
void refreshAfterMutation(() => bulkMoveChannelsToGroup({
channelIds: selectedChannels.map((channel) => channel.id),
group
}));
};
const renumberSelected = () => {
const firstNumber = window.prompt('First channel number', selectedChannels[0]?.number ?? '1');
if (firstNumber == null) {
return;
}
const start = Number.parseFloat(firstNumber);
if (!Number.isFinite(start)) {
setMutationError('First channel number must be numeric');
return;
}
void refreshAfterMutation(() => bulkRenumberChannels({
channels: selectedChannels.map((channel, index) => ({
id: channel.id,
number: formatChannelNumber(start + index)
}))
}));
};
const deleteSelected = () => {
if (!window.confirm(`Delete ${selectedChannels.length} selected channel${selectedChannels.length === 1 ? '' : 's'}?`)) {
return;
}
void refreshAfterMutation(() => bulkDeleteChannels({ channelIds: selectedChannels.map((channel) => channel.id) }));
};
const changeFilter = (nextFilter: ChannelViewFilter) => {
setFilter(nextFilter);
setSelectedIds(new Set());
};
const deleteOne = (channel: ChannelSummary) => {
if (!window.confirm(`Delete ${channel.name}?`)) {
return;
}
void refreshAfterMutation(() => deleteChannel(channel.id));
};
// Blazor parity (ChannelEditor.razor "add" branch): a blank channel is created with these
// defaults, then the editor opens for the rest of the fields. Distinct from "Add Channel",
// which is the library-to-lineup ChannelBuilder flow (untouched here).
const createBlankChannel = async () => {
if (creatingBlank) {
return;
}
setMutationError(null);
setCreatingBlank(true);
try {
const ffmpegSettings = await getFfmpegSettings();
const maxNumber = channels.reduce((max, channel) => {
const parsed = Number.parseInt(channel.number.split('.')[0] ?? '', 10);
return Number.isFinite(parsed) ? Math.max(max, parsed) : max;
}, 0);
const body: CreateChannelRequest = {
categories: null,
fallbackFillerId: null,
ffmpegProfileId: ffmpegSettings.defaultFFmpegProfileId,
group: 'ErsatzTV',
idleBehavior: 'StopOnDisconnect',
isEnabled: true,
logo: { path: null, contentType: null },
mirrorSourceChannelId: null,
musicVideoCreditsMode: 'None',
musicVideoCreditsTemplate: null,
name: 'New Channel',
number: String(maxNumber + 1),
playoutMode: 'Continuous',
playoutOffset: null,
playoutSource: 'Generated',
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
preferredSubtitleLanguageCode: null,
showInEpg: true,
slugSeconds: null,
songVideoMode: 'Default',
streamingMode: 'TransportStreamHybrid',
streamSelector: null,
streamSelectorMode: 'Default',
subtitleMode: 'None',
transcodeMode: 'OnDemand',
watermarkId: null
};
const created = await createChannel(body);
navigateToPath(`/app/edit-channel/${created.id}`);
} catch (error: unknown) {
setMutationError(messageFromError(error));
} finally {
setCreatingBlank(false);
}
};
if (channels.length === 0) {
return (
<ChannelsEmptyState
creatingBlank={creatingBlank}
mutationError={mutationError}
onCreateBlank={() => void createBlankChannel()}
/>
);
}
return (
<div className="ctv-channels-screen">
<div className="ctv-channels-actionbar">
{selectedChannels.length > 0 ? (
<>
<span className="ctv-channels-selected"><code>{selectedChannels.length}</code> selected</span>
<span className="ctv-topbar-divider" />
<Button disabled={mutating} onClick={renumberSelected} size="sm" startIcon={<Hash aria-hidden="true" size={14} />} variant="secondary">Renumber</Button>
<Button disabled={mutating} onClick={moveSelectedToGroup} size="sm" startIcon={<FolderInput aria-hidden="true" size={14} />} variant="secondary">Move to group</Button>
<Button disabled={mutating} onClick={deleteSelected} size="sm" startIcon={<Trash2 aria-hidden="true" size={14} />} variant="danger">Delete selected</Button>
<span className="ctv-channels-spacer" />
<Button disabled={mutating} onClick={() => setSelectedIds(new Set())} size="sm" variant="ghost">Clear</Button>
</>
) : (
<>
<div className="ctv-segmented" role="group" aria-label="Channel view">
<button type="button" aria-pressed={filter === 'all'} onClick={() => changeFilter('all')}>All <code>{channels.length}</code></button>
<button type="button" aria-pressed={filter === 'onair'} onClick={() => changeFilter('onair')}>On air <code>{onAirCount}</code></button>
<button type="button" aria-pressed={filter === 'disabled'} onClick={() => changeFilter('disabled')}>Disabled <code>{disabledCount}</code></button>
</div>
<span className="ctv-channels-spacer" />
<span className="ctv-channels-live"><StatusDot status="live" size={7} /><code>{onAirCount}</code> on air</span>
<Button
disabled={creatingBlank}
loading={creatingBlank}
onClick={() => void createBlankChannel()}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
variant="secondary"
>
New blank channel
</Button>
<Button size="sm" startIcon={<Plus aria-hidden="true" size={14} />} onClick={() => navigateToPath('/app/new-channel')}>Add Channel</Button>
</>
)}
</div>
{mutationError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{mutationError}</span>
</div>
)}
<div className="ctv-channels-table-frame">
<div className="ctv-channels-table-scroll">
<table className="ctv-channels-table" aria-label="Channels lineup">
<thead>
<tr>
<th className="ctv-channel-check"><Checkbox checked={allVisibleSelected} indeterminate={someVisibleSelected} label="Select visible channels" onChange={toggleAll} /></th>
<th>No.</th>
<th>Channel</th>
<th>Now playing</th>
<th>Streaming</th>
<th>FFmpeg</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{groups.map(({ group, rows }) => (
<ChannelGroupRows
channels={rows}
group={group}
key={group}
mutating={mutating}
onDelete={deleteOne}
onToggle={toggleOne}
selectedIds={selectedIds}
statesById={statesById}
/>
))}
</tbody>
</table>
</div>
<div className="ctv-channels-footer">
<span>{visibleChannels.length} of {channels.length} channels</span>
<span><code>{groups.length}</code> groups</span>
</div>
</div>
</div>
);
}
function ChannelGroupRows({
channels,
group,
mutating,
onDelete,
onToggle,
selectedIds,
statesById
}: {
channels: ChannelSummary[];
group: string;
mutating: boolean;
onDelete: (channel: ChannelSummary) => void;
onToggle: (channelId: number) => void;
selectedIds: Set<number>;
statesById: Map<number, ChannelState>;
}) {
return (
<>
<tr className="ctv-channel-group-row">
<td colSpan={7}>
<span><Folder aria-hidden="true" size={13} />{group}<code>{channels.length}</code></span>
</td>
</tr>
{channels.map((channel) => (
<ChannelTableRow
channel={channel}
key={channel.id}
mutating={mutating}
onDelete={onDelete}
onToggle={onToggle}
selected={selectedIds.has(channel.id)}
state={statesById.get(channel.id) ?? null}
/>
))}
</>
);
}
function formatChannelNumber(value: number): string {
return Number.isInteger(value) ? `${value}` : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}
function ChannelTableRow({
channel,
mutating,
onDelete,
onToggle,
selected,
state
}: {
channel: ChannelSummary;
mutating: boolean;
onDelete: (channel: ChannelSummary) => void;
onToggle: (channelId: number) => void;
selected: boolean;
state: ChannelState | null;
}) {
const live = state?.onAir === true;
const dim = !channel.isEnabled;
const progress = state ? progressFromChannelState(state) : null;
const streamingTone = streamingModeBadgeTones[channel.streamingMode] ?? 'neutral';
return (
<tr className={`${live ? 'ctv-channel-row-live ' : ''}${selected ? 'ctv-channel-row-selected ' : ''}${dim ? 'ctv-channel-row-dim' : ''}`}>
<td className="ctv-channel-check">
<Checkbox checked={selected} label={`Select ${channel.name}`} onChange={() => onToggle(channel.id)} />
</td>
<td className="ctv-channel-number">
<span>{live && <StatusDot status="live" size={7} />}<code>{channel.number}</code></span>
</td>
<td>
<div className="ctv-channel-identity">
<ChannelLogo name={channel.name} size={34} />
<div>
<strong>{channel.name}</strong>
<span className="ctv-channel-markers">
{!channel.isEnabled && <Marker title="Disabled">D</Marker>}
{!channel.showInEpg && <Marker title="Hidden from EPG">H</Marker>}
</span>
<small>{channel.language || 'Language unset'}</small>
</div>
</div>
</td>
<td className="ctv-channel-now">
{state?.nowPlaying ? (
<div>
<span>{state.nowPlaying.title}</span>
<ProgressBar height={3} value={progress} />
</div>
) : (
<span>{channel.isEnabled ? 'Idle' : 'Off air'}</span>
)}
</td>
<td><Badge tone={streamingTone}>{channel.streamingMode}</Badge></td>
<td className="ctv-channel-ffmpeg">{channel.ffmpegProfile || 'Unassigned'}</td>
<td>
<div className="ctv-channel-actions">
<IconButton disabled size="sm" title={`Preview unavailable for ${channel.name}`}>
<Play aria-hidden="true" size={15} />
</IconButton>
<IconButton onClick={() => navigateToPath(`/app/edit-channel/${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
<Pencil aria-hidden="true" size={15} />
</IconButton>
<IconButton
onClick={() => navigateToPath(`/app/troubleshooting/playback?channel=${channel.id}`)}
size="sm"
title={`Troubleshoot ${channel.name}`}
>
<Stethoscope aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled={mutating} onClick={() => onDelete(channel)} size="sm" title={`Delete ${channel.name}`}>
<Trash2 aria-hidden="true" size={15} />
</IconButton>
</div>
</td>
</tr>
);
}