@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user