Files
ersatztv/web/src/App.tsx
T
timothyandClaude Fable 5 1f3385a2ff
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(web): review fixes — EPG switch gating, edit route key, stronger PUT assertion (#146)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:32:29 +02:00

2935 lines
96 KiB
TypeScript

import {
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
type KeyboardEvent,
type MouseEvent,
type ReactNode
} from 'react';
import {
ArrowDownWideNarrow,
Bell,
CalendarClock,
Cast,
Check,
ChevronDown,
Clapperboard,
CircleHelp,
Clock,
ClipboardCopy,
Copy,
Crosshair,
Film,
FileImage,
Folder,
FolderInput,
FolderTree,
GripVertical,
Hash,
HardDrive,
Info,
LayoutDashboard,
LayoutGrid,
Library,
ListVideo,
MonitorPlay,
Music,
Plus,
Pencil,
Play,
Radio,
RefreshCw,
Search,
Server,
Settings,
Shuffle,
Sparkles,
Stethoscope,
Timer,
Trash2,
TriangleAlert,
Tv
} from 'lucide-react';
import chicoryMarkUrl from '../../design-system/assets/chicory-mark.svg';
import { ChannelBuilderScreen } from './builder/ChannelBuilder';
import { ChannelEditScreen } from './screens/ChannelEditScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { navigateToPath } from './routing';
import {
Badge,
Button,
Card,
Checkbox,
ChannelLogo,
IconButton,
Input,
NavItem,
NavSection,
ProgressBar,
Select,
Spinner,
Stat,
StatusDot,
Switch,
Tabs
} from './components';
import {
useChannelsQuery,
useChannelsScreenQuery,
bulkDeleteChannels,
bulkMoveChannelsToGroup,
bulkRenumberChannels,
deleteChannel,
messageFromError,
addScheduleItem,
deleteScheduleItem,
resetAllPlayouts,
replaceScheduleItems,
usePlayoutsScreenQuery,
useScheduleScreenQuery,
useDashboardHealthQuery,
useDashboardQuery,
useDashboardVersionQuery,
defaultGuideWindowStart,
GUIDE_WINDOW_MS,
useGuideScreenQuery,
useLibrariesScreenQuery,
type ChannelState,
type ChannelSummary,
type ChannelGuideChannel,
type ChannelGuideProgramme,
type DashboardChannel,
type SchedulePickerData,
type DashboardChannelState,
type DashboardHealthQueryState,
type LibraryScanStatus,
type MediaSource,
type MediaSourceLibrary,
type MediaCollection,
type ProgramSchedule,
type ProgramScheduleItem,
type PlayoutItem,
type PlayoutSummary,
type ScheduleItemRequest
} from './api';
import {
applyDesignSystemTheme,
designSystemThemes,
getStoredDesignSystemTheme,
type DesignSystemThemeId
} from './designSystem';
type ScreenId =
| 'dashboard'
| 'channels'
| 'builder'
| 'editChannel'
| 'guide'
| 'schedules'
| 'playouts'
| 'collections'
| 'libraries'
| 'settings';
interface ScreenRoute {
id: ScreenId;
path: string;
label: string;
title: string;
kicker: string;
description: string;
icon: ReactNode;
primaryAction: string;
placeholder: string;
badge?: number;
// Screens that own linkable sub-sections (e.g. /app/settings/streaming) opt in here;
// the sub-path is otherwise unowned and 404s (see routeFromLocation).
allowSubPaths?: boolean;
}
const routes: ScreenRoute[] = [
{
id: 'dashboard',
path: '/app',
label: 'Dashboard',
title: 'Dashboard',
kicker: 'Overview',
description: 'On-air status, health checks, and recent server activity.',
icon: <LayoutDashboard aria-hidden="true" size={16} />,
primaryAction: 'Add Channel',
placeholder: 'Dashboard workspace'
},
{
id: 'channels',
path: '/app/channels',
label: 'Channels',
title: 'Channels',
kicker: 'Lineup',
description: 'Dense channel table, ordering tools, bulk actions, and playback status.',
icon: <Tv aria-hidden="true" size={16} />,
primaryAction: 'Add Channel',
placeholder: 'Channel list workspace'
},
{
id: 'builder',
path: '/app/new-channel',
label: 'New Channel',
title: 'New Channel',
kicker: 'Builder',
description: 'Library-to-lineup channel creation flow with template-backed defaults.',
icon: <Plus aria-hidden="true" size={16} />,
primaryAction: 'Create Channel',
placeholder: 'Channel builder workspace'
},
{
// Not in the sidebar nav; reached via the edit pencil on the channel table. The
// screen owns parsing the {id} suffix (see ChannelEditScreen), so it opts into
// sub-paths like /app/edit-channel/5.
id: 'editChannel',
path: '/app/edit-channel',
label: 'Edit Channel',
title: 'Edit Channel',
kicker: 'Channel',
description: 'Full channel editor: identity, playout, streaming, selection and branding.',
icon: <Tv aria-hidden="true" size={16} />,
primaryAction: 'Save Changes',
placeholder: 'Channel editor workspace',
allowSubPaths: true
},
{
id: 'guide',
path: '/app/guide',
label: 'Guide',
title: 'Guide',
kicker: 'EPG',
description: 'Timeline grid with channel rows, programme blocks, and a live now marker.',
icon: <LayoutGrid aria-hidden="true" size={16} />,
primaryAction: 'Jump To Now',
placeholder: 'EPG grid workspace'
},
{
id: 'schedules',
path: '/app/schedules',
label: 'Schedules',
title: 'Schedules',
kicker: 'Programming',
description: 'Two-pane schedule editor with drag ordering and progressive details.',
icon: <CalendarClock aria-hidden="true" size={16} />,
primaryAction: 'Add Schedule',
placeholder: 'Schedule editor workspace'
},
{
id: 'playouts',
path: '/app/playouts',
label: 'Playouts',
title: 'Playouts',
kicker: 'Runtime',
description: 'Playout state, reset controls, timeline diagnostics, and build warnings.',
icon: <ListVideo aria-hidden="true" size={16} />,
primaryAction: 'Reset All',
placeholder: 'Playouts workspace',
badge: 3
},
{
id: 'collections',
path: '/app/collections',
label: 'Collections',
title: 'Collections',
kicker: 'Media',
description: 'Manual, smart, multi, playlist, search, and rerun collection management.',
icon: <FolderTree aria-hidden="true" size={16} />,
primaryAction: 'Add Collection',
placeholder: 'Collections workspace'
},
{
id: 'libraries',
path: '/app/libraries',
label: 'Libraries',
title: 'Libraries',
kicker: 'Sources',
description: 'Local, Plex, Jellyfin, and Emby media source monitoring.',
icon: <Library aria-hidden="true" size={16} />,
primaryAction: 'Scan',
placeholder: 'Libraries workspace'
},
{
id: 'settings',
path: '/app/settings',
label: 'Settings',
title: 'Settings',
kicker: 'System',
description: 'Server configuration, access keys, FFmpeg profiles, and UI preferences.',
icon: <Settings aria-hidden="true" size={16} />,
primaryAction: 'Save Changes',
placeholder: 'Settings workspace',
allowSubPaths: true
}
];
const routeById = new Map(routes.map((route) => [route.id, route]));
const primaryNavIds: ScreenId[] = [
'dashboard',
'channels',
'builder',
'guide',
'schedules',
'playouts'
];
const mediaNavIds: ScreenId[] = ['collections', 'libraries'];
const systemNavIds: ScreenId[] = ['settings'];
function normalizePath(pathname: string): string {
return pathname.replace(/\/+$/, '') || '/app';
}
function routeFromLocation(): ScreenRoute | null {
const pathname = normalizePath(window.location.pathname);
const exactMatch = routes.find((route) => route.path === pathname);
if (exactMatch) {
return exactMatch;
}
// Fall back to a sub-path of a route that has explicitly opted into linkable
// sub-sections (e.g. /app/settings/streaming) - the screen owns parsing the
// suffix itself (see SettingsScreen).
const prefixMatches = routes
.filter((route) => route.allowSubPaths && pathname.startsWith(`${route.path}/`))
.sort((a, b) => b.path.length - a.path.length);
return prefixMatches[0] ?? null;
}
function routeHref(route: ScreenRoute): string {
return route.id === 'dashboard' ? '/app' : route.path;
}
function SidebarNavGroup({
activeRoute,
ids,
onNavigate
}: {
activeRoute: ScreenRoute | null;
ids: ScreenId[];
onNavigate: (route: ScreenRoute, event: MouseEvent) => void;
}) {
return (
<>
{ids.map((id) => {
const route = routeById.get(id)!;
return (
<NavItem
key={route.id}
icon={route.icon}
label={route.label}
active={route.id === activeRoute?.id}
badge={route.badge}
badgeTone="warn"
href={routeHref(route)}
onClick={(event) => onNavigate(route, event)}
/>
);
})}
</>
);
}
function ThemeSwitcher({
theme,
onThemeChange
}: {
theme: DesignSystemThemeId;
onThemeChange: (theme: DesignSystemThemeId) => void;
}) {
return (
<section className="ctv-theme-switcher" aria-label="Theme">
<span>Theme</span>
<div>
{designSystemThemes.map(({ id, description, label }) => (
<button
key={id}
type="button"
className={`ctv-theme-swatch ctv-theme-swatch-${id}`}
aria-label={description}
aria-pressed={theme === id}
title={label}
onClick={() => onThemeChange(id)}
/>
))}
</div>
</section>
);
}
function Sidebar({
activeRoute,
healthState,
onNavigate
}: {
activeRoute: ScreenRoute | null;
healthState: DashboardHealthQueryState;
onNavigate: (route: ScreenRoute, event: MouseEvent) => void;
}) {
const healthSummary = summarizeHealth(healthState);
return (
<aside className="ctv-sidebar">
<div className="ctv-brand">
<img src={chicoryMarkUrl} alt="ChicoryTV" />
<span className="ctv-brand-wordmark">
Chicory<span>TV</span>
</span>
</div>
<nav aria-label="Primary" className="ctv-nav">
<SidebarNavGroup activeRoute={activeRoute} ids={primaryNavIds} onNavigate={onNavigate} />
<NavSection>Media</NavSection>
<SidebarNavGroup activeRoute={activeRoute} ids={mediaNavIds} onNavigate={onNavigate} />
<NavSection>System</NavSection>
<SidebarNavGroup activeRoute={activeRoute} ids={systemNavIds} onNavigate={onNavigate} />
</nav>
<div className="ctv-sidebar-health">
<div>
<span>ChicoryTV</span>
<SidebarVersion />
</div>
<StatusDot status={healthSummary.status} label={healthSummary.label} />
</div>
</aside>
);
}
function SidebarVersion() {
const versionQuery = useDashboardVersionQuery();
if (versionQuery.status === 'success') {
return <code>{versionQuery.version.appVersion ?? 'version unknown'}</code>;
}
if (versionQuery.status === 'error') {
return <code>version unavailable</code>;
}
return <code>loading version</code>;
}
function EndpointRow({ icon, label, url }: { icon: ReactNode; label: string; url: string }) {
const [copied, setCopied] = useState(false);
const copyUrl = () => {
void navigator.clipboard?.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
return (
<div className="ctv-connect-endpoint">
<span className="ctv-connect-endpoint-icon">{icon}</span>
<div>
<strong>{label}</strong>
<code>{url}</code>
</div>
<button type="button" onClick={copyUrl}>
{copied ? <Check aria-hidden="true" size={13} /> : <ClipboardCopy aria-hidden="true" size={13} />}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
);
}
function ConnectMenu() {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const channelsQuery = useChannelsQuery();
const playlistUrl = `${window.location.origin}/iptv/channels.m3u`;
const guideUrl = `${window.location.origin}/iptv/xmltv.xml`;
useEffect(() => {
if (open) {
menuRef.current?.focus();
}
}, [open]);
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
const channelCountLabel = (() => {
if (channelsQuery.status === 'loading') {
return 'Loading channels';
}
if (channelsQuery.status === 'error') {
return 'Channels unavailable';
}
const count = channelsQuery.channels.length;
return `${count} ${count === 1 ? 'channel' : 'channels'}`;
})();
return (
<div className="ctv-connect">
<button
type="button"
className="ctv-connect-button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<Cast aria-hidden="true" size={15} />
Connect
<ChevronDown aria-hidden="true" size={13} />
</button>
{open && (
<>
<button
type="button"
className="ctv-connect-dismiss"
aria-label="Close Connect menu"
onClick={() => setOpen(false)}
/>
<div
className="ctv-connect-menu"
role="dialog"
aria-label="Connect a player"
tabIndex={-1}
ref={menuRef}
onKeyDown={onMenuKeyDown}
>
<div className="ctv-connect-header">
<strong>Connect a player</strong>
<span>{channelCountLabel}</span>
</div>
<EndpointRow
icon={<ListVideo aria-hidden="true" size={15} />}
label="M3U playlist"
url={playlistUrl}
/>
<EndpointRow
icon={<CalendarClock aria-hidden="true" size={15} />}
label="XMLTV guide"
url={guideUrl}
/>
<p>
<Info aria-hidden="true" size={13} />
Works with any IPTV player.
</p>
</div>
</>
)}
</div>
);
}
function TopBar({ route }: { route: ScreenRoute | null }) {
const title = route?.title ?? 'Page not found';
const kicker = route?.kicker ?? 'Unknown route';
const primaryAction = route?.primaryAction ?? 'Go To Dashboard';
return (
<header className="ctv-topbar">
<div className="ctv-topbar-title">
<p>{kicker}</p>
<h1>{title}</h1>
</div>
<label className="ctv-topbar-search">
<Search aria-hidden="true" size={15} />
<input type="search" aria-label="Search" placeholder="Search channels, schedules..." />
</label>
<div className="ctv-topbar-spacer" />
<div className="ctv-topbar-tools">
<ConnectMenu />
<span className="ctv-topbar-divider" />
<IconButton title="Documentation" size="sm">
<CircleHelp aria-hidden="true" size={17} />
</IconButton>
<IconButton title="Notifications" size="sm">
<Bell aria-hidden="true" size={17} />
</IconButton>
<span className="ctv-avatar">TB</span>
</div>
<Button startIcon={<Plus aria-hidden="true" size={15} />}>{primaryAction}</Button>
</header>
);
}
type HealthStatus = 'error' | 'idle' | 'live' | 'ok' | 'warn';
function summarizeHealth(healthState: DashboardHealthQueryState): { label: string; status: HealthStatus } {
if (healthState.status === 'loading') {
return { label: 'Checking', status: 'idle' };
}
if (healthState.status === 'error') {
return { label: 'Health unavailable', status: 'error' };
}
const failedCount = healthState.checks.filter((check) => isErrorHealthStatus(check.status)).length;
const warningCount = healthState.checks.filter((check) => isWarningHealthStatus(check.status)).length;
if (failedCount > 0) {
return { label: `${failedCount} failing`, status: 'error' };
}
if (warningCount > 0) {
return { label: `${warningCount} warning${warningCount === 1 ? '' : 's'}`, status: 'warn' };
}
return { label: 'Healthy', status: 'ok' };
}
// The backend serializes health check status as exactly 'pass' | 'fail' | 'warn' | 'info'
// (see ErsatzTV.Application/Health/Mapper.cs GetStatus).
function isWarningHealthStatus(status: string): boolean {
return status.toLowerCase() === 'warn';
}
function isErrorHealthStatus(status: string): boolean {
return status.toLowerCase() === 'fail';
}
function isInfoHealthStatus(status: string): boolean {
return status.toLowerCase() === 'info';
}
function healthIconStatus(status: string): HealthStatus {
if (isErrorHealthStatus(status)) {
return 'error';
}
if (isWarningHealthStatus(status)) {
return 'warn';
}
if (isInfoHealthStatus(status)) {
return 'idle';
}
return 'ok';
}
function healthIcon(status: string): ReactNode {
const iconStatus = healthIconStatus(status);
if (iconStatus === 'error') {
return <TriangleAlert aria-hidden="true" size={15} />;
}
if (iconStatus === 'warn') {
return <TriangleAlert aria-hidden="true" size={15} />;
}
if (iconStatus === 'idle') {
return <Info aria-hidden="true" size={15} />;
}
return <Check aria-hidden="true" size={15} />;
}
function progressFromNowPlaying(nowPlaying: NonNullable<DashboardChannelState['nowPlaying']>): number | null {
const start = new Date(nowPlaying.startUtc).getTime();
const finish = new Date(nowPlaying.finishUtc).getTime();
const now = Date.now();
if (!Number.isFinite(start) || !Number.isFinite(finish) || finish <= start) {
return null;
}
return Math.round(((now - start) / (finish - start)) * 100);
}
function minutesUntil(value: string): number | null {
const finish = new Date(value).getTime();
if (!Number.isFinite(finish)) {
return null;
}
return Math.max(0, Math.ceil((finish - Date.now()) / 60000));
}
function OnAirCard({ channel, state }: { channel: DashboardChannel | null; state: DashboardChannelState }) {
const name = channel?.name ?? 'Unnamed channel';
const number = state.channelNumber || channel?.number || `${state.channelId}`;
const nowPlaying = state.nowPlaying;
const progress = nowPlaying ? progressFromNowPlaying(nowPlaying) : null;
const remaining = nowPlaying ? minutesUntil(nowPlaying.finishUtc) : null;
return (
<div className="ctv-onair-card">
<div className="ctv-onair-head">
<ChannelLogo name={name} size={34} />
<div>
<code>{number}</code>
<strong>{name}</strong>
</div>
<Badge tone="accent" dot>
On air
</Badge>
</div>
<p>{nowPlaying?.title ?? 'Now-playing data unavailable'}</p>
<ProgressBar value={progress} />
<div className="ctv-onair-meta">
<span>{progress == null ? 'Progress unavailable' : `${Math.max(0, Math.min(100, progress))}% elapsed`}</span>
<span>{remaining == null ? 'Finish unavailable' : `${remaining}m to next`}</span>
</div>
</div>
);
}
function DashboardLoadingState() {
return (
<Card>
<div className="ctv-dashboard-state">
<Spinner size={20} tone="accent" />
<span>Loading dashboard</span>
</div>
</Card>
);
}
function DashboardErrorState({ error }: { error: string }) {
return (
<Card title={<h2>Dashboard unavailable</h2>} subtitle="Live API request failed">
<div className="ctv-dashboard-error">
<span>API request failed</span>
<strong>{error}</strong>
</div>
</Card>
);
}
function HealthPanel({ healthState }: { healthState: DashboardHealthQueryState }) {
const summary = summarizeHealth(healthState);
return (
<Card
title={<h2>System health</h2>}
subtitle="Backend health checks"
actions={
<Button
onClick={healthState.refresh}
loading={healthState.status === 'loading'}
startIcon={<RefreshCw aria-hidden="true" size={14} />}
variant="secondary"
>
Refresh health
</Button>
}
padded={false}
>
<div className="ctv-health-panel">
{healthState.status === 'loading' && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-idle">
<Spinner size={15} tone="muted" />
</span>
<strong>Health checks</strong>
<span>Loading current health</span>
<StatusDot status="idle" />
</div>
)}
{healthState.status === 'error' && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-error">
<TriangleAlert aria-hidden="true" size={15} />
</span>
<strong>Health checks</strong>
<span>{healthState.error}</span>
<StatusDot status="error" />
</div>
)}
{healthState.status === 'success' && healthState.checks.length === 0 && (
<div className="ctv-health-row">
<span className="ctv-health-icon ctv-health-icon-idle">
<Info aria-hidden="true" size={15} />
</span>
<strong>Health checks</strong>
<span>No health checks returned</span>
<StatusDot status="idle" />
</div>
)}
{healthState.status === 'success' && healthState.checks.map((check) => {
const rowStatus = healthIconStatus(check.status);
return (
<div className="ctv-health-row" key={check.title}>
<span className={`ctv-health-icon ctv-health-icon-${rowStatus}`}>{healthIcon(check.status)}</span>
<strong>{check.title}</strong>
<span>{check.detail}</span>
<StatusDot status={rowStatus} />
</div>
);
})}
</div>
<div className="ctv-health-summary">
<StatusDot status={summary.status} label={summary.label} />
</div>
</Card>
);
}
function DashboardScreen({
healthState
}: {
healthState: DashboardHealthQueryState;
}) {
const dashboardQuery = useDashboardQuery();
if (dashboardQuery.status === 'loading') {
return <DashboardLoadingState />;
}
if (dashboardQuery.status === 'error') {
return <DashboardErrorState error={dashboardQuery.error} />;
}
const { channels, channelStates, mediaSources, playouts } = dashboardQuery.data;
const channelsById = new Map(channels.map((channel) => [channel.id, channel]));
const onAirStates = channelStates.filter((state) => state.onAir).slice(0, 4);
const playoutCount = playouts.totalCount;
const libraryCount = mediaSources.reduce((count, source) => count + source.libraries.length, 0);
return (
<div className="ctv-screen-stack">
<section className="ctv-stat-row" aria-label="At a glance">
<Stat label="Channels" value={channels.length} icon={<Tv aria-hidden="true" size={15} />} />
<Stat label="Active playouts" value={playoutCount} icon={<ListVideo aria-hidden="true" size={15} />} />
<Stat label="On air" value={onAirStates.length} icon={<Radio aria-hidden="true" size={15} />} />
<Stat label="Libraries" value={libraryCount} icon={<Library aria-hidden="true" size={15} />} />
</section>
<section className="ctv-dashboard-grid">
<Card
title={<h2>On air now</h2>}
subtitle="Current programmes by channel"
actions={<Badge tone="accent" dot>{onAirStates.length} on air</Badge>}
>
<div className="ctv-onair-grid">
{onAirStates.length > 0 ? (
onAirStates.map((state) => (
<OnAirCard channel={channelsById.get(state.channelId) ?? null} state={state} key={state.channelId} />
))
) : (
<div className="ctv-dashboard-empty">No on-air channels reported</div>
)}
</div>
</Card>
<HealthPanel healthState={healthState} />
</section>
</div>
);
}
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/edit-channel/${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>
);
}
type ScheduleInspectorTab = 'content' | 'playback' | 'filler' | 'overrides';
function SchedulesLoadingState() {
return (
<Card>
<div className="ctv-dashboard-state">
<Spinner size={20} tone="accent" />
<span>Loading schedules</span>
</div>
</Card>
);
}
function SchedulesErrorState({ error, refresh }: { error: string; refresh: () => void }) {
return (
<Card
title={<h2>Schedules 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 SchedulesEmptyState() {
return (
<Card title={<h2>No schedules</h2>} subtitle="The API returned an empty schedule list.">
<div className="ctv-dashboard-empty">No schedules returned</div>
</Card>
);
}
function ScheduleScreen() {
const query = useScheduleScreenQuery();
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [selectedCollectionId, setSelectedCollectionId] = useState<number | null>(null);
const [dragItemId, setDragItemId] = useState<number | null>(null);
const [overItemId, setOverItemId] = useState<number | null>(null);
const [inspectorTab, setInspectorTab] = useState<ScheduleInspectorTab>('content');
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
const mutatingRef = useRef(false);
if (query.status === 'loading') {
return <SchedulesLoadingState />;
}
if (query.status === 'error') {
return <SchedulesErrorState error={query.error} refresh={query.refresh} />;
}
const { activeSchedule, items, pickers, schedules, totalDurationEstimate } = query.data;
const { itemsLoading, setActiveSchedule, setItems } = query;
if (!activeSchedule) {
return <SchedulesEmptyState />;
}
const orderedItems = sortedScheduleItems(items);
const selectedItem = orderedItems.find((item) => item.id === selectedItemId) ?? orderedItems[0] ?? null;
const effectiveSelectedItemId = selectedItem?.id ?? null;
const selectedIndex = selectedItem ? orderedItems.findIndex((item) => item.id === selectedItem.id) : -1;
const collectionOptions = sortedCollections(pickers.collections);
const effectiveCollectionId = collectionOptions.some((collection) => collection.id === selectedCollectionId)
? selectedCollectionId
: collectionOptions[0]?.id ?? null;
const setMutatingState = (value: boolean) => {
mutatingRef.current = value;
setMutating(value);
};
const refetchItemsAfterMutation = async (operation: () => Promise<void>) => {
setMutationError(null);
setMutatingState(true);
try {
await operation();
setActiveSchedule(activeSchedule.id);
} catch (error: unknown) {
setMutationError(messageFromError(error));
} finally {
setMutatingState(false);
}
};
const persistOrder = async (nextItems: ProgramScheduleItem[]) => {
const previousSelectedId = effectiveSelectedItemId;
setSelectedItemId((current) => current ?? previousSelectedId ?? nextItems[0]?.id ?? null);
try {
setMutationError(null);
setMutatingState(true);
const result = await replaceScheduleItems(activeSchedule.id, {
items: nextItems.map(scheduleItemToRequest)
});
setSelectedItemId(previousSelectedId ?? result[0]?.id ?? null);
setItems(result);
} catch (error: unknown) {
setSelectedItemId(previousSelectedId);
setMutationError(messageFromError(error));
} finally {
setMutatingState(false);
}
};
const moveItem = (itemId: number, direction: -1 | 1) => {
if (mutatingRef.current) {
return;
}
const index = orderedItems.findIndex((item) => item.id === itemId);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= orderedItems.length) {
return;
}
const nextItems = [...orderedItems];
const [moved] = nextItems.splice(index, 1);
nextItems.splice(nextIndex, 0, moved);
void persistOrder(nextItems);
};
const dropItem = (targetId: number) => {
if (dragItemId == null || dragItemId === targetId || mutatingRef.current) {
setDragItemId(null);
setOverItemId(null);
return;
}
const from = orderedItems.findIndex((item) => item.id === dragItemId);
const to = orderedItems.findIndex((item) => item.id === targetId);
if (from >= 0 && to >= 0) {
const nextItems = [...orderedItems];
const [moved] = nextItems.splice(from, 1);
nextItems.splice(to, 0, moved);
void persistOrder(nextItems);
}
setDragItemId(null);
setOverItemId(null);
};
const addItem = () => {
const collection = collectionOptions.find((candidate) => candidate.id === effectiveCollectionId);
if (!collection) {
setMutationError('A collection is required before adding a schedule item');
return;
}
void refetchItemsAfterMutation(async () => {
const added = await addScheduleItem(activeSchedule.id, newScheduleItemRequest(collection));
setSelectedItemId(added.id ?? null);
});
};
const deleteItem = (item: ProgramScheduleItem) => {
const itemId = item.id;
if (itemId == null || !window.confirm(`Remove ${scheduleItemName(item)}?`)) {
return;
}
void refetchItemsAfterMutation(async () => {
await deleteScheduleItem(activeSchedule.id, itemId);
setSelectedItemId(null);
});
};
return (
<div className="ctv-schedule-screen">
<section className="ctv-schedule-header">
<span className="ctv-schedule-header-icon"><CalendarClock aria-hidden="true" size={18} /></span>
<div>
<h2>{activeSchedule.name ?? 'Unnamed schedule'}</h2>
<p>
{schedules.length} schedule{schedules.length === 1 ? '' : 's'} · {orderedItems.length} item{orderedItems.length === 1 ? '' : 's'} · programs <code>{totalDurationEstimate ?? 'unknown'}</code>
</p>
</div>
<Select
disabled={mutating}
label="Active schedule"
onChange={(event) => setActiveSchedule(Number(event.target.value))}
options={scheduleOptions(schedules)}
size="sm"
value={`${activeSchedule.id}`}
/>
<Button disabled startIcon={<Play aria-hidden="true" size={15} />} variant="secondary">Preview playout</Button>
</section>
{mutationError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{mutationError}</span>
</div>
)}
<div className="ctv-schedule-grid">
<section className="ctv-schedule-lineup-panel" aria-label="Lineup">
<div className="ctv-schedule-panel-head">
<span>Lineup · drag to reorder</span>
{itemsLoading && <Spinner size={13} tone="muted" />}
<Select
disabled={mutating || collectionOptions.length === 0}
label="Collection for new item"
onChange={(event) => setSelectedCollectionId(Number(event.target.value))}
options={pickerOptions(collectionOptions)}
size="sm"
value={`${effectiveCollectionId ?? ''}`}
/>
<Button disabled={mutating} onClick={addItem} size="sm" startIcon={<Plus aria-hidden="true" size={14} />} variant="ghost">Add item</Button>
</div>
{orderedItems.length === 0 ? (
<div className="ctv-schedule-empty">No schedule items</div>
) : (
<div className="ctv-schedule-lineup" role="list" aria-label="Schedule lineup">
{orderedItems.map((item, index) => (
<ScheduleItemBlock
active={item.id === selectedItem?.id}
dragging={item.id === dragItemId}
isFirst={index === 0}
isLast={index === orderedItems.length - 1}
isOver={item.id === overItemId && dragItemId != null && dragItemId !== item.id}
item={item}
key={item.id ?? index}
mutating={mutating}
onDelete={deleteItem}
onDrop={dropItem}
onMove={moveItem}
onOver={setOverItemId}
onSelect={setSelectedItemId}
onStartDrag={setDragItemId}
/>
))}
</div>
)}
</section>
<ScheduleInspector
item={selectedItem}
itemIndex={selectedIndex}
mutating={mutating}
onDelete={deleteItem}
pickers={pickers}
schedule={activeSchedule}
tab={inspectorTab}
onTabChange={(value) => setInspectorTab(value as ScheduleInspectorTab)}
/>
</div>
</div>
);
}
function ScheduleItemBlock({
active,
dragging,
isFirst,
isLast,
isOver,
item,
mutating,
onDelete,
onDrop,
onMove,
onOver,
onSelect,
onStartDrag
}: {
active: boolean;
dragging: boolean;
isFirst: boolean;
isLast: boolean;
isOver: boolean;
item: ProgramScheduleItem;
mutating: boolean;
onDelete: (item: ProgramScheduleItem) => void;
onDrop: (targetId: number) => void;
onMove: (itemId: number, direction: -1 | 1) => void;
onOver: (itemId: number | null) => void;
onSelect: (itemId: number | null) => void;
onStartDrag: (itemId: number | null) => void;
}) {
const itemId = item.id ?? null;
const name = scheduleItemName(item);
const fill = scheduleFillDescriptor(item);
const fixed = item.startType === 'Fixed';
const filler = item.guideMode === 'Filler';
const onDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
onOver(itemId);
};
return (
<div className="ctv-schedule-lineup-row" role="listitem">
<div className="ctv-schedule-rail" aria-hidden="true">
<span className={fixed ? 'ctv-schedule-rail-dot-fixed' : 'ctv-schedule-rail-dot'} />
<code>{fixed ? formatScheduleTime(item.startTime) : 'flows'}</code>
</div>
<div
aria-roledescription="draggable schedule item"
className={`ctv-schedule-block${active ? ' ctv-schedule-block-active' : ''}${dragging ? ' ctv-schedule-block-dragging' : ''}${isOver ? ' ctv-schedule-block-over' : ''}`}
draggable={!mutating}
onDragEnd={() => {
onStartDrag(null);
onOver(null);
}}
onDragOver={onDragOver}
onDragStart={() => onStartDrag(itemId)}
onDrop={() => {
if (itemId != null) {
onDrop(itemId);
}
}}
>
<button
type="button"
className="ctv-schedule-block-main"
aria-current={active ? 'true' : undefined}
onClick={() => onSelect(itemId)}
>
<GripVertical aria-hidden="true" size={16} />
<ChannelLogo name={name} size={38} />
<span className="ctv-schedule-block-title">
<strong>{name}</strong>
<small>{formatScheduleCollectionType(item.collectionType)}</small>
</span>
<span className="ctv-schedule-fill-chip">
{fill.icon}
<code>{fill.label}</code>
</span>
</button>
<div className="ctv-schedule-block-meta">
<span><ArrowDownWideNarrow aria-hidden="true" size={13} />{formatScheduleEnum(item.playbackOrder ?? 'Shuffle')}</span>
{filler && <Badge tone="neutral">Hidden from guide</Badge>}
<span className="ctv-schedule-block-duration">{item.durationEstimate ?? 'unknown'}</span>
</div>
<div className="ctv-schedule-block-actions">
<IconButton disabled={mutating || isFirst || itemId == null} onClick={() => itemId != null && onMove(itemId, -1)} size="sm" title={`Move ${name} up`}>
<ChevronDown className="ctv-schedule-up-icon" aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled={mutating || isLast || itemId == null} onClick={() => itemId != null && onMove(itemId, 1)} size="sm" title={`Move ${name} down`}>
<ChevronDown aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled={mutating || itemId == null} onClick={() => onDelete(item)} size="sm" title={`Remove ${name}`}>
<Trash2 aria-hidden="true" size={15} />
</IconButton>
</div>
</div>
</div>
);
}
function ScheduleInspector({
item,
itemIndex,
mutating,
onDelete,
onTabChange,
pickers,
schedule,
tab
}: {
item: ProgramScheduleItem | null;
itemIndex: number;
mutating: boolean;
onDelete: (item: ProgramScheduleItem) => void;
onTabChange: (value: string) => void;
pickers: SchedulePickerData;
schedule: ProgramSchedule;
tab: ScheduleInspectorTab;
}) {
if (!item) {
return (
<section className="ctv-schedule-inspector">
<div className="ctv-schedule-empty">Select or add a schedule item</div>
</section>
);
}
const name = scheduleItemName(item);
const fill = scheduleFillDescriptor(item);
return (
<section className="ctv-schedule-inspector" aria-label="Schedule item inspector">
<div className="ctv-schedule-inspector-head">
<ChannelLogo name={name} size={34} />
<div>
<strong>{name}</strong>
<span>Item {itemIndex + 1} · {formatScheduleCollectionType(item.collectionType)}</span>
</div>
<IconButton disabled={mutating || item.id == null} onClick={() => onDelete(item)} title="Remove selected item">
<Trash2 aria-hidden="true" size={15} />
</IconButton>
</div>
<div className="ctv-schedule-inspector-badges">
<Badge tone={item.startType === 'Fixed' ? 'accent' : 'neutral'} dot>{item.startType === 'Fixed' ? formatScheduleTime(item.startTime) : 'Dynamic'}</Badge>
<Badge tone="neutral">{formatScheduleEnum(item.playbackOrder ?? 'Shuffle')}</Badge>
<Badge tone="neutral">{fill.label}</Badge>
</div>
<Tabs
value={tab}
onChange={onTabChange}
tabs={[
{ value: 'content', label: 'Content' },
{ value: 'playback', label: 'Playback' },
{ value: 'filler', label: 'Filler' },
{ value: 'overrides', label: 'Overrides' }
]}
/>
<div className="ctv-schedule-inspector-body">
{tab === 'content' && (
<>
<div className="ctv-schedule-form-grid">
<Select disabled label="Start type" value={item.startType ?? 'Dynamic'} options={['Dynamic', 'Fixed']} />
<Input disabled label="Start time" value={item.startType === 'Fixed' ? formatScheduleTime(item.startTime) : ''} placeholder="dynamic" />
</div>
<Select disabled label="Collection type" value={item.collectionType ?? 'Collection'} options={[item.collectionType ?? 'Collection']} />
<Select
disabled
label="Collection"
value={`${collectionIdForItem(item) ?? ''}`}
options={pickerOptions(pickers.collections)}
/>
<Input disabled label="Custom title" value={item.customTitle ?? ''} placeholder="Optional guide title" />
<div className="ctv-schedule-form-grid">
<Select disabled label="Guide mode" value={item.guideMode ?? 'Normal'} options={['Normal', 'Filler']} />
<Input disabled label="Schedule" value={schedule.name ?? 'Unnamed schedule'} />
</div>
<Checkbox disabled checked={schedule.keepMultiPartEpisodesTogether} label="Keep multi-part together" />
</>
)}
{tab === 'playback' && (
<>
<Select disabled label="Playback order" value={item.playbackOrder ?? 'Shuffle'} options={[item.playbackOrder ?? 'Shuffle']} />
<Select disabled label="Playout mode" value={item.playoutMode ?? 'One'} options={['Flood', 'One', 'Multiple', 'Duration']} />
<div className="ctv-schedule-form-grid">
<Input disabled label="Multiple count" value={item.multipleCount ?? item.count ?? ''} placeholder="n/a" />
<Input disabled label="Duration estimate" value={item.durationEstimate ?? 'unknown'} />
</div>
<div className="ctv-schedule-note">
{fill.icon}
<span>{playbackExplanation(item)}</span>
</div>
</>
)}
{tab === 'filler' && (
<div className="ctv-schedule-form-grid">
<Select disabled label="Pre-roll" value={item.preRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
<Select disabled label="Mid-roll" value={item.midRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
<Select disabled label="Post-roll" value={item.postRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
<Select disabled label="Fallback" value={item.fallbackFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
<Select disabled label="Tail" value={item.tailFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
</div>
)}
{tab === 'overrides' && (
<div className="ctv-schedule-form-grid">
<Select disabled label="Watermarks" value={item.watermarks?.[0]?.name ?? 'Inherit'} options={['Inherit', ...namedPickerLabels(pickers.watermarks)]} />
<Select disabled label="Subtitle mode" value={item.subtitleMode ?? 'Inherit'} options={['Inherit', 'None', 'Any', 'Forced', 'Default']} />
<Input disabled label="Preferred audio" value={item.preferredAudioLanguageCode ?? ''} placeholder="Deferred: no languages endpoint" />
<Input disabled label="Preferred subtitle" value={item.preferredSubtitleLanguageCode ?? ''} placeholder="Deferred: no languages endpoint" />
</div>
)}
</div>
</section>
);
}
function sortedScheduleItems(items: ProgramScheduleItem[]): ProgramScheduleItem[] {
return [...items].sort((left, right) => (left.index ?? 0) - (right.index ?? 0));
}
function scheduleItemToRequest(item: ProgramScheduleItem): ScheduleItemRequest {
return {
collectionId: item.collection?.id ?? null,
collectionType: item.collectionType ?? 'Collection',
customTitle: item.customTitle ?? null,
discardToFillAttempts: item.discardToFillAttempts ?? null,
fallbackFillerId: item.fallbackFiller?.id ?? null,
fillWithGroupMode: item.fillWithGroupMode ?? 'None',
fixedStartTimeBehavior: item.fixedStartTimeBehavior ?? null,
graphicsElementIds: item.graphicsElements?.map((element) => element.id) ?? [],
guideMode: item.guideMode ?? 'Normal',
marathonBatchSize: item.marathonBatchSize ?? null,
marathonGroupBy: item.marathonGroupBy ?? 'None',
marathonShuffleGroups: item.marathonShuffleGroups ?? false,
marathonShuffleItems: item.marathonShuffleItems ?? false,
mediaItemId: item.mediaItem?.mediaItemId ?? null,
midRollFillerId: item.midRollFiller?.id ?? null,
multipleCount: item.multipleCount ?? item.count ?? null,
multipleMode: item.multipleMode ?? 'Count',
multiCollectionId: item.multiCollection?.id ?? null,
playbackOrder: item.playbackOrder ?? 'Shuffle',
playlistId: item.playlist?.id ?? null,
playoutDuration: item.playoutDuration ?? null,
playoutMode: item.playoutMode ?? 'One',
postRollFillerId: item.postRollFiller?.id ?? null,
preferredAudioLanguageCode: item.preferredAudioLanguageCode ?? null,
preferredAudioTitle: item.preferredAudioTitle ?? null,
preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode ?? null,
preRollFillerId: item.preRollFiller?.id ?? null,
rerunCollectionId: item.rerunCollection?.id ?? null,
searchQuery: item.searchQuery ?? null,
searchTitle: item.searchTitle ?? null,
smartCollectionId: item.smartCollection?.id ?? null,
startTime: item.startTime ?? null,
startType: item.startType ?? 'Dynamic',
subtitleMode: item.subtitleMode ?? null,
tailFillerId: item.tailFiller?.id ?? null,
tailMode: item.tailMode ?? 'None',
watermarkIds: item.watermarks?.map((watermark) => watermark.id) ?? []
};
}
function newScheduleItemRequest(collection: MediaCollection): ScheduleItemRequest {
return {
collectionId: collection.id,
collectionType: collection.collectionType ?? 'Collection',
customTitle: null,
discardToFillAttempts: null,
fallbackFillerId: null,
fillWithGroupMode: 'None',
fixedStartTimeBehavior: null,
graphicsElementIds: [],
guideMode: 'Normal',
marathonBatchSize: null,
marathonGroupBy: 'None',
marathonShuffleGroups: false,
marathonShuffleItems: false,
mediaItemId: null,
midRollFillerId: null,
multipleCount: null,
multipleMode: 'Count',
multiCollectionId: null,
playbackOrder: 'Shuffle',
playlistId: null,
playoutDuration: null,
playoutMode: 'One',
postRollFillerId: null,
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
preferredSubtitleLanguageCode: null,
preRollFillerId: null,
rerunCollectionId: null,
searchQuery: null,
searchTitle: null,
smartCollectionId: null,
startTime: null,
startType: 'Dynamic',
subtitleMode: null,
tailFillerId: null,
tailMode: 'None',
watermarkIds: []
};
}
function scheduleItemName(item: ProgramScheduleItem): string {
return item.name ?? item.collection?.name ?? item.multiCollection?.name ?? item.smartCollection?.name ?? item.searchTitle ?? item.searchQuery ?? 'Unnamed item';
}
function collectionIdForItem(item: ProgramScheduleItem): number | null {
return item.collection?.id ?? item.multiCollection?.id ?? item.smartCollection?.id ?? null;
}
function sortedCollections(collections: MediaCollection[]): MediaCollection[] {
return [...collections].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
}
function scheduleOptions(schedules: ProgramSchedule[]): Array<{ label: string; value: string }> {
return schedules.map((schedule) => ({ label: schedule.name ?? `Schedule ${schedule.id}`, value: `${schedule.id}` }));
}
function scheduleFillDescriptor(item: ProgramScheduleItem): { icon: ReactNode; label: string } {
if (item.playoutMode === 'Flood') {
return { icon: <GripVertical aria-hidden="true" size={13} />, label: 'Fills to next' };
}
if (item.playoutMode === 'Multiple') {
return { icon: <Copy aria-hidden="true" size={13} />, label: `x${item.multipleCount ?? item.count ?? '?'}` };
}
if (item.playoutMode === 'Duration') {
return { icon: <Timer aria-hidden="true" size={13} />, label: item.durationEstimate ?? 'Duration' };
}
return { icon: <Shuffle aria-hidden="true" size={13} />, label: '1 item' };
}
function playbackExplanation(item: ProgramScheduleItem): string {
if (item.playoutMode === 'Flood') {
return 'Plays until the next fixed-start item.';
}
if (item.playoutMode === 'Multiple') {
return `Plays ${item.multipleCount ?? item.count ?? 'multiple'} items, then advances.`;
}
if (item.playoutMode === 'Duration') {
return 'Plays for a fixed duration.';
}
return 'Plays exactly one item, then advances.';
}
function formatScheduleTime(value: string | null | undefined): string {
return value?.slice(0, 5) ?? 'dynamic';
}
function formatScheduleEnum(value: string): string {
return value.replace(/([a-z])([A-Z])/g, '$1 $2');
}
function formatScheduleCollectionType(value: string | undefined): string {
return formatScheduleEnum(value ?? 'Collection');
}
const EPG_SLOT_MINUTES = 30;
const EPG_SLOT_WIDTH = 156;
const EPG_RAIL_WIDTH = 196;
const EPG_ROW_HEIGHT = 82;
const EPG_HEADER_HEIGHT = 34;
function GuideLoadingState() {
return (
<Card title={<h2>Loading guide</h2>} subtitle="Fetching a bounded JSON guide window and live channel state.">
<div className="ctv-dashboard-state">
<Spinner tone="muted" />
<span>Loading guide</span>
</div>
</Card>
);
}
function GuideErrorState({ error, refresh }: { error: string; refresh: () => void }) {
return (
<Card
title={<h2>Guide unavailable</h2>}
subtitle="The API returned an error while loading the JSON guide."
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Retry</Button>}
>
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
</Card>
);
}
function GuideScreen() {
const query = useGuideScreenQuery();
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const intervalId = window.setInterval(() => {
setNow(new Date());
}, 60000);
return () => {
window.clearInterval(intervalId);
};
}, []);
if (query.status === 'loading') {
return <GuideLoadingState />;
}
if (query.status === 'error') {
return <GuideErrorState error={query.error} refresh={query.refresh} />;
}
const channels = query.data.guide.channels;
const channelStatesByNumber = new Map(query.data.channelStates.map((state) => [state.channelNumber, state]));
const windowStart = new Date(query.data.guide.start);
const windowEnd = new Date(query.data.guide.end);
const slots = guideSlots(windowStart, windowEnd);
const totalWidth = EPG_RAIL_WIDTH + slots.length * EPG_SLOT_WIDTH;
const nowOffset = offsetPx(now, windowStart);
const nowInWindow = now >= windowStart && now <= windowEnd;
const moveWindow = (delta: number) => {
query.setWindowStart(new Date(query.windowStart.getTime() + delta * GUIDE_WINDOW_MS));
};
const jumpToNow = () => {
query.setWindowStart(defaultGuideWindowStart(now));
};
return (
<div className="ctv-epg-screen">
<section className="ctv-epg-toolbar" aria-label="Guide controls">
<div className="ctv-epg-filter">
<Select disabled label="Channel group" options={['All channels']} size="sm" value="All channels" />
</div>
<div className="ctv-epg-window">
<span>{formatGuideTime(windowStart)}</span>
<input
aria-label="Guide window position"
disabled
max="100"
min="0"
type="range"
value={nowInWindow ? Math.round((now.getTime() - windowStart.getTime()) / (windowEnd.getTime() - windowStart.getTime()) * 100) : 0}
readOnly
/>
<span>{formatGuideTime(windowEnd)}</span>
</div>
<Badge tone={nowInWindow ? 'accent' : 'neutral'} dot={nowInWindow}>Now {formatGuideTime(now)}</Badge>
<Button onClick={() => moveWindow(-1)} variant="secondary">Previous</Button>
<Button onClick={() => moveWindow(1)} variant="secondary">Next guide window</Button>
<Button onClick={jumpToNow} startIcon={<Crosshair aria-hidden="true" size={15} />} variant="primary">Jump to now</Button>
</section>
<section className="ctv-epg-grid-shell" aria-label="Guide timeline">
<div className="ctv-epg-scroll">
<div
aria-label="Channel guide"
className="ctv-epg-grid"
role="grid"
style={{ minWidth: totalWidth }}
>
<div className="ctv-epg-time-head" role="row" style={{ height: EPG_HEADER_HEIGHT }}>
<div className="ctv-epg-rail-head" style={{ width: EPG_RAIL_WIDTH }} />
{slots.map((slot) => (
<div className="ctv-epg-time-slot" key={slot.toISOString()} role="columnheader" style={{ width: EPG_SLOT_WIDTH }}>
{formatGuideTime(slot)}
</div>
))}
</div>
{nowInWindow && (
<div
aria-hidden="true"
className="ctv-epg-now-marker"
style={{
bottom: 0,
left: EPG_RAIL_WIDTH + nowOffset,
top: EPG_HEADER_HEIGHT
}}
>
<span />
</div>
)}
{channels.map((channel, index) => (
<GuideChannelRow
channel={channel}
channelState={channelStatesByNumber.get(channel.number) ?? null}
index={index}
key={channel.number}
windowEnd={windowEnd}
windowStart={windowStart}
/>
))}
</div>
</div>
</section>
</div>
);
}
function GuideChannelRow({
channel,
channelState,
index,
windowEnd,
windowStart
}: {
channel: ChannelGuideChannel;
channelState: ChannelState | null;
index: number;
windowEnd: Date;
windowStart: Date;
}) {
const visibleProgrammes = channel.programmes
.map((programme) => clipProgramme(programme, windowStart, windowEnd))
.filter((programme): programme is ClippedProgramme => programme !== null);
return (
<div className="ctv-epg-row" role="row" style={{ height: EPG_ROW_HEIGHT }}>
<div className="ctv-epg-channel-rail" role="rowheader" style={{ width: EPG_RAIL_WIDTH }}>
<code>{channel.number}</code>
<ChannelLogo name={channel.name} size={30} />
<span>{channel.name}</span>
{channelState?.onAir && <StatusDot status="live" size={7} />}
</div>
<div className={`ctv-epg-track${index % 2 ? ' ctv-epg-track-alt' : ''}`} role="gridcell">
{visibleProgrammes.length === 0 ? (
<span className="ctv-epg-empty">No programmes in this window</span>
) : visibleProgrammes.map((programme) => (
<GuideProgrammeBlock
key={`${programme.title}-${programme.start.getTime()}-${programme.stop.getTime()}`}
programme={programme}
channelState={channelState}
windowStart={windowStart}
/>
))}
</div>
</div>
);
}
interface ClippedProgramme {
category: null | string;
fillerKind: string;
start: Date;
stop: Date;
subTitle: null | string;
title: string;
visibleStart: Date;
visibleStop: Date;
}
function GuideProgrammeBlock({
channelState,
programme,
windowStart
}: {
channelState: ChannelState | null;
programme: ClippedProgramme;
windowStart: Date;
}) {
const filler = programme.fillerKind !== 'None';
const live = Boolean(channelState?.onAir && programmeMatchesNowPlaying(programme, channelState.nowPlaying));
const left = offsetPx(programme.visibleStart, windowStart) + 3;
const width = Math.max(24, offsetPx(programme.visibleStop, programme.visibleStart) - 6);
return (
<article
className={`ctv-epg-programme${live ? ' ctv-epg-programme-live' : ''}${filler ? ' ctv-epg-programme-filler' : ''}`}
style={{ left, width }}
title={`${programme.title}${programme.subTitle ? ` - ${programme.subTitle}` : ''}`}
>
<div className="ctv-epg-programme-title">
{live && <StatusDot status="live" size={6} />}
<strong>{programme.title}</strong>
</div>
{programme.subTitle && <span>{programme.subTitle}</span>}
{filler ? <Badge tone="neutral">Filler</Badge> : programme.category && <small>{programme.category}</small>}
</article>
);
}
function guideSlots(start: Date, end: Date): Date[] {
const slots: Date[] = [];
const slotMs = EPG_SLOT_MINUTES * 60 * 1000;
for (let value = start.getTime(); value < end.getTime(); value += slotMs) {
slots.push(new Date(value));
}
return slots;
}
function formatGuideTime(value: Date): string {
return value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function offsetPx(value: Date, start: Date): number {
return (value.getTime() - start.getTime()) / (EPG_SLOT_MINUTES * 60 * 1000) * EPG_SLOT_WIDTH;
}
function clipProgramme(programme: ChannelGuideProgramme, windowStart: Date, windowEnd: Date): ClippedProgramme | null {
const start = new Date(programme.start);
const stop = new Date(programme.stop);
if (Number.isNaN(start.getTime()) || Number.isNaN(stop.getTime()) || stop <= windowStart || start >= windowEnd) {
return null;
}
return {
category: programme.category,
fillerKind: programme.fillerKind,
start,
stop,
subTitle: programme.subTitle,
title: programme.title,
visibleStart: start < windowStart ? windowStart : start,
visibleStop: stop > windowEnd ? windowEnd : stop
};
}
function programmeMatchesNowPlaying(programme: ClippedProgramme, nowPlaying: ChannelState['nowPlaying'] | undefined): boolean {
if (!nowPlaying) {
return false;
}
const nowPlayingStart = new Date(nowPlaying.startUtc);
const nowPlayingFinish = new Date(nowPlaying.finishUtc);
return programme.start.getTime() === nowPlayingStart.getTime() &&
programme.stop.getTime() === nowPlayingFinish.getTime();
}
function LibrariesLoadingState() {
return (
<Card title={<h2>Loading libraries</h2>} subtitle="Fetching media sources and active scan status.">
<div className="ctv-dashboard-state">
<Spinner tone="muted" />
<span>Loading libraries</span>
</div>
</Card>
);
}
function LibrariesErrorState({ error, refresh }: { error: string; refresh: () => void }) {
return (
<Card
title={<h2>Libraries unavailable</h2>}
subtitle="The API returned an error while loading media sources."
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Retry</Button>}
>
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
</Card>
);
}
function LibrariesEmptyState() {
return (
<Card title={<h2>No media sources</h2>} subtitle="Configure a source in the server setup screens before monitoring scans here.">
<div className="ctv-schedule-empty">No media sources returned</div>
</Card>
);
}
function LibrariesScreen() {
const query = useLibrariesScreenQuery();
if (query.status === 'loading') {
return <LibrariesLoadingState />;
}
if (query.status === 'error') {
return <LibrariesErrorState error={query.error} refresh={query.refresh} />;
}
if (query.data.sources.length === 0) {
return <LibrariesEmptyState />;
}
const totalItems = query.data.sources.reduce(
(sum, source) => sum + source.libraries.reduce((librarySum, library) => librarySum + library.itemCount, 0),
0
);
const scanStatusesByLibraryId = new Map(query.data.scanStatuses.map((status) => [status.libraryId, status]));
return (
<div className="ctv-libraries-screen">
<section className="ctv-libraries-header">
<div>
<span className="ctv-kicker">Sources</span>
<h2>Media Libraries</h2>
<p>Monitor connected media sources and trigger per-library scans.</p>
</div>
<div className="ctv-libraries-header-actions">
<Button disabled startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Scan All</Button>
<Button disabled startIcon={<Plus aria-hidden="true" size={15} />}>Add Source</Button>
</div>
</section>
{query.error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{query.error}</span>
</div>
)}
<div className="ctv-libraries-summary">
<span>{query.data.sources.length} source{query.data.sources.length === 1 ? '' : 's'}</span>
<span>{totalItems.toLocaleString()} items</span>
<span><StatusDot status={query.data.scanStatuses.length > 0 ? 'live' : 'ok'} size={7} />{query.data.scanStatuses.length} active scan{query.data.scanStatuses.length === 1 ? '' : 's'}</span>
</div>
<div className="ctv-libraries-grid">
{query.data.sources.map((source) => (
<MediaSourceCard
key={source.id}
onScanLibrary={query.scanLibrary}
scanStatusesByLibraryId={scanStatusesByLibraryId}
scanningLibraryIds={query.scanningLibraryIds}
source={source}
/>
))}
<section className="ctv-library-add-card" aria-label="Add media source">
<span className="ctv-library-kind-icon ctv-library-kind-local"><Plus aria-hidden="true" size={18} /></span>
<div>
<h3>Connect a media source</h3>
<p>Adding sources is deferred to the existing server setup screens.</p>
</div>
<div className="ctv-library-add-options" aria-hidden="true">
<span><HardDrive size={13} />Local folder</span>
<span><Server size={13} />Plex</span>
<span><Server size={13} />Jellyfin</span>
<span><Server size={13} />Emby</span>
</div>
</section>
</div>
</div>
);
}
function MediaSourceCard({
onScanLibrary,
scanStatusesByLibraryId,
scanningLibraryIds,
source
}: {
onScanLibrary: (libraryId: number) => Promise<void>;
scanStatusesByLibraryId: Map<number, LibraryScanStatus>;
scanningLibraryIds: Set<number>;
source: MediaSource;
}) {
const totalItems = source.libraries.reduce((sum, library) => sum + library.itemCount, 0);
const scanning = source.libraries.some((library) => scanningLibraryIds.has(library.id));
const scanSource = () => {
source.libraries.forEach((library) => {
void onScanLibrary(library.id);
});
};
return (
<section className="ctv-library-source-card" aria-label={`${source.name} media source`}>
<div className="ctv-library-source-head">
<span className={`ctv-library-kind-icon ctv-library-kind-${source.kind.toLowerCase()}`}>{sourceKindIcon(source.kind)}</span>
<div>
<div className="ctv-library-source-title">
<h3>{source.name}</h3>
<StatusDot status={scanning ? 'live' : 'ok'} label={scanning ? 'Scanning' : 'Synced'} size={7} />
</div>
<span>{source.connectionAddress ?? 'Local connection'}</span>
</div>
<div className="ctv-library-source-actions">
<IconButton disabled={scanning} onClick={scanSource} size="sm" title={`Scan all libraries in ${source.name}`}>
<RefreshCw aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled size="sm" title={`Settings unavailable for ${source.name}`}>
<Settings aria-hidden="true" size={15} />
</IconButton>
</div>
</div>
<div className="ctv-library-list" role="list" aria-label={`${source.name} libraries`}>
{source.libraries.length === 0 ? (
<div className="ctv-library-empty">No synced libraries returned</div>
) : source.libraries.map((library) => (
<LibraryRow
key={library.id}
library={library}
onScanLibrary={onScanLibrary}
scanStatus={scanStatusesByLibraryId.get(library.id) ?? null}
scanning={scanningLibraryIds.has(library.id)}
/>
))}
</div>
<div className="ctv-library-source-foot">
<span><Clock aria-hidden="true" size={13} />{scanning ? 'Scanning now' : sourceLastScanLabel(source)}</span>
<span><code>{totalItems.toLocaleString()}</code> items · {source.libraries.length} librar{source.libraries.length === 1 ? 'y' : 'ies'}</span>
</div>
</section>
);
}
function LibraryRow({
library,
onScanLibrary,
scanStatus,
scanning
}: {
library: MediaSourceLibrary;
onScanLibrary: (libraryId: number) => Promise<void>;
scanStatus: LibraryScanStatus | null;
scanning: boolean;
}) {
const scanActive = scanStatus !== null;
const disabled = scanning || scanActive;
return (
<div className="ctv-library-row" role="listitem">
<span className="ctv-library-media-icon">{libraryMediaIcon(library.mediaKind)}</span>
<div className="ctv-library-row-main">
<strong>{library.name}</strong>
<span><span>{formatLibraryMediaKind(library.mediaKind)}</span> · <code>{library.itemCount.toLocaleString()}</code> items</span>
<small>{library.lastScan ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}</small>
</div>
<div className="ctv-library-row-status">
{scanStatus ? (
<div className="ctv-library-progress">
<ProgressBar value={scanStatus.percent} showLabel />
<Badge tone="accent" dot>Scanning</Badge>
</div>
) : (
<Badge tone="ok" dot>Synced</Badge>
)}
<IconButton disabled={disabled} onClick={() => void onScanLibrary(library.id)} size="sm" title={`Scan ${library.name}`}>
<RefreshCw aria-hidden="true" size={15} />
</IconButton>
</div>
</div>
);
}
function sourceKindIcon(kind: string): ReactNode {
return kind === 'Local'
? <HardDrive aria-hidden="true" size={18} />
: <Server aria-hidden="true" size={18} />;
}
function libraryMediaIcon(kind: string): ReactNode {
switch (kind) {
case 'Movies':
return <Clapperboard aria-hidden="true" size={14} />;
case 'Shows':
return <MonitorPlay aria-hidden="true" size={14} />;
case 'MusicVideos':
case 'Songs':
return <Music aria-hidden="true" size={14} />;
case 'Images':
return <FileImage aria-hidden="true" size={14} />;
case 'RemoteStreams':
return <Radio aria-hidden="true" size={14} />;
case 'OtherVideos':
return <Film aria-hidden="true" size={14} />;
default:
return <Folder aria-hidden="true" size={14} />;
}
}
function formatLibraryMediaKind(kind: string): string {
switch (kind) {
case 'MusicVideos':
return 'Music Videos';
case 'OtherVideos':
return 'Other Videos';
case 'RemoteStreams':
return 'Remote Streams';
default:
return kind;
}
}
function sourceLastScanLabel(source: MediaSource): string {
const scans = source.libraries
.map((library) => library.lastScan)
.filter((scan): scan is string => Boolean(scan))
.sort();
return scans.length > 0 ? `Last scan ${formatDateTime(scans[scans.length - 1])}` : 'Never scanned';
}
function PlayoutsLoadingState() {
return (
<Card title={<h2>Loading playouts</h2>} subtitle="Fetching playouts, channel state, and upcoming items.">
<div className="ctv-dashboard-state">
<Spinner tone="muted" />
<span>Loading playouts</span>
</div>
</Card>
);
}
function PlayoutsErrorState({ error, refresh }: { error: string; refresh: () => void }) {
return (
<Card
title={<h2>Playouts unavailable</h2>}
subtitle="The API returned an error while loading the monitor."
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Retry</Button>}
>
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
</Card>
);
}
function PlayoutsEmptyState() {
return (
<Card title={<h2>No playouts returned</h2>} subtitle="Create a channel playout before using the runtime monitor.">
<div className="ctv-schedule-empty">No playouts returned</div>
</Card>
);
}
function PlayoutsScreen() {
const query = usePlayoutsScreenQuery();
const [filter, setFilter] = useState('');
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
const mutatingRef = useRef(false);
if (query.status === 'loading') {
return <PlayoutsLoadingState />;
}
if (query.status === 'error') {
return <PlayoutsErrorState error={query.error} refresh={query.refresh} />;
}
const { channelStates, items, playout, playouts, selectedPlayoutId, totalCount, warningsCount } = query.data;
const { itemsLoading, setShowFiller, showFiller } = query;
const selectedSummary = playouts.find((candidate) => candidate.id === selectedPlayoutId) ?? playouts[0] ?? null;
if (!selectedSummary) {
return <PlayoutsEmptyState />;
}
const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber);
const nowPlaying = selectedState?.nowPlaying ?? null;
const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null;
const nextItem = nextPlayoutItem(items, nowItem);
const filteredPlayouts = filterPlayouts(playouts, filter);
const setMutatingState = (value: boolean) => {
mutatingRef.current = value;
setMutating(value);
};
const resetAll = () => {
if (mutatingRef.current || !window.confirm('Reset all playouts?')) {
return;
}
setMutationError(null);
setMutatingState(true);
resetAllPlayouts()
.then(() => {
query.refresh();
})
.catch((error: unknown) => {
setMutationError(messageFromError(error));
})
.finally(() => {
setMutatingState(false);
});
};
return (
<div className="ctv-playouts-screen">
<section className="ctv-playouts-header">
<Button disabled={mutating} onClick={resetAll} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Reset all playouts</Button>
<span className="ctv-playouts-header-spacer" />
<Badge tone={warningsCount > 0 ? 'warn' : 'neutral'} dot={warningsCount > 0}>{warningsCount} warning{warningsCount === 1 ? '' : 's'}</Badge>
<Button disabled startIcon={<Plus aria-hidden="true" size={15} />} variant="primary">Add Playout</Button>
</section>
{mutationError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{mutationError}</span>
</div>
)}
<div className="ctv-playouts-grid">
<aside className="ctv-playouts-rail" aria-label="Playout selector">
<div className="ctv-playouts-filter">
<Input
label="Filter playouts"
onChange={(event) => setFilter(event.target.value)}
placeholder="Filter playouts..."
size="sm"
value={filter}
/>
<span>{playoutsCountLabel(filteredPlayouts.length, totalCount)}</span>
</div>
<div className="ctv-playouts-list">
{filteredPlayouts.map((candidate) => {
const state = channelStates.find((entry) => entry.channelNumber === candidate.channelNumber);
const active = candidate.id === selectedSummary.id;
return (
<button
aria-current={active ? 'true' : undefined}
className={`ctv-playout-option${active ? ' ctv-playout-option-active' : ''}`}
disabled={mutating}
key={candidate.id}
onClick={() => query.setActivePlayout(candidate.id)}
type="button"
>
<ChannelLogo name={candidate.channelName} size={30} />
<span>
<code>{candidate.channelNumber}</code>
<strong>{candidate.channelName}</strong>
<small>{state?.nowPlaying?.title ?? candidate.scheduleName}</small>
</span>
{state?.onAir && <StatusDot status="live" size={7} />}
</button>
);
})}
</div>
</aside>
<section className="ctv-playouts-monitor" aria-label="Selected playout monitor">
<div className="ctv-playouts-title">
<ChannelLogo name={selectedSummary.channelName} size={38} />
<div>
<span><code>{selectedSummary.channelNumber}</code> {selectedState?.onAir && <Badge tone="accent" dot>On air</Badge>}</span>
<h2>{selectedSummary.channelName}</h2>
</div>
</div>
<div className="ctv-playout-now">
<div className="ctv-playout-preview" aria-label="Live preview disabled">
<Play aria-hidden="true" size={24} />
<Badge tone="neutral">Metadata preview only</Badge>
</div>
<div className="ctv-playout-now-copy">
<span>On air now</span>
<h3>{nowPlaying?.title ?? nowItem?.title ?? 'No current item reported'}</h3>
<ProgressBar value={playoutProgress(nowPlaying?.startUtc ?? nowItem?.start, nowPlaying?.finishUtc ?? nowItem?.finish)} />
<div>
<code>{formatDateTime(nowPlaying?.startUtc ?? nowItem?.start)}</code>
<code>{formatDateTime(nowPlaying?.finishUtc ?? nowItem?.finish)}</code>
</div>
</div>
</div>
<div className="ctv-playouts-cards">
<Card title="Up next">
<div className="ctv-playout-next">
<Film aria-hidden="true" size={18} />
<span>
<strong>{nextItem?.title ?? 'No upcoming item'}</strong>
<small>{nextItem ? `${formatDateTime(nextItem.start)} · ${nextItem.duration ?? 'duration unknown'}` : 'Upcoming list is empty'}</small>
</span>
</div>
</Card>
<Card title="Playout">
<div className="ctv-playout-detail-grid">
<Input disabled label="Mode" value={playout?.playoutMode ?? 'Unknown'} />
<Input disabled label="Schedule" value={playout?.scheduleName ?? selectedSummary.scheduleName} />
<Input disabled label="Kind" value={formatScheduleEnum(playout?.scheduleKind ?? selectedSummary.scheduleKind)} />
<Input disabled label="Rebuild" value={formatDailyRebuild(playout?.dailyRebuildTime ?? selectedSummary.dailyRebuildTime)} />
</div>
<div className="ctv-playout-detail-actions" title="No per-playout reset endpoint exists yet">
<Button disabled size="sm" startIcon={<RefreshCw aria-hidden="true" size={13} />} variant="secondary">Reset</Button>
<Button disabled size="sm" startIcon={<Clock aria-hidden="true" size={13} />} variant="ghost">Schedule reset</Button>
<small>Per-playout reset is deferred the API has no per-playout reset endpoint yet.</small>
</div>
</Card>
</div>
<Card title="Timeline" subtitle="Selected playout items" actions={itemsLoading ? <Spinner size={13} tone="muted" /> : undefined}>
<PlayoutTimeline items={items} itemsLoading={itemsLoading} nowItem={nowItem} />
</Card>
<Card
title="Upcoming"
subtitle="Next items"
padded={false}
actions={
<span className="ctv-playout-upcoming-actions">
{itemsLoading && <Spinner size={13} tone="muted" />}
<Switch
checked={showFiller}
disabled={itemsLoading || mutating}
label="Show filler"
onChange={setShowFiller}
size="sm"
/>
</span>
}
>
<div className="ctv-playout-upcoming" role="list" aria-label="Upcoming playout items">
{items.length === 0 ? (
<div className="ctv-schedule-empty">{itemsLoading ? <Spinner size={15} tone="muted" /> : 'No upcoming items'}</div>
) : (
items.map((item, index) => (
<div className={item === nowItem ? 'ctv-playout-upcoming-now' : ''} key={`${item.start}-${index}`} role="listitem">
<code>{formatDateTime(item.start)}</code>
{isFillerItem(item) ? <Sparkles aria-hidden="true" size={13} /> : <Film aria-hidden="true" size={13} />}
<span>{item.title ?? 'Untitled item'}</span>
{isFillerItem(item) && <Badge tone="neutral">Filler</Badge>}
<small>{item.duration ?? 'unknown'}</small>
</div>
))
)}
</div>
</Card>
</section>
</div>
</div>
);
}
function PlayoutTimeline({ items, itemsLoading, nowItem }: { items: PlayoutItem[]; itemsLoading: boolean; nowItem: PlayoutItem | null }) {
if (items.length === 0) {
return (
<div className="ctv-schedule-empty">
{itemsLoading ? <Spinner size={15} tone="muted" /> : 'No timeline items'}
</div>
);
}
const firstStart = Date.parse(items[0].start);
const lastFinish = Date.parse(items[items.length - 1].finish);
const span = Math.max(lastFinish - firstStart, 1);
return (
<div className="ctv-playout-timeline">
<div>
{items.map((item, index) => {
const width = Math.max(((Date.parse(item.finish) - Date.parse(item.start)) / span) * 100, 2);
return (
<span
className={isFillerItem(item) ? 'ctv-playout-timeline-filler' : ''}
key={`${item.start}-${index}`}
style={{ width: `${width}%` }}
title={item.title ?? 'Untitled item'}
>
{width > 12 && (item.title ?? 'Untitled item')}
</span>
);
})}
{nowItem && <i style={{ left: `${timelinePosition(nowItem.start, firstStart, span)}%` }} />}
</div>
<p>
<code>{formatDateTime(items[0].start)}</code>
<span><Clock aria-hidden="true" size={12} /> now</span>
<code>{formatDateTime(items[items.length - 1].finish)}</code>
</p>
</div>
);
}
function filterPlayouts(playouts: PlayoutSummary[], filter: string): PlayoutSummary[] {
const normalized = filter.trim().toLowerCase();
if (!normalized) {
return playouts;
}
return playouts.filter((playout) =>
playout.channelName.toLowerCase().includes(normalized) ||
playout.channelNumber.toLowerCase().includes(normalized) ||
playout.scheduleName.toLowerCase().includes(normalized)
);
}
// The backend always emits fillerKind; real content items carry 'None', so only a
// concrete filler kind (PreRoll, MidRoll, ...) marks an item as filler.
function isFillerItem(item: PlayoutItem): boolean {
return item.fillerKind != null && item.fillerKind !== 'None';
}
function playoutsCountLabel(shownCount: number, totalCount: number): string {
if (shownCount === totalCount) {
return `${totalCount} playout${totalCount === 1 ? '' : 's'} loaded`;
}
return `${shownCount} of ${totalCount} playout${totalCount === 1 ? '' : 's'}`;
}
function itemMatchingNow(items: PlayoutItem[], title: string | null | undefined): PlayoutItem | null {
const nowMs = Date.now();
const windowMatch = items.find((item) => {
const startMs = Date.parse(item.start);
const finishMs = Date.parse(item.finish);
return Number.isFinite(startMs) && Number.isFinite(finishMs) && startMs <= nowMs && nowMs < finishMs;
});
if (windowMatch) {
return windowMatch;
}
if (!title) {
return null;
}
return items.find((item) => item.title === title) ?? null;
}
function nextPlayoutItem(items: PlayoutItem[], nowItem: PlayoutItem | null): PlayoutItem | null {
const index = nowItem ? items.indexOf(nowItem) : -1;
return items[index + 1] ?? items[1] ?? null;
}
function playoutProgress(start: string | null | undefined, finish: string | null | undefined): number {
if (!start || !finish) {
return 0;
}
const startMs = Date.parse(start);
const finishMs = Date.parse(finish);
const nowMs = Date.now();
if (!Number.isFinite(startMs) || !Number.isFinite(finishMs) || finishMs <= startMs) {
return 0;
}
return Math.min(100, Math.max(0, ((nowMs - startMs) / (finishMs - startMs)) * 100));
}
function timelinePosition(start: string, firstStart: number, span: number): number {
const startMs = Date.parse(start);
if (!Number.isFinite(startMs)) {
return 0;
}
return Math.min(100, Math.max(0, ((startMs - firstStart) / span) * 100));
}
function formatDateTime(value: string | null | undefined): string {
if (!value) {
return 'unknown';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value.slice(0, 5);
}
return parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatDailyRebuild(value: string | null | undefined): string {
return value ? value.slice(0, 5) : 'manual';
}
function pickerOptions(items: MediaCollection[]): Array<{ label: string; value: string }> {
return items.map((item) => ({ label: item.name ?? `Collection ${item.id}`, value: `${item.id}` }));
}
function namedPickerLabels(items: unknown[]): string[] {
return items.map((item) => (item as { name?: null | string }).name ?? 'Unnamed');
}
function fillerOptions(items: unknown[]): string[] {
return ['None', ...namedPickerLabels(items)];
}
function PlaceholderScreen({ route }: { route: ScreenRoute }) {
return (
<div className="ctv-screen-stack">
<Card
title={<h2>{route.placeholder}</h2>}
subtitle={route.description}
actions={<Badge tone="accent">Issue slot</Badge>}
>
<div className="ctv-placeholder-layout">
<div className="ctv-placeholder-icon">{route.icon}</div>
<div>
<h3>Screen slot</h3>
<p>{route.description}</p>
</div>
</div>
</Card>
<Card title={<h2>Ready for implementation</h2>} subtitle="Screen-specific work lands in later issues">
<div className="ctv-slot-grid">
<span>Controls</span>
<span>Data table</span>
<span>Details panel</span>
</div>
</Card>
</div>
);
}
function NotFoundScreen() {
const pathname = normalizePath(window.location.pathname);
return (
<div className="ctv-screen-stack">
<Card title={<h2>Unknown app route</h2>} subtitle="This URL does not match a ChicoryTV screen.">
<div className="ctv-placeholder-layout">
<div className="ctv-placeholder-icon"><Info aria-hidden="true" size={20} /></div>
<div>
<h3>Requested path</h3>
<p>{pathname}</p>
</div>
</div>
</Card>
</div>
);
}
function ScreenContent({
healthState,
route
}: {
healthState: DashboardHealthQueryState;
route: ScreenRoute | null;
}) {
if (route === null) {
return <NotFoundScreen />;
}
if (route.id === 'dashboard') {
return <DashboardScreen healthState={healthState} />;
}
if (route.id === 'channels') {
return <ChannelsScreen />;
}
if (route.id === 'builder') {
return <ChannelBuilderScreen />;
}
if (route.id === 'editChannel') {
return <ChannelEditScreen key={window.location.pathname} />;
}
if (route.id === 'guide') {
return <GuideScreen />;
}
if (route.id === 'schedules') {
return <ScheduleScreen />;
}
if (route.id === 'playouts') {
return <PlayoutsScreen />;
}
if (route.id === 'libraries') {
return <LibrariesScreen />;
}
if (route.id === 'settings') {
return <SettingsScreen />;
}
return <PlaceholderScreen route={route} />;
}
export function App() {
const [theme, setTheme] = useState<DesignSystemThemeId>(() => getStoredDesignSystemTheme());
const [activeRoute, setActiveRoute] = useState<ScreenRoute | null>(() => routeFromLocation());
const healthState = useDashboardHealthQuery();
useEffect(() => {
applyDesignSystemTheme(theme);
}, [theme]);
useEffect(() => {
const onPopState = () => setActiveRoute(routeFromLocation());
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const route = useMemo(() => activeRoute, [activeRoute]);
const navigate = (nextRoute: ScreenRoute, event: MouseEvent) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) {
return;
}
event.preventDefault();
if (nextRoute.id === route?.id) {
return;
}
window.history.pushState(null, '', routeHref(nextRoute));
setActiveRoute(nextRoute);
};
return (
<div className="ctv-app-shell">
<Sidebar activeRoute={route} healthState={healthState} onNavigate={navigate} />
<div className="ctv-shell-body">
<TopBar route={route} />
<main className="ctv-main">
<ThemeSwitcher theme={theme} onThemeChange={setTheme} />
<ScreenContent healthState={healthState} route={route} />
</main>
</div>
</div>
);
}