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>
4038 lines
130 KiB
TypeScript
4038 lines
130 KiB
TypeScript
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 block’s 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>
|
||
);
|
||
}
|