Files
ersatztv/web/src/App.tsx
T
timothyandClaude Fable 5 91e23bcd57 feat(spa): wire playout delete/reset/erase + scheduling context + templates preview calendar (#210)
Adds the SPA slice for the newly-merged playout endpoints:
- api/playouts: deletePlayout, resetChannelPlayout, erasePlayoutItems,
  erasePlayoutItemsAndHistory, getPlayoutItemSchedulingContext (+ types).
- PlayoutsScreen: enable per-playout Reset (channel playout reset), Delete
  (danger, confirm), and erase-by-kind buttons (Block = items + items&history;
  Classic/Sequential/Scripted = items&history; none for ExternalJson); replaces
  the stale "no per-playout reset endpoint" block. Disable Alternate schedules
  for OnDemand Classic playouts (Blazor parity).
- Per-item scheduling-context dialog (info button when hasSchedulingContext &&
  id != null), rendering the decoded JSON in a scrollable <pre>.
- Playout-templates editor: "Preview calendar" toggle with a Monday-first month
  grid; winning template per day computed client-side via a colocated
  appliesToDate/firstMatchingIndex helper that ports
  AlternateScheduleSelector.GetScheduleForDate / PlayoutTemplateEditViewModel
  .AppliesToDate exactly (inclusive range, year-wrap reverse, start-day rollover,
  end-day clamp, explicit-year override).
- Button gains a title prop (parity with IconButton) for the disabled-button hint.

Tests: api client URL/method assertions, PlayoutsScreen action flows
(delete/reset/erase per kind, OnDemand gating, scheduling-context dialog +
error), calendar-helper unit tests, and a preview-calendar render test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:30:28 +02:00

4038 lines
130 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
type KeyboardEvent,
type MouseEvent,
type ReactNode
} from 'react';
import {
ArrowDownWideNarrow,
Bell,
Boxes,
Palette,
CalendarClock,
CalendarDays,
CalendarRange,
Cast,
Check,
ChevronDown,
Clapperboard,
CircleHelp,
Clock,
ClipboardCopy,
Copy,
Crosshair,
Film,
FileImage,
Eraser,
Folder,
FolderInput,
FolderTree,
GripVertical,
Hash,
HardDrive,
Info,
Layers,
LayoutDashboard,
LayoutGrid,
Library,
Link2,
ListChecks,
ListMusic,
ListVideo,
MonitorPlay,
Music,
Plus,
Pencil,
Play,
Radio,
RefreshCw,
Repeat,
ScrollText,
Search,
Server,
Settings,
Shuffle,
SlidersHorizontal,
Sparkles,
Stamp,
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 { CollectionsScreen } from './screens/CollectionsScreen';
import { FFmpegProfilesScreen } from './screens/FFmpegProfilesScreen';
import { FillerPresetsScreen } from './screens/FillerPresetsScreen';
import { LogsScreen } from './screens/LogsScreen';
import { MediaBrowseScreen } from './screens/MediaBrowseScreen';
import { MultiCollectionsScreen } from './screens/MultiCollectionsScreen';
import { PlaylistsScreen } from './screens/PlaylistsScreen';
import { RerunCollectionsScreen } from './screens/RerunCollectionsScreen';
import {
ArtistDetailScreen,
MovieDetailScreen,
SeasonDetailScreen,
ShowDetailScreen
} from './screens/MediaDetailScreen';
import { ImageBrowserScreen } from './screens/ImageBrowserScreen';
import { SearchScreen } from './screens/SearchScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { TraktListsScreen } from './screens/TraktListsScreen';
import { TrashScreen } from './screens/TrashScreen';
import { TroubleshootingScreen } from './screens/TroubleshootingScreen';
import { BlockPlayoutTroubleshootingScreen } from './screens/BlockPlayoutTroubleshootingScreen';
import { PlaybackTroubleshootingScreen } from './screens/PlaybackTroubleshootingScreen';
import { YamlValidatorScreen } from './screens/YamlValidatorScreen';
import { WatermarksScreen } from './screens/WatermarksScreen';
import { BlocksScreen } from './screens/BlocksScreen';
import { DecosScreen } from './screens/DecosScreen';
import { DecoTemplatesScreen } from './screens/DecoTemplatesScreen';
import { TemplatesScreen } from './screens/TemplatesScreen';
import {
PlayoutAlternateSchedulesScreen,
PlayoutTemplatesEditorScreen
} from './screens/PlayoutScheduleEditors';
import { navigateToPath, parseMediaSubRoute, parsePlayoutSubRoute } from './routing';
import {
Badge,
Button,
Card,
Checkbox,
ChannelLogo,
Dialog,
IconButton,
Input,
NavItem,
NavSection,
ProgressBar,
Select,
Spinner,
Stat,
StatusDot,
Switch,
Tabs
} from './components';
import {
useChannelsQuery,
useChannelsScreenQuery,
bulkDeleteChannels,
bulkMoveChannelsToGroup,
bulkRenumberChannels,
createPlayout,
deletePlayout,
erasePlayoutItems,
erasePlayoutItemsAndHistory,
getPlayoutItemSchedulingContext,
resetChannelPlayout,
deleteChannel,
getDecos,
messageFromError,
addScheduleItem,
deleteScheduleItem,
getSchedules,
resetAllPlayouts,
replaceScheduleItems,
updatePlayoutDefaultDeco,
updatePlayoutDetails,
usePlayoutsScreenQuery,
useScheduleScreenQuery,
useDashboardHealthQuery,
useDashboardQuery,
useDashboardVersionQuery,
defaultGuideWindowStart,
GUIDE_WINDOW_MS,
useGuideScreenQuery,
useLibrariesScreenQuery,
type ChannelState,
type ChannelSummary,
type ChannelGuideChannel,
type ChannelGuideProgramme,
type CreatePlayoutRequest,
type DashboardChannel,
type DecoListItem,
type SchedulePickerData,
type DashboardChannelState,
type DashboardHealthQueryState,
type LibraryScanStatus,
type MediaSource,
type MediaSourceLibrary,
type MediaCollection,
type PlayoutDetail,
type PlayoutScheduleKind,
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'
| 'blocks'
| 'templates'
| 'decos'
| 'decoTemplates'
| 'playouts'
| 'media'
| 'search'
| 'trash'
| 'collections'
| 'multiCollections'
| 'rerunCollections'
| 'playlists'
| 'fillerPresets'
| 'libraries'
| 'traktLists'
| 'ffmpegProfiles'
| 'watermarks'
| 'settings'
| 'logs'
| 'troubleshooting'
| 'blockPlayoutTroubleshooting'
| 'playbackTroubleshooting'
| 'yamlValidator';
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'
},
{
// The editor lives at a sub-path (/app/blocks/{id}); BlocksScreen owns parsing the id suffix.
id: 'blocks',
path: '/app/blocks',
label: 'Blocks',
title: 'Blocks',
kicker: 'Programming',
description: 'Group blocks and edit their items, durations, and playout preview.',
icon: <Boxes aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Blocks workspace',
allowSubPaths: true
},
{
// The editor lives at a sub-path (/app/templates/{id}); TemplatesScreen owns parsing the id suffix.
id: 'templates',
path: '/app/templates',
label: 'Templates',
title: 'Templates',
kicker: 'Programming',
description: 'Group templates and assign blocks to times of day.',
icon: <CalendarDays aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Templates workspace',
allowSubPaths: true
},
{
// The editor lives at a sub-path (/app/decos/{id}); DecosScreen owns parsing the id suffix.
id: 'decos',
path: '/app/decos',
label: 'Decos',
title: 'Decos',
kicker: 'Programming',
description: 'Group decos and configure watermark, graphics, break content, and filler overrides.',
icon: <Palette aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Decos workspace',
allowSubPaths: true
},
{
// The editor lives at a sub-path (/app/deco-templates/{id}); DecoTemplatesScreen owns parsing the id suffix.
id: 'decoTemplates',
path: '/app/deco-templates',
label: 'Deco Templates',
title: 'Deco Templates',
kicker: 'Programming',
description: 'Group deco templates and assign decos to times of day.',
icon: <CalendarRange aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Deco templates workspace',
allowSubPaths: true
},
{
// The alternate-schedules / templates editors live at sub-paths
// (/app/playouts/{id}/alternate-schedules, /app/playouts/{id}/templates).
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,
allowSubPaths: true
},
{
id: 'media',
path: '/app/media',
label: 'Browse',
title: 'Browse Media',
kicker: 'Media',
description: 'Browse indexed movies, shows, episodes, music and more by kind.',
icon: <Clapperboard aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Media browse workspace',
allowSubPaths: true
},
{
id: 'search',
path: '/app/search',
label: 'Search',
title: 'Search',
kicker: 'Media',
description: 'Search across every media kind at once.',
icon: <Search aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Search workspace',
allowSubPaths: true
},
{
id: 'trash',
path: '/app/trash',
label: 'Trash',
title: 'Trash',
kicker: 'Media',
description: 'Library items whose files have gone missing.',
icon: <Trash2 aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Trash workspace'
},
{
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: 'multiCollections',
path: '/app/multi-collections',
label: 'Multi-Collections',
title: 'Multi-Collections',
kicker: 'Media',
description: 'Group manual and smart collections into a single schedulable collection.',
icon: <Layers aria-hidden="true" size={16} />,
primaryAction: 'Add Multi-Collection',
placeholder: 'Multi-collections workspace'
},
{
id: 'rerunCollections',
path: '/app/rerun-collections',
label: 'Rerun Collections',
title: 'Rerun Collections',
kicker: 'Media',
description: 'A collection with distinct first-run and rerun playback orders.',
icon: <Repeat aria-hidden="true" size={16} />,
primaryAction: 'Add Rerun Collection',
placeholder: 'Rerun collections workspace'
},
{
id: 'playlists',
path: '/app/playlists',
label: 'Playlists',
title: 'Playlists',
kicker: 'Media',
description: 'Group, order, and preview playlist items scheduled into channels.',
icon: <ListMusic aria-hidden="true" size={16} />,
primaryAction: 'Add Playlist',
placeholder: 'Playlists workspace'
},
{
id: 'fillerPresets',
path: '/app/filler-presets',
label: 'Filler Presets',
title: 'Filler Presets',
kicker: 'Media',
description: 'Pre/mid/post-roll, tail and fallback filler presets used by schedules.',
icon: <Film aria-hidden="true" size={16} />,
primaryAction: 'Add Filler Preset',
placeholder: 'Filler presets workspace',
allowSubPaths: true
},
{
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'
},
{
// The editor lives at a sub-path (/app/trakt-lists/{id}); the screen owns parsing the
// {id} suffix itself (see TraktListsScreen), same pattern as editChannel/settings.
id: 'traktLists',
path: '/app/trakt-lists',
label: 'Trakt Lists',
title: 'Trakt Lists',
kicker: 'Media',
description: 'Add, match, and manage Trakt list imports.',
icon: <Link2 aria-hidden="true" size={16} />,
primaryAction: 'Add Trakt List',
placeholder: 'Trakt lists workspace',
allowSubPaths: true
},
{
id: 'ffmpegProfiles',
path: '/app/ffmpeg-profiles',
label: 'FFmpeg Profiles',
title: 'FFmpeg Profiles',
kicker: 'System',
description: 'Transcoding profiles: resolution, video/audio formats, hardware acceleration.',
icon: <SlidersHorizontal aria-hidden="true" size={16} />,
primaryAction: 'Add Profile',
placeholder: 'FFmpeg profiles workspace',
allowSubPaths: true
},
{
id: 'watermarks',
path: '/app/watermarks',
label: 'Watermarks',
title: 'Watermarks',
kicker: 'System',
description: 'Channel watermark overlays: image, position, size and opacity.',
icon: <Stamp aria-hidden="true" size={16} />,
primaryAction: 'Add Watermark',
placeholder: 'Watermarks workspace',
allowSubPaths: true
},
{
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
},
{
id: 'logs',
path: '/app/logs',
label: 'Logs',
title: 'Logs',
kicker: 'System',
description: 'Recent server log entries with level and free-text filtering.',
icon: <ScrollText aria-hidden="true" size={16} />,
primaryAction: 'Refresh',
placeholder: 'Logs workspace'
},
{
id: 'troubleshooting',
path: '/app/troubleshooting',
label: 'Troubleshooting',
title: 'Troubleshooting',
kicker: 'System',
description: 'Version, environment, hardware and FFmpeg capability diagnostics.',
icon: <Stethoscope aria-hidden="true" size={16} />,
primaryAction: 'Refresh',
placeholder: 'Troubleshooting workspace'
},
{
id: 'blockPlayoutTroubleshooting',
path: '/app/troubleshooting/blocks',
label: 'Block History',
title: 'Block Playout Troubleshooting',
kicker: 'System',
description: 'Inspect a block playout, its blocks, and each blocks scheduling history.',
icon: <Boxes aria-hidden="true" size={16} />,
primaryAction: 'Refresh',
placeholder: 'Block playout troubleshooting workspace'
},
{
id: 'playbackTroubleshooting',
path: '/app/troubleshooting/playback',
label: 'Playback Troubleshooting',
title: 'Playback Troubleshooting',
kicker: 'System',
description: 'Preview channel or media-item playback with a chosen FFmpeg profile and inspect the transcode logs.',
icon: <Stethoscope aria-hidden="true" size={16} />,
primaryAction: 'Play',
placeholder: 'Playback troubleshooting workspace'
},
{
id: 'yamlValidator',
path: '/app/troubleshooting/yaml',
label: 'Schedule Validator',
title: 'Sequential Schedule Validator',
kicker: 'System',
description: 'Validate a sequential-schedule YAML document against the schema.',
icon: <ListChecks aria-hidden="true" size={16} />,
primaryAction: 'Validate',
placeholder: 'Sequential schedule validator workspace'
}
];
const routeById = new Map(routes.map((route) => [route.id, route]));
const primaryNavIds: ScreenId[] = [
'dashboard',
'channels',
'builder',
'guide',
'schedules',
'blocks',
'templates',
'decos',
'decoTemplates',
'playouts'
];
const mediaNavIds: ScreenId[] = [
'media',
'search',
'trash',
'collections',
'multiCollections',
'rerunCollections',
'playlists',
'fillerPresets',
'libraries',
'traktLists'
];
const systemNavIds: ScreenId[] = [
'settings',
'logs',
'troubleshooting',
'blockPlayoutTroubleshooting',
'yamlValidator',
'ffmpegProfiles',
'watermarks'
];
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';
const [searchValue, setSearchValue] = useState('');
return (
<header className="ctv-topbar">
<div className="ctv-topbar-title">
<p>{kicker}</p>
<h1>{title}</h1>
</div>
<form
className="ctv-topbar-search"
onSubmit={(event) => {
event.preventDefault();
const trimmed = searchValue.trim();
if (trimmed) {
navigateToPath(`/app/search?query=${encodeURIComponent(trimmed)}`);
}
}}
role="search"
>
<Search aria-hidden="true" size={15} />
<input
aria-label="Search"
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Search movies, shows, music..."
type="search"
value={searchValue}
/>
</form>
<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
onClick={() => navigateToPath(`/app/troubleshooting/playback?channel=${channel.id}`)}
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>
);
}
const PLAYOUT_KIND_OPTIONS: Array<{ label: string; value: PlayoutScheduleKind }> = [
{ label: 'Classic', value: 'Classic' },
{ label: 'Block', value: 'Block' },
{ label: 'Sequential', value: 'Sequential' },
{ label: 'Scripted', value: 'Scripted' },
{ label: 'External JSON (dizqueTV)', value: 'ExternalJson' }
];
function scheduleFileHelperText(kind: PlayoutScheduleKind): string {
switch (kind) {
case 'Sequential':
return 'The full path to the sequential schedule (YAML) file';
case 'Scripted':
return 'The command line used to run the scripted schedule';
case 'ExternalJson':
return 'The full path to the JSON (dizqueTV) schedule file';
default:
return '';
}
}
function AddPlayoutDialog({
busy,
channelsWithPlayouts,
error,
onCancel,
onSubmit,
open
}: {
busy: boolean;
channelsWithPlayouts: Set<string>;
error: string | null;
onCancel: () => void;
onSubmit: (request: CreatePlayoutRequest) => void;
open: boolean;
}) {
const channelsQuery = useChannelsQuery();
const [kind, setKind] = useState<PlayoutScheduleKind>('Classic');
const [channelId, setChannelId] = useState('');
const [scheduleId, setScheduleId] = useState('');
const [scheduleFile, setScheduleFile] = useState('');
const [schedules, setSchedules] = useState<ProgramSchedule[] | null>(null);
const schedulesLoading = kind === 'Classic' && schedules === null;
useEffect(() => {
if (kind !== 'Classic' || schedules !== null) {
return;
}
let active = true;
getSchedules()
.then((result) => {
if (active) {
setSchedules(result);
}
})
.catch(() => {
if (active) {
setSchedules([]);
}
});
return () => {
active = false;
};
}, [kind, schedules]);
const channels = channelsQuery.status === 'success' ? channelsQuery.channels : [];
const needsScheduleFile = kind === 'Sequential' || kind === 'Scripted' || kind === 'ExternalJson';
const canSubmit =
channelId.length > 0 &&
(kind !== 'Classic' || scheduleId.length > 0) &&
(!needsScheduleFile || scheduleFile.trim().length > 0);
const submit = () => {
if (!canSubmit) {
return;
}
const request: CreatePlayoutRequest = {
channelId: Number(channelId),
programScheduleId: kind === 'Classic' ? Number(scheduleId) : null,
scheduleFile: needsScheduleFile ? scheduleFile.trim() : null,
scheduleKind: kind
};
onSubmit(request);
};
return (
<Dialog
footer={
<>
<Button disabled={busy} onClick={onCancel} variant="secondary">
Cancel
</Button>
<Button disabled={busy || !canSubmit} loading={busy} onClick={submit} variant="primary">
Create
</Button>
</>
}
onClose={onCancel}
open={open}
title="Add playout"
width={480}
>
<Select
label="Kind"
onChange={(event) => setKind(event.target.value as PlayoutScheduleKind)}
options={PLAYOUT_KIND_OPTIONS}
value={kind}
/>
<div style={{ marginTop: 12 }}>
<Select
label="Channel"
onChange={(event) => setChannelId(event.target.value)}
options={[
{ label: 'Select a channel...', value: '' },
...channels.map((channel) => ({
label: `${channel.number} - ${channel.name}${channelsWithPlayouts.has(channel.number) ? ' (already has a playout)' : ''}`,
value: `${channel.id}`
}))
]}
value={channelId}
/>
</div>
{kind === 'Classic' && (
<div style={{ marginTop: 12 }}>
<Select
disabled={schedulesLoading}
label="Classic schedule"
onChange={(event) => setScheduleId(event.target.value)}
options={[
{ label: schedulesLoading ? 'Loading...' : 'Select a schedule...', value: '' },
...(schedules ?? []).map((schedule) => ({ label: schedule.name ?? `Schedule ${schedule.id}`, value: `${schedule.id}` }))
]}
value={scheduleId}
/>
</div>
)}
{kind === 'Block' && (
<p style={{ color: 'var(--ctv-text-muted, #888)', marginTop: 12 }}>Block templates are added later.</p>
)}
{needsScheduleFile && (
<div style={{ marginTop: 12 }}>
<Input
label={kind === 'ExternalJson' ? 'JSON (dizqueTV) schedule' : `${kind} schedule`}
onChange={(event) => setScheduleFile(event.target.value)}
value={scheduleFile}
/>
<small style={{ color: 'var(--ctv-text-muted, #888)' }}>{scheduleFileHelperText(kind)}</small>
</div>
)}
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
</Dialog>
);
}
function EditPlayoutDetailsDialog({
busy,
error,
onCancel,
onSubmit,
open,
playout
}: {
busy: boolean;
error: string | null;
onCancel: () => void;
onSubmit: (dailyRebuildTime: string | null, scheduleFile: string | null) => void;
open: boolean;
playout: PlayoutDetail;
}) {
const canEditScheduleFile =
playout.scheduleKind === 'Sequential' || playout.scheduleKind === 'Scripted' || playout.scheduleKind === 'ExternalJson';
const [dailyRebuildTime, setDailyRebuildTime] = useState(playout.dailyRebuildTime ?? '');
const [scheduleFile, setScheduleFile] = useState(playout.scheduleFile ?? '');
const rebuildOptions = [
{ label: 'Do not automatically reset', value: '' },
...Array.from({ length: 47 }, (_, index) => {
const totalMinutes = (index + 1) * 30;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const value = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
const label = new Date(2000, 0, 1, hours, minutes).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return { label, value };
})
];
return (
<Dialog
footer={
<>
<Button disabled={busy} onClick={onCancel} variant="secondary">
Cancel
</Button>
<Button
disabled={busy || (canEditScheduleFile && scheduleFile.trim().length === 0)}
loading={busy}
onClick={() => onSubmit(dailyRebuildTime.length > 0 ? dailyRebuildTime : null, canEditScheduleFile ? scheduleFile.trim() : null)}
variant="primary"
>
Save changes
</Button>
</>
}
onClose={onCancel}
open={open}
title="Edit playout details"
width={480}
>
<Select
label="Daily reset time"
onChange={(event) => setDailyRebuildTime(event.target.value)}
options={rebuildOptions}
value={dailyRebuildTime}
/>
{canEditScheduleFile && (
<div style={{ marginTop: 12 }}>
<Input
label={playout.scheduleKind === 'ExternalJson' ? 'JSON (dizqueTV) schedule' : `${playout.scheduleKind} schedule`}
onChange={(event) => setScheduleFile(event.target.value)}
value={scheduleFile}
/>
</div>
)}
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
</Dialog>
);
}
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);
const [addOpen, setAddOpen] = useState(false);
const [addBusy, setAddBusy] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editBusy, setEditBusy] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [contextItemId, setContextItemId] = useState<number | null>(null);
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;
const channelsWithPlayouts = new Set(playouts.map((candidate) => candidate.channelNumber));
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);
});
};
const runMutation = (confirmMessage: string, action: () => Promise<unknown>) => {
if (mutatingRef.current || !window.confirm(confirmMessage)) {
return;
}
setMutationError(null);
setMutatingState(true);
action()
.then(() => {
query.refresh();
})
.catch((error: unknown) => {
setMutationError(messageFromError(error));
})
.finally(() => {
setMutatingState(false);
});
};
const resetSelectedChannel = () => {
if (!selectedSummary) {
return;
}
runMutation(`Reset the playout for ${selectedSummary.channelName}?`, () =>
resetChannelPlayout(selectedSummary.channelNumber)
);
};
const deleteSelectedPlayout = () => {
if (!selectedSummary) {
return;
}
runMutation(`Delete the playout for ${selectedSummary.channelName}? This cannot be undone.`, () =>
deletePlayout(selectedSummary.id)
);
};
const eraseSelectedItems = () => {
if (!selectedSummary) {
return;
}
runMutation(`Erase all built items for ${selectedSummary.channelName}?`, () =>
erasePlayoutItems(selectedSummary.id)
);
};
const eraseSelectedItemsAndHistory = () => {
if (!selectedSummary) {
return;
}
runMutation(`Erase all built items and history for ${selectedSummary.channelName}?`, () =>
erasePlayoutItemsAndHistory(selectedSummary.id)
);
};
const submitAddPlayout = (request: CreatePlayoutRequest) => {
setAddBusy(true);
setAddError(null);
createPlayout(request)
.then((created) => {
setAddOpen(false);
query.setActivePlayout(created.id);
query.refresh();
})
.catch((error: unknown) => {
setAddError(messageFromError(error));
})
.finally(() => {
setAddBusy(false);
});
};
const submitEditPlayoutDetails = (dailyRebuildTime: string | null, scheduleFile: string | null) => {
if (!selectedSummary) {
return;
}
setEditBusy(true);
setEditError(null);
updatePlayoutDetails(selectedSummary.id, { dailyRebuildTime, scheduleFile })
.then(() => {
setEditOpen(false);
query.refresh();
})
.catch((error: unknown) => {
setEditError(messageFromError(error));
})
.finally(() => {
setEditBusy(false);
});
};
if (!selectedSummary) {
return (
<div className="ctv-playouts-screen">
<section className="ctv-playouts-header">
<span className="ctv-playouts-header-spacer" />
<Button onClick={() => setAddOpen(true)} startIcon={<Plus aria-hidden="true" size={15} />} variant="primary">Add Playout</Button>
</section>
<PlayoutsEmptyState />
<AddPlayoutDialog
busy={addBusy}
channelsWithPlayouts={channelsWithPlayouts}
error={addError}
key={`add-${addOpen}`}
onCancel={() => setAddOpen(false)}
onSubmit={submitAddPlayout}
open={addOpen}
/>
</div>
);
}
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);
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 onClick={() => setAddOpen(true)} 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"
actions={
playout && (
<Button
onClick={() => {
setEditError(null);
setEditOpen(true);
}}
size="sm"
startIcon={<Pencil aria-hidden="true" size={13} />}
variant="ghost"
>
Edit details
</Button>
)
}
>
<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>
{selectedSummary.scheduleKind === 'Classic' && (
<div className="ctv-playout-detail-actions">
<Button
disabled={selectedSummary.playoutMode === 'OnDemand'}
onClick={() => navigateToPath(`/app/playouts/${selectedSummary.id}/alternate-schedules`)}
size="sm"
startIcon={<CalendarClock aria-hidden="true" size={13} />}
title={
selectedSummary.playoutMode === 'OnDemand'
? 'Alternate schedules are unavailable for on-demand channels'
: undefined
}
variant="secondary"
>
Alternate schedules
</Button>
</div>
)}
{selectedSummary.scheduleKind === 'Block' && playout && (
<div className="ctv-playout-detail-actions">
<Button
onClick={() => navigateToPath(`/app/playouts/${selectedSummary.id}/templates`)}
size="sm"
startIcon={<CalendarDays aria-hidden="true" size={13} />}
variant="secondary"
>
Templates
</Button>
<PlayoutDefaultDecoField playout={playout} onSaved={query.refresh} />
</div>
)}
<div className="ctv-playout-detail-actions">
<Button
disabled={mutating}
onClick={resetSelectedChannel}
size="sm"
startIcon={<RefreshCw aria-hidden="true" size={13} />}
variant="secondary"
>
Reset
</Button>
{selectedSummary.scheduleKind === 'Block' && (
<Button
disabled={mutating}
onClick={eraseSelectedItems}
size="sm"
startIcon={<Eraser aria-hidden="true" size={13} />}
variant="ghost"
>
Erase items
</Button>
)}
{(selectedSummary.scheduleKind === 'Classic' ||
selectedSummary.scheduleKind === 'Block' ||
selectedSummary.scheduleKind === 'Sequential' ||
selectedSummary.scheduleKind === 'Scripted') && (
<Button
disabled={mutating}
onClick={eraseSelectedItemsAndHistory}
size="sm"
startIcon={<Eraser aria-hidden="true" size={13} />}
variant="ghost"
>
Erase items and history
</Button>
)}
<Button
disabled={mutating}
onClick={deleteSelectedPlayout}
size="sm"
startIcon={<Trash2 aria-hidden="true" size={13} />}
variant="danger"
>
Delete
</Button>
</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) => {
const itemId = item.id;
return (
<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>
{item.hasSchedulingContext && itemId != null && (
<IconButton
onClick={() => setContextItemId(itemId)}
size="sm"
title="Scheduling context"
variant="ghost"
>
<Info aria-hidden="true" size={13} />
</IconButton>
)}
</div>
);
})
)}
</div>
</Card>
</section>
</div>
<AddPlayoutDialog
busy={addBusy}
channelsWithPlayouts={channelsWithPlayouts}
error={addError}
key={`add-${addOpen}`}
onCancel={() => setAddOpen(false)}
onSubmit={submitAddPlayout}
open={addOpen}
/>
{playout && (
<EditPlayoutDetailsDialog
busy={editBusy}
error={editError}
key={`edit-${editOpen}-${playout.id}`}
onCancel={() => setEditOpen(false)}
onSubmit={submitEditPlayoutDetails}
open={editOpen}
playout={playout}
/>
)}
<PlayoutSchedulingContextDialog
itemId={contextItemId}
key={contextItemId ?? 'none'}
onClose={() => setContextItemId(null)}
/>
</div>
);
}
function PlayoutSchedulingContextDialog({ itemId, onClose }: { itemId: number | null; onClose: () => void }) {
const [state, setState] = useState<
| { status: 'loading' }
| { status: 'success'; context: string }
| { status: 'error'; error: string }
>({ status: 'loading' });
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
if (itemId != null) {
getPlayoutItemSchedulingContext(itemId)
.then((result) => {
if (activeRef.current) {
setState({ context: result.context, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ error: messageFromError(error), status: 'error' });
}
});
}
return () => {
activeRef.current = false;
};
}, [itemId]);
return (
<Dialog onClose={onClose} open={itemId != null} title="Scheduling context" width={720}>
{state.status === 'loading' && <Spinner size={16} tone="muted" />}
{state.status === 'error' && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{state.error}</span>
</div>
)}
{state.status === 'success' && (
<pre
style={{
fontFamily: 'var(--font-mono, monospace)',
fontSize: 12,
margin: 0,
maxHeight: '60vh',
overflow: 'auto',
whiteSpace: 'pre'
}}
>
{state.context}
</pre>
)}
</Dialog>
);
}
function PlayoutDefaultDecoField({ playout, onSaved }: { playout: PlayoutDetail; onSaved: () => void }) {
const [decos, setDecos] = useState<DecoListItem[] | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<null | string>(null);
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
getDecos()
.then((list) => {
if (activeRef.current) {
setDecos(list.filter((deco) => deco.id > 0));
}
})
.catch(() => {
if (activeRef.current) {
setDecos([]);
}
});
return () => {
activeRef.current = false;
};
}, []);
const options = [
{ label: 'None (no default deco)', value: '' },
...(decos ?? [])
.slice()
.sort((a, b) => `${a.decoGroupName}${a.name}`.localeCompare(`${b.decoGroupName}${b.name}`))
.map((deco) => ({ label: `${deco.decoGroupName} / ${deco.name}`, value: String(deco.id) }))
];
return (
<div style={{ minWidth: 240 }}>
<Select
disabled={decos === null || saving}
label="Default deco"
onChange={(event) => {
const nextId = event.target.value ? Number(event.target.value) : null;
setSaving(true);
setError(null);
updatePlayoutDefaultDeco(playout.id, nextId)
.then(() => {
onSaved();
})
.catch((saveError: unknown) => {
if (activeRef.current) {
setError(messageFromError(saveError));
}
})
.finally(() => {
if (activeRef.current) {
setSaving(false);
}
});
}}
options={options}
value={playout.decoId == null ? '' : String(playout.decoId)}
/>
{error && <span className="ctv-settings-savebar-error">{error}</span>}
</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>
);
}
// The Playouts nav entry owns two sub-path editors (/app/playouts/{id}/alternate-schedules and
// /app/playouts/{id}/templates). Like BlocksScreen/TemplatesScreen/DecosScreen, this wrapper owns
// its own pathname state and popstate listener so client-side navigation (pushState + synthetic
// popstate via navigateToPath) re-renders the correct sub-screen: routeFromLocation() returns the
// SAME ScreenRoute object reference for the base path and every sub-path of an allowSubPaths route,
// so App's setActiveRoute(routeFromLocation()) bails via Object.is and never re-invokes
// ScreenContent. Tracking pathname locally here sidesteps that entirely.
function PlayoutsRouteScreen() {
const [pathname, setPathname] = useState(() => window.location.pathname);
useEffect(() => {
const onPopState = () => setPathname(window.location.pathname);
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const sub = parsePlayoutSubRoute(pathname);
if (sub?.kind === 'alternate-schedules') {
return <PlayoutAlternateSchedulesScreen key={pathname} playoutId={sub.id} />;
}
if (sub?.kind === 'templates') {
return <PlayoutTemplatesEditorScreen key={pathname} playoutId={sub.id} />;
}
return <PlayoutsScreen />;
}
// The Media nav entry owns detail sub-pages (/app/media/{movies|shows|seasons|artists}/{id}) and the
// image folder browser (/app/media/images/browser). Like PlayoutsRouteScreen, this wrapper tracks
// pathname locally + listens for popstate, because routeFromLocation() returns the SAME 'media'
// ScreenRoute object for the base grid and every sub-path (Object.is bails App's setActiveRoute).
function MediaRouteScreen() {
const [pathname, setPathname] = useState(() => window.location.pathname);
const [search, setSearch] = useState(() => window.location.search);
useEffect(() => {
const onPopState = () => {
setPathname(window.location.pathname);
setSearch(window.location.search);
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const sub = parseMediaSubRoute(pathname);
if (sub?.kind === 'movie') {
return <MovieDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'show') {
return <ShowDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'season') {
return <SeasonDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'artist') {
return <ArtistDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'images') {
return <ImageBrowserScreen key={pathname} />;
}
return <MediaBrowseScreen key={search} />;
}
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 === 'blocks') {
return <BlocksScreen key={window.location.pathname} />;
}
if (route.id === 'templates') {
return <TemplatesScreen key={window.location.pathname} />;
}
if (route.id === 'decos') {
return <DecosScreen key={window.location.pathname} />;
}
if (route.id === 'decoTemplates') {
return <DecoTemplatesScreen key={window.location.pathname} />;
}
if (route.id === 'playouts') {
return <PlayoutsRouteScreen />;
}
if (route.id === 'libraries') {
return <LibrariesScreen />;
}
if (route.id === 'settings') {
return <SettingsScreen />;
}
if (route.id === 'media') {
return <MediaRouteScreen />;
}
if (route.id === 'search') {
return <SearchScreen key={window.location.search} />;
}
if (route.id === 'trash') {
return <TrashScreen />;
}
if (route.id === 'collections') {
return <CollectionsScreen />;
}
if (route.id === 'multiCollections') {
return <MultiCollectionsScreen />;
}
if (route.id === 'rerunCollections') {
return <RerunCollectionsScreen />;
}
if (route.id === 'playlists') {
return <PlaylistsScreen />;
}
if (route.id === 'traktLists') {
return <TraktListsScreen key={window.location.pathname} />;
}
if (route.id === 'fillerPresets') {
return <FillerPresetsScreen />;
}
if (route.id === 'ffmpegProfiles') {
return <FFmpegProfilesScreen />;
}
if (route.id === 'watermarks') {
return <WatermarksScreen />;
}
if (route.id === 'logs') {
return <LogsScreen />;
}
if (route.id === 'troubleshooting') {
return <TroubleshootingScreen />;
}
if (route.id === 'blockPlayoutTroubleshooting') {
return <BlockPlayoutTroubleshootingScreen />;
}
if (route.id === 'playbackTroubleshooting') {
return <PlaybackTroubleshootingScreen key={window.location.search} />;
}
if (route.id === 'yamlValidator') {
return <YamlValidatorScreen />;
}
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>
);
}