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