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

This commit was merged in pull request #124.
This commit is contained in:
2026-07-05 09:26:22 +00:00
4 changed files with 1306 additions and 4 deletions
+402 -3
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { App } from './App';
import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Toast, Tooltip } from './components';
@@ -11,6 +11,7 @@ import {
describe('ChicoryTV SPA scaffold', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
window.history.replaceState(null, '', '/');
});
@@ -35,13 +36,13 @@ describe('ChicoryTV SPA scaffold', () => {
expect(designSystemStylesheet).toBe('../../design-system/styles.css');
});
it('routes between shell screen slots without a page reload', () => {
it('routes between shell screen slots without a page reload', async () => {
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(screen.getByRole('heading', { name: 'Channels' })).toBeInTheDocument();
expect(screen.getByText('Channel list workspace')).toBeInTheDocument();
expect(await screen.findByText('No channels returned')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Channels' })).toHaveAttribute(
'aria-current',
'page'
@@ -271,6 +272,382 @@ 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 () => {
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: {
finishUtc: '2026-07-04T21:30:00Z',
startUtc: '2026-07-04T21:00:00Z',
title: 'Saturday Morning Cartoons'
}
},
{
channelId: 2,
channelNumber: '24',
onAir: false,
nowPlaying: null
}
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByRole('heading', { name: 'Channels' })).toBeInTheDocument();
expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument();
expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.getByText('Saturday Morning Cartoons')).toBeInTheDocument();
expect(screen.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.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('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('shows the dashboard loading state while requests are pending', async () => {
vi.spyOn(window, 'fetch').mockImplementation(() => new Promise<Response>(() => {}));
@@ -428,18 +805,27 @@ function jsonResponse(body: unknown, status = 200): Response {
function mockDashboardApi({
channels = [],
channelStates = [],
confirm = false,
health = [],
mediaSources = [],
mutationFailures = {},
playouts = { page: [], totalCount: 0 },
prompt = null,
version = { apiVersion: 3, appVersion: '26.4.0' }
}: {
channels?: unknown[];
channelStates?: unknown[];
confirm?: boolean;
health?: unknown[];
mediaSources?: unknown[];
mutationFailures?: Record<string, unknown>;
playouts?: unknown;
prompt?: string | null;
version?: unknown;
} = {}) {
vi.spyOn(window, 'confirm').mockReturnValue(confirm);
vi.spyOn(window, 'prompt').mockReturnValue(prompt);
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const path = input.toString();
@@ -467,6 +853,19 @@ function mockDashboardApi({
return Promise.resolve(jsonResponse(version));
}
if (path in mutationFailures) {
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
}
if (
path === '/api/channels/bulk/renumber' ||
path === '/api/channels/bulk/group' ||
path === '/api/channels/bulk/delete' ||
path.startsWith('/api/channels/')
) {
return Promise.resolve(new Response(null, { status: 204 }));
}
return Promise.resolve(jsonResponse(null, 404));
});
}
+436
View File
@@ -15,17 +15,24 @@ import {
ChevronDown,
CircleHelp,
ClipboardCopy,
Folder,
FolderInput,
FolderTree,
Hash,
Info,
LayoutDashboard,
LayoutGrid,
Library,
ListVideo,
Plus,
Pencil,
Play,
Radio,
RefreshCw,
Search,
Settings,
Stethoscope,
Trash2,
TriangleAlert,
Tv,
} from 'lucide-react';
@@ -34,6 +41,7 @@ import {
Badge,
Button,
Card,
Checkbox,
ChannelLogo,
IconButton,
NavItem,
@@ -45,9 +53,17 @@ import {
} from './components';
import {
useChannelsQuery,
useChannelsScreenQuery,
bulkDeleteChannels,
bulkMoveChannelsToGroup,
bulkRenumberChannels,
deleteChannel,
messageFromError,
useDashboardHealthQuery,
useDashboardQuery,
useDashboardVersionQuery,
type ChannelState,
type ChannelSummary,
type DashboardChannel,
type DashboardChannelState,
type DashboardHealthQueryState
@@ -213,6 +229,11 @@ function routeHref(route: ScreenRoute): string {
return route.id === 'dashboard' ? '/app' : route.path;
}
function navigateToPath(path: string) {
window.history.pushState(null, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function SidebarNavGroup({
activeRoute,
ids,
@@ -739,6 +760,417 @@ 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>
);
}
function ChannelsEmptyState() {
return (
<Card title={<h2>No channels</h2>} subtitle="The API returned an empty channel lineup.">
<div className="ctv-dashboard-empty">No channels returned</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);
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));
};
if (channels.length === 0) {
return <ChannelsEmptyState />;
}
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 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/new-channel?edit=${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
<Pencil aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled 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 PlaceholderScreen({ route }: { route: ScreenRoute }) {
return (
<div className="ctv-screen-stack">
@@ -799,6 +1231,10 @@ function ScreenContent({
return <DashboardScreen healthState={healthState} />;
}
if (route.id === 'channels') {
return <ChannelsScreen />;
}
return <PlaceholderScreen route={route} />;
}
+169 -1
View File
@@ -1,8 +1,176 @@
import { request } from './client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
export interface ChannelsScreenData {
channels: ChannelSummary[];
channelStates: ChannelState[];
}
export type ChannelsScreenQueryState =
| { data: ChannelsScreenData; error: null; refresh: () => void; status: 'success' }
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type ChannelsScreenState =
| { data: ChannelsScreenData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getChannels(): Promise<ChannelSummary[]> {
return request<ChannelSummary[]>('/api/channels');
}
export function getChannelStates(): Promise<ChannelState[]> {
return request<ChannelState[]>('/api/channels/state');
}
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
const [channels, channelStates] = await Promise.all([
getChannels(),
getChannelStates()
]);
return { channels, channelStates };
}
export function bulkRenumberChannels(body: BulkRenumberChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/renumber', {
body,
method: 'POST'
});
}
export function bulkMoveChannelsToGroup(body: BulkMoveChannelsToGroupRequest): Promise<void> {
return request<void>('/api/channels/bulk/group', {
body,
method: 'POST'
});
}
export function bulkDeleteChannels(body: BulkDeleteChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/delete', {
body,
method: 'POST'
});
}
export function deleteChannel(channelId: number): Promise<void> {
return request<void>(`/api/channels/${channelId}`, {
method: 'DELETE'
});
}
export function useChannelsScreenQuery(pollMs = 30000): ChannelsScreenQueryState {
const [state, setState] = useState<ChannelsScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback((showLoading = true) => {
if (showLoading) {
setState({ data: null, error: null, status: 'loading' });
}
getChannelsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const loadChannelStates = useCallback(() => {
getChannelStates()
.then((channelStates) => {
if (activeRef.current) {
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
status: 'success'
};
});
}
})
.catch(() => {
// Keep the current table visible on background polling failures.
});
}, []);
useEffect(() => {
const loadForEffect = () => {
getChannelsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
};
loadForEffect();
const intervalId = window.setInterval(() => {
loadChannelStates();
}, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [loadChannelStates, pollMs]);
const refresh = useCallback(() => {
load();
}, [load]);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, status: 'success' };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
export function messageFromError(error: unknown): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return 'Unable to load channels';
}
+299
View File
@@ -703,6 +703,287 @@
text-transform: uppercase;
}
.ctv-channels-screen {
display: grid;
gap: var(--space-5, 10px);
}
.ctv-channels-actionbar {
min-height: 58px;
display: flex;
align-items: center;
gap: var(--space-5, 10px);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
padding: var(--space-5, 10px) var(--space-7, 16px);
}
.ctv-segmented {
display: inline-flex;
gap: 2px;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm, 5px);
background: var(--ctv-bg-sunken);
padding: 2px;
}
.ctv-segmented button {
height: 26px;
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
border: 0;
border-radius: var(--radius-xs, 3px);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
padding: 0 11px;
font: inherit;
font-size: var(--text-xs, 12px);
}
.ctv-segmented button[aria-pressed="true"] {
background: var(--ctv-surface-3);
color: var(--text-primary);
font-weight: var(--weight-medium, 500);
}
.ctv-segmented code,
.ctv-channels-selected code,
.ctv-channels-live code,
.ctv-channels-footer code,
.ctv-channel-group-row code,
.ctv-channel-number code {
font-family: var(--font-mono, ui-monospace, monospace);
font-variant-numeric: tabular-nums;
}
.ctv-channels-selected,
.ctv-channels-live {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
}
.ctv-channels-selected {
color: var(--text-primary);
font-size: var(--text-sm, 13px);
font-weight: var(--weight-medium, 500);
}
.ctv-channels-spacer {
flex: 1;
}
.ctv-channels-error {
display: flex;
align-items: center;
gap: var(--space-4, 8px);
border: 1px solid color-mix(in srgb, var(--status-error) 32%, transparent);
border-radius: var(--radius-md, 7px);
background: var(--ctv-error-soft);
color: var(--status-error);
padding: var(--space-5, 10px) var(--space-6, 12px);
font-size: var(--text-sm, 13px);
font-weight: var(--weight-medium, 500);
}
.ctv-channels-table-frame {
overflow: hidden;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
}
.ctv-channels-table-scroll {
overflow: auto;
}
.ctv-channels-table {
width: 100%;
min-width: 920px;
border-collapse: collapse;
}
.ctv-channels-table th {
position: sticky;
top: 0;
z-index: 2;
height: 34px;
border-bottom: 1px solid var(--border-hairline);
background: var(--surface-card);
color: var(--text-disabled);
padding: 0 var(--pad-cell-x, 16px);
text-align: left;
white-space: nowrap;
font-size: var(--text-2xs, 11px);
font-weight: var(--weight-medium, 500);
letter-spacing: var(--tracking-caps, 0.06em);
text-transform: uppercase;
}
.ctv-channels-table td {
height: 56px;
border-top: 1px solid var(--border-hairline);
color: var(--text-primary);
padding: 0 var(--pad-cell-x, 16px);
vertical-align: middle;
}
.ctv-channel-check {
width: 38px;
padding-left: 14px !important;
}
.ctv-channel-group-row td {
height: 32px;
border-top: 1px solid var(--border-hairline);
border-bottom: 1px solid var(--border-hairline);
background: var(--ctv-bg-sunken);
}
.ctv-channel-group-row span {
display: inline-flex;
align-items: center;
gap: var(--space-4, 8px);
color: var(--text-secondary);
font-size: var(--text-2xs, 11px);
font-weight: var(--weight-semibold, 600);
letter-spacing: var(--tracking-caps, 0.06em);
text-transform: uppercase;
}
.ctv-channel-group-row svg {
color: var(--text-disabled);
}
.ctv-channel-row-live {
background: var(--ctv-live-soft);
box-shadow: inset 2px 0 0 var(--status-live);
}
.ctv-channel-row-selected {
background: var(--ctv-accent-soft);
}
.ctv-channel-row-dim td:not(:last-child) {
opacity: 0.58;
}
.ctv-channel-number {
width: 74px;
}
.ctv-channel-number span {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
color: var(--status-live);
}
.ctv-channel-identity {
min-width: 220px;
display: flex;
align-items: center;
gap: 11px;
}
.ctv-channel-identity > div {
min-width: 0;
display: grid;
grid-template-columns: auto auto;
align-items: center;
gap: 3px var(--space-4, 8px);
}
.ctv-channel-identity strong {
overflow: hidden;
color: var(--text-primary);
font-weight: var(--weight-medium, 500);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-channel-identity small {
grid-column: 1 / -1;
color: var(--text-disabled);
font-size: var(--text-2xs, 11px);
line-height: 1;
}
.ctv-channel-markers {
display: inline-flex;
gap: var(--space-2, 4px);
}
.ctv-channel-marker {
min-width: 15px;
height: 15px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-control);
border-radius: var(--radius-xs, 3px);
background: var(--ctv-surface-3);
color: var(--text-disabled);
padding: 0 3px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 9px;
font-weight: var(--weight-semibold, 600);
line-height: 1;
}
.ctv-channel-now {
width: 280px;
}
.ctv-channel-now > div {
max-width: 250px;
display: grid;
gap: var(--space-3, 6px);
}
.ctv-channel-now span {
overflow: hidden;
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-channel-now > span {
color: var(--text-faint);
}
.ctv-channel-ffmpeg {
width: 140px;
color: var(--text-secondary) !important;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: var(--text-xs, 12px);
}
.ctv-channel-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
}
.ctv-channels-footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--border-hairline);
color: var(--text-secondary);
padding: var(--space-5, 10px) var(--space-7, 16px);
font-size: var(--text-xs, 12px);
}
@media (max-width: 980px) {
.ctv-app-shell {
grid-template-columns: 1fr;
@@ -746,4 +1027,22 @@
.ctv-slot-grid {
grid-template-columns: 1fr;
}
.ctv-channels-actionbar {
align-items: flex-start;
flex-wrap: wrap;
}
.ctv-channels-spacer {
display: none;
}
.ctv-segmented {
width: 100%;
}
.ctv-segmented button {
flex: 1;
justify-content: center;
}
}