diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index d0b4a55ef..d7bdd191b 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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(); 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(() => {})); @@ -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; 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)); }); } diff --git a/web/src/App.tsx b/web/src/App.tsx index 83c503882..fd832dd0d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = { + 'HLS Direct': 'neutral', + 'HLS Segmenter': 'accent', + 'MPEG-TS': 'accent', + 'MPEG-TS (Legacy)': 'neutral' +}; + +function stateByChannelId(states: ChannelState[]): Map { + 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(); + + 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 ( + + {children} + + ); +} + +function ChannelsLoadingState() { + return ( + +
+ + Loading channels +
+
+ ); +} + +function ChannelsErrorState({ error, refresh }: { error: string; refresh: () => void }) { + return ( + Channels unavailable} + subtitle="Live API request failed" + actions={} + > +
+ API request failed + {error} +
+
+ ); +} + +function ChannelsEmptyState() { + return ( + No channels} subtitle="The API returned an empty channel lineup."> +
No channels returned
+
+ ); +} + +function ChannelsScreen() { + const query = useChannelsScreenQuery(); + const [filter, setFilter] = useState('all'); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const [mutationError, setMutationError] = useState(null); + const [mutating, setMutating] = useState(false); + + if (query.status === 'loading') { + return ; + } + + if (query.status === 'error') { + return ; + } + + 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) => { + 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 ; + } + + return ( +
+
+ {selectedChannels.length > 0 ? ( + <> + {selectedChannels.length} selected + + + + + + + + ) : ( + <> +
+ + + +
+ + {onAirCount} on air + + + )} +
+ + {mutationError && ( +
+
+ )} + +
+
+ + + + + + + + + + + + + {groups.map(({ group, rows }) => ( + + ))} + +
No.ChannelNow playingStreamingFFmpeg +
+
+
+ {visibleChannels.length} of {channels.length} channels + {groups.length} groups +
+
+
+ ); +} + +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; + statesById: Map; +}) { + return ( + <> + + + + + + {channels.map((channel) => ( + + ))} + + ); +} + +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 ( + + + onToggle(channel.id)} /> + + + {live && }{channel.number} + + +
+ +
+ {channel.name} + + {!channel.isEnabled && D} + {!channel.showInEpg && H} + + {channel.language || 'Language unset'} +
+
+ + + {state?.nowPlaying ? ( +
+ {state.nowPlaying.title} + +
+ ) : ( + {channel.isEnabled ? 'Idle' : 'Off air'} + )} + + {channel.streamingMode} + {channel.fFmpegProfile || 'Unassigned'} + +
+ + + navigateToPath(`/app/new-channel?edit=${channel.id}`)} size="sm" title={`Edit ${channel.name}`}> + + + + onDelete(channel)} size="sm" title={`Delete ${channel.name}`}> + +
+ + + ); +} + function PlaceholderScreen({ route }: { route: ScreenRoute }) { return (
@@ -799,6 +1231,10 @@ function ScreenContent({ return ; } + if (route.id === 'channels') { + return ; + } + return ; } diff --git a/web/src/api/channels.ts b/web/src/api/channels.ts index 379703918..feec08fe2 100644 --- a/web/src/api/channels.ts +++ b/web/src/api/channels.ts @@ -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 { return request('/api/channels'); } + +export function getChannelStates(): Promise { + return request('/api/channels/state'); +} + +export async function getChannelsScreenData(): Promise { + const [channels, channelStates] = await Promise.all([ + getChannels(), + getChannelStates() + ]); + + return { channels, channelStates }; +} + +export function bulkRenumberChannels(body: BulkRenumberChannelsRequest): Promise { + return request('/api/channels/bulk/renumber', { + body, + method: 'POST' + }); +} + +export function bulkMoveChannelsToGroup(body: BulkMoveChannelsToGroupRequest): Promise { + return request('/api/channels/bulk/group', { + body, + method: 'POST' + }); +} + +export function bulkDeleteChannels(body: BulkDeleteChannelsRequest): Promise { + return request('/api/channels/bulk/delete', { + body, + method: 'POST' + }); +} + +export function deleteChannel(channelId: number): Promise { + return request(`/api/channels/${channelId}`, { + method: 'DELETE' + }); +} + +export function useChannelsScreenQuery(pollMs = 30000): ChannelsScreenQueryState { + const [state, setState] = useState({ + 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'; +} diff --git a/web/src/shell.css b/web/src/shell.css index fcedceadd..e1df48878 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -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; + } }