+
+
+ {fixed ? formatScheduleTime(item.startTime) : 'flows'}
+
+
{
+ onStartDrag(null);
+ onOver(null);
+ }}
+ onDragOver={onDragOver}
+ onDragStart={() => onStartDrag(itemId)}
+ onDrop={() => {
+ if (itemId != null) {
+ onDrop(itemId);
+ }
+ }}
+ >
+
+
+
{formatScheduleEnum(item.playbackOrder ?? 'Shuffle')}
+ {filler &&
Hidden from guide}
+
{item.durationEstimate ?? 'unknown'}
+
+
+ itemId != null && onMove(itemId, -1)} size="sm" title={`Move ${name} up`}>
+
+
+ itemId != null && onMove(itemId, 1)} size="sm" title={`Move ${name} down`}>
+
+
+ onDelete(item)} size="sm" title={`Remove ${name}`}>
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+ {name}
+ Item {itemIndex + 1} · {formatScheduleCollectionType(item.collectionType)}
+
+
onDelete(item)} title="Remove selected item">
+
+
+
+
+ {item.startType === 'Fixed' ? formatScheduleTime(item.startTime) : 'Dynamic'}
+ {formatScheduleEnum(item.playbackOrder ?? 'Shuffle')}
+ {fill.label}
+
+
+
+
+ );
+}
+
+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 firstCollection(collections: MediaCollection[]): MediaCollection | null {
+ return [...collections].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''))[0] ?? null;
+}
+
+function scheduleFillDescriptor(item: ProgramScheduleItem): { icon: ReactNode; label: string } {
+ if (item.playoutMode === 'Flood') {
+ return { icon:
@@ -1235,6 +1854,10 @@ function ScreenContent({
return
;
}
+ if (route.id === 'schedules') {
+ return
;
+ }
+
return
;
}
diff --git a/web/src/api/index.ts b/web/src/api/index.ts
index 5b1ab2855..60e0b0df0 100644
--- a/web/src/api/index.ts
+++ b/web/src/api/index.ts
@@ -2,4 +2,5 @@ export * from './auth';
export * from './channels';
export * from './client';
export * from './dashboard';
+export * from './schedules';
export * from './useChannelsQuery';
diff --git a/web/src/api/schedules.ts b/web/src/api/schedules.ts
new file mode 100644
index 000000000..c42da55c8
--- /dev/null
+++ b/web/src/api/schedules.ts
@@ -0,0 +1,222 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { ApiError, request } from './client';
+import type { components } from './generated/v1';
+
+export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
+export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemViewModel'] & {
+ count?: null | string;
+ discardToFillAttempts?: null | number;
+ multipleCount?: null | string;
+ multipleMode?: components['schemas']['MultipleMode'];
+ playoutDuration?: null | string;
+ tailMode?: components['schemas']['TailMode'];
+};
+export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
+export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
+export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
+export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
+export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
+export type FillerPreset = components['schemas']['FillerPresetResponseModel'];
+export type Watermark = components['schemas']['WatermarkResponseModel'];
+
+export interface SchedulePickerData {
+ collections: MediaCollection[];
+ fillerPresets: FillerPreset[];
+ smartCollections: SmartCollection[];
+ watermarks: Watermark[];
+}
+
+export interface ScheduleScreenData {
+ activeSchedule: ProgramSchedule | null;
+ items: ProgramScheduleItem[];
+ pickers: SchedulePickerData;
+ schedules: ProgramSchedule[];
+ totalDurationEstimate: string | null;
+}
+
+export type ScheduleScreenQueryState =
+ | { data: ScheduleScreenData; error: null; refresh: () => void; status: 'success' }
+ | { data: null; error: string; refresh: () => void; status: 'error' }
+ | { data: null; error: null; refresh: () => void; status: 'loading' };
+
+type ScheduleScreenState =
+ | { data: ScheduleScreenData; error: null; status: 'success' }
+ | { data: null; error: string; status: 'error' }
+ | { data: null; error: null; status: 'loading' };
+
+export function getSchedules(): Promise
{
+ return request('/api/schedules');
+}
+
+export function getScheduleItems(scheduleId: number): Promise {
+ return request(`/api/schedules/${scheduleId}/items`);
+}
+
+export function getCollections(): Promise {
+ return request('/api/collections');
+}
+
+export function getSmartCollections(): Promise {
+ return request('/api/smart-collections');
+}
+
+export function getFillerPresets(): Promise {
+ return request('/api/filler-presets');
+}
+
+export function getWatermarks(): Promise {
+ return request('/api/watermarks');
+}
+
+export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise {
+ return request(`/api/schedules/${scheduleId}/items`, {
+ body,
+ method: 'POST'
+ });
+}
+
+export function replaceScheduleItems(
+ scheduleId: number,
+ body: ReplaceScheduleItemsRequest
+): Promise {
+ return request(`/api/schedules/${scheduleId}/items`, {
+ body,
+ method: 'PUT'
+ });
+}
+
+export function deleteScheduleItem(scheduleId: number, itemId: number): Promise {
+ return request(`/api/schedules/${scheduleId}/items/${itemId}`, {
+ method: 'DELETE'
+ });
+}
+
+export async function getScheduleScreenData(): Promise {
+ const schedules = await getSchedules();
+ const activeSchedule = schedules[0] ?? null;
+
+ if (!activeSchedule) {
+ return {
+ activeSchedule: null,
+ items: [],
+ pickers: emptyPickerData(),
+ schedules,
+ totalDurationEstimate: null
+ };
+ }
+
+ const [itemsEnvelope, pickers] = await Promise.all([
+ getScheduleItems(activeSchedule.id),
+ getSchedulePickerData()
+ ]);
+
+ return {
+ activeSchedule,
+ items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
+ pickers,
+ schedules,
+ totalDurationEstimate: itemsEnvelope.totalDurationEstimate
+ };
+}
+
+export function useScheduleScreenQuery(): ScheduleScreenQueryState {
+ const [state, setState] = useState({
+ data: null,
+ error: null,
+ status: 'loading'
+ });
+ const activeRef = useRef(true);
+
+ useEffect(() => {
+ activeRef.current = true;
+
+ return () => {
+ activeRef.current = false;
+ };
+ }, []);
+
+ const load = useCallback(() => {
+ setState({ data: null, error: null, status: 'loading' });
+
+ getScheduleScreenData()
+ .then((data) => {
+ if (activeRef.current) {
+ setState({ data, error: null, status: 'success' });
+ }
+ })
+ .catch((error: unknown) => {
+ if (activeRef.current) {
+ setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
+ }
+ });
+ }, []);
+
+ useEffect(() => {
+ getScheduleScreenData()
+ .then((data) => {
+ if (activeRef.current) {
+ setState({ data, error: null, status: 'success' });
+ }
+ })
+ .catch((error: unknown) => {
+ if (activeRef.current) {
+ setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
+ }
+ });
+ }, []);
+
+ const refresh = useCallback(() => {
+ load();
+ }, [load]);
+
+ if (state.status === 'success') {
+ return { data: state.data, error: null, refresh, status: 'success' };
+ }
+
+ if (state.status === 'error') {
+ return { data: null, error: state.error, refresh, status: 'error' };
+ }
+
+ return { data: null, error: null, refresh, status: 'loading' };
+}
+
+async function getSchedulePickerData(): Promise {
+ const [collections, smartCollections, fillerPresets, watermarks] = await Promise.all([
+ getCollections(),
+ getSmartCollections(),
+ getFillerPresets(),
+ getWatermarks()
+ ]);
+
+ return {
+ collections,
+ fillerPresets: sortByName(fillerPresets),
+ smartCollections,
+ watermarks: sortByName(watermarks)
+ };
+}
+
+function emptyPickerData(): SchedulePickerData {
+ return {
+ collections: [],
+ fillerPresets: [],
+ smartCollections: [],
+ watermarks: []
+ };
+}
+
+function sortByName(items: T[]): T[] {
+ return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
+}
+
+function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
+ if (error instanceof ApiError) {
+ return error.detail ?? error.message;
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return fallback;
+}
diff --git a/web/src/shell.css b/web/src/shell.css
index e1df48878..c856514e7 100644
--- a/web/src/shell.css
+++ b/web/src/shell.css
@@ -984,6 +984,330 @@
font-size: var(--text-xs, 12px);
}
+.ctv-schedule-screen {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ height: 100%;
+ gap: var(--space-6, 12px);
+}
+
+.ctv-schedule-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-6, 12px);
+ border: 1px solid var(--border-hairline);
+ border-radius: var(--radius-md, 7px);
+ background: var(--surface-card);
+ padding: var(--space-6, 12px) var(--space-8, 20px);
+}
+
+.ctv-schedule-header-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ border-radius: var(--radius-sm, 5px);
+ background: var(--ctv-accent-soft);
+ color: var(--ctv-accent);
+}
+
+.ctv-schedule-header > div {
+ flex: 1;
+ min-width: 0;
+}
+
+.ctv-schedule-header h2,
+.ctv-schedule-inspector-head strong {
+ margin: 0;
+ color: var(--text-primary);
+ font-size: var(--text-md, 15px);
+ font-weight: var(--weight-semibold, 600);
+ line-height: 1.2;
+}
+
+.ctv-schedule-header p,
+.ctv-schedule-inspector-head span {
+ margin: var(--space-2, 4px) 0 0;
+ color: var(--text-secondary);
+ font-size: var(--text-xs, 12px);
+ line-height: 1.2;
+}
+
+.ctv-schedule-header code,
+.ctv-schedule-block-duration,
+.ctv-schedule-rail code,
+.ctv-schedule-fill-chip code {
+ font-family: var(--font-mono, ui-monospace, monospace);
+ font-variant-numeric: tabular-nums;
+}
+
+.ctv-schedule-grid {
+ display: grid;
+ grid-template-columns: minmax(420px, 1fr) minmax(360px, 460px);
+ gap: var(--space-8, 20px);
+ min-height: 0;
+ flex: 1;
+}
+
+.ctv-schedule-lineup-panel,
+.ctv-schedule-inspector {
+ min-height: 0;
+ border: 1px solid var(--border-hairline);
+ border-radius: var(--radius-md, 7px);
+ background: var(--surface-card);
+}
+
+.ctv-schedule-lineup-panel {
+ display: flex;
+ flex-direction: column;
+}
+
+.ctv-schedule-panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-5, 10px);
+ border-bottom: 1px solid var(--border-hairline);
+ padding: var(--space-5, 10px) var(--space-7, 16px);
+}
+
+.ctv-schedule-panel-head > span {
+ color: var(--text-disabled);
+ font-size: var(--text-2xs, 11px);
+ font-weight: var(--weight-semibold, 600);
+ line-height: 1;
+ text-transform: uppercase;
+}
+
+.ctv-schedule-lineup {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: var(--space-6, 12px);
+}
+
+.ctv-schedule-lineup-row {
+ display: grid;
+ grid-template-columns: 58px minmax(0, 1fr);
+ gap: var(--space-5, 10px);
+}
+
+.ctv-schedule-rail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-2, 4px);
+ padding-top: var(--space-7, 16px);
+ color: var(--text-faint);
+ font-size: 9px;
+}
+
+.ctv-schedule-rail-dot,
+.ctv-schedule-rail-dot-fixed {
+ width: 9px;
+ height: 9px;
+ border: 2px solid var(--border-control);
+ border-radius: var(--radius-pill, 999px);
+}
+
+.ctv-schedule-rail-dot-fixed {
+ border-color: var(--ctv-accent);
+ background: var(--ctv-accent);
+}
+
+.ctv-schedule-block {
+ position: relative;
+ display: grid;
+ gap: var(--space-4, 8px);
+ margin-bottom: var(--space-6, 12px);
+ border: 1px solid var(--border-hairline);
+ border-radius: var(--radius-md, 7px);
+ background: var(--ctv-surface-1);
+ padding: var(--space-5, 10px);
+ transition:
+ background var(--dur-fast) var(--ease-standard),
+ border-color var(--dur-fast) var(--ease-standard),
+ opacity var(--dur-fast) var(--ease-standard);
+}
+
+.ctv-schedule-block-active {
+ border-color: var(--action-primary);
+ background: var(--ctv-accent-soft);
+}
+
+.ctv-schedule-block-dragging {
+ opacity: 0.45;
+}
+
+.ctv-schedule-block-over {
+ box-shadow: inset 0 2px 0 var(--action-primary);
+}
+
+.ctv-schedule-block-main {
+ display: grid;
+ grid-template-columns: 16px 38px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--space-5, 10px);
+ width: 100%;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ padding: 0;
+ text-align: left;
+}
+
+.ctv-schedule-block-main > svg {
+ color: var(--text-faint);
+ cursor: grab;
+}
+
+.ctv-schedule-block-title {
+ display: grid;
+ min-width: 0;
+ gap: var(--space-2, 4px);
+}
+
+.ctv-schedule-block-title strong {
+ overflow: hidden;
+ color: var(--text-primary);
+ font-size: var(--text-md, 15px);
+ font-weight: var(--weight-semibold, 600);
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ctv-schedule-block-title small,
+.ctv-schedule-block-meta {
+ color: var(--text-secondary);
+ font-size: var(--text-xs, 12px);
+ line-height: 1;
+}
+
+.ctv-schedule-fill-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-3, 6px);
+ border: 1px solid var(--border-hairline);
+ border-radius: var(--radius-sm, 5px);
+ background: var(--ctv-bg-sunken);
+ color: var(--text-primary);
+ padding: var(--space-3, 6px) var(--space-4, 8px);
+ white-space: nowrap;
+}
+
+.ctv-schedule-fill-chip svg,
+.ctv-schedule-block-meta svg,
+.ctv-schedule-note svg {
+ color: var(--text-secondary);
+}
+
+.ctv-schedule-block-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4, 8px);
+ flex-wrap: wrap;
+ padding-left: 64px;
+}
+
+.ctv-schedule-block-meta > span {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2, 4px);
+}
+
+.ctv-schedule-block-duration {
+ margin-left: auto;
+ color: var(--text-disabled);
+ font-size: var(--text-2xs, 11px);
+}
+
+.ctv-schedule-block-actions {
+ position: absolute;
+ right: var(--space-3, 6px);
+ bottom: var(--space-3, 6px);
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.ctv-schedule-up-icon {
+ transform: rotate(180deg);
+}
+
+.ctv-schedule-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 180px;
+ color: var(--text-secondary);
+ font-size: var(--text-sm, 13px);
+}
+
+.ctv-schedule-inspector {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.ctv-schedule-inspector-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-5, 10px);
+ border-bottom: 1px solid var(--border-hairline);
+ padding: var(--space-7, 16px);
+}
+
+.ctv-schedule-inspector-head > div {
+ display: grid;
+ flex: 1;
+ min-width: 0;
+ gap: var(--space-2, 4px);
+}
+
+.ctv-schedule-inspector-badges {
+ display: flex;
+ gap: var(--space-3, 6px);
+ flex-wrap: wrap;
+ padding: var(--space-5, 10px) var(--space-7, 16px);
+}
+
+.ctv-schedule-inspector .ctv-tabs {
+ padding: 0 var(--space-7, 16px);
+}
+
+.ctv-schedule-inspector-body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ flex-direction: column;
+ gap: var(--space-6, 12px);
+ overflow: auto;
+ padding: var(--space-7, 16px);
+}
+
+.ctv-schedule-form-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-6, 12px);
+}
+
+.ctv-schedule-note {
+ display: flex;
+ align-items: center;
+ gap: var(--space-5, 10px);
+ border: 1px solid var(--border-hairline);
+ border-radius: var(--radius-sm, 5px);
+ background: var(--ctv-bg-sunken);
+ color: var(--text-secondary);
+ padding: var(--space-6, 12px);
+ font-size: var(--text-xs, 12px);
+ line-height: 1.4;
+}
+
@media (max-width: 980px) {
.ctv-app-shell {
grid-template-columns: 1fr;
@@ -1045,4 +1369,23 @@
flex: 1;
justify-content: center;
}
+
+ .ctv-schedule-header {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .ctv-schedule-grid,
+ .ctv-schedule-form-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .ctv-schedule-block-main {
+ grid-template-columns: 16px 38px minmax(0, 1fr);
+ }
+
+ .ctv-schedule-fill-chip {
+ grid-column: 2 / -1;
+ justify-self: start;
+ }
}