+ |
+ 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;
+ }
}