This commit was merged in pull request #124.
This commit is contained in:
+402
-3
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user