diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index d7bdd191b..e6532192e 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from './App'; import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Toast, Tooltip } from './components'; @@ -52,7 +52,7 @@ describe('ChicoryTV SPA scaffold', () => { fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); expect(screen.getByRole('heading', { name: 'Schedules' })).toBeInTheDocument(); - expect(screen.getByText('Schedule editor workspace')).toBeInTheDocument(); + expect(await screen.findByText('No schedules returned')).toBeInTheDocument(); expect(window.location.pathname).toBe('/app/schedules'); }); @@ -648,6 +648,261 @@ describe('ChicoryTV SPA scaffold', () => { expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument(); }); + it('renders the Schedule editor from live schedule APIs', async () => { + mockDashboardApi({ + scheduleItems: [ + scheduleItem({ durationEstimate: '01:30:00', id: 11, name: 'Saturday Cartoons' }), + scheduleItem({ + collection: { id: 6, name: 'Station IDs' }, + collectionType: 'Collection', + durationEstimate: null, + guideMode: 'Filler', + id: 12, + name: 'Station IDs', + playoutMode: 'Flood' + }) + ], + schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })], + scheduleItemsTotalDuration: '01:30:00' + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); + + expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument(); + expect(screen.getByRole('list', { name: 'Schedule lineup' })).toBeInTheDocument(); + expect(screen.getAllByText('Saturday Cartoons').length).toBeGreaterThan(0); + expect(screen.getAllByText('01:30:00').length).toBeGreaterThan(0); + expect(screen.getAllByText('Station IDs').length).toBeGreaterThan(0); + expect(screen.getByText('unknown')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Content' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByDisplayValue('Prime Time Cartoons')).toBeInTheDocument(); + expect(window.fetch).toHaveBeenCalledWith('/api/schedules', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/collections', expect.any(Object)); + expect(window.fetch).not.toHaveBeenCalledWith('/api/languages', expect.any(Object)); + }); + + it('shows the Schedule editor loading state', async () => { + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const path = input.toString(); + + if (path === '/api/schedules') { + return new Promise(() => {}); + } + + return Promise.resolve(jsonResponse([])); + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); + + expect(await screen.findByText('Loading schedules')).toBeInTheDocument(); + }); + + it('shows Schedule editor API errors and retries 404 parent handling', async () => { + mockDashboardApi({ + scheduleItemFailuresBeforeSuccess: 1, + scheduleItemFailure: { + detail: 'Schedule 5 was not found', + status: 404, + title: 'Not found' + }, + schedules: [schedule({ id: 5, name: 'Missing Schedule' })] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); + + expect(await screen.findByText('Schedule 5 was not found')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + expect(await screen.findByText('No schedule items')).toBeInTheDocument(); + expect(fetchCount('/api/schedules/5/items')).toBe(2); + }); + + it('shows an empty Schedule editor state when no schedule exists', async () => { + mockDashboardApi({ schedules: [] }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); + + expect(await screen.findByText('No schedules returned')).toBeInTheDocument(); + }); + + it('shows an empty Schedule editor lineup for a schedule with no items', async () => { + mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Empty Schedule' })] }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); + + expect(await screen.findByText('No schedule items')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add item' })).toBeInTheDocument(); + }); + + it('reorders schedule items with keyboard controls and sends the exact replace-all body', async () => { + const first = scheduleItem({ id: 11, name: 'Saturday Cartoons' }); + const second = scheduleItem({ + collection: { id: 6, name: 'Station IDs' }, + durationEstimate: null, + guideMode: 'Filler', + id: 12, + name: 'Station IDs', + playoutMode: 'Flood' + }); + + mockDashboardApi({ + replaceScheduleItemsResponse: [second, first], + scheduleItems: [first, second], + schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); + expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0); + + fireEvent.click(screen.getByRole('button', { name: 'Move Station IDs up' })); + + await waitFor(() => { + expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.objectContaining({ + body: JSON.stringify({ items: [scheduleItemRequest(second), scheduleItemRequest(first)] }), + method: 'PUT' + })); + }); + const lineup = screen.getByRole('list', { name: 'Schedule lineup' }); + const rows = within(lineup).getAllByRole('listitem'); + expect(rows[0]).toHaveTextContent('Station IDs'); + expect(rows[1]).toHaveTextContent('Saturday Cartoons'); + }); + + it('rolls back schedule reorder and shows ProblemDetails on replace failure', async () => { + mockDashboardApi({ + mutationFailures: { + '/api/schedules/5/items': { + detail: 'Schedule items are locked', + status: 422, + title: 'Validation failed' + } + }, + scheduleItems: [ + scheduleItem({ id: 11, name: 'Saturday Cartoons' }), + scheduleItem({ collection: { id: 6, name: 'Station IDs' }, id: 12, name: 'Station IDs' }) + ], + schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); + expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0); + + fireEvent.click(screen.getByRole('button', { name: 'Move Station IDs up' })); + + expect(await screen.findByText('Schedule items are locked')).toBeInTheDocument(); + const lineup = screen.getByRole('list', { name: 'Schedule lineup' }); + expect(within(lineup).getAllByRole('listitem')[0]).toHaveTextContent('Saturday Cartoons'); + }); + + it('adds schedule items and refetches after success', async () => { + const added = scheduleItem({ collection: { id: 3, name: 'Movie Mix' }, id: 20, name: 'Movie Mix' }); + mockDashboardApi({ + addScheduleItemResponse: added, + collections: [{ id: 3, name: 'Movie Mix' }], + scheduleItems: [], + scheduleItemsAfterAdd: [added], + schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); + expect(await screen.findByText('No schedule items')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Add item' })); + + expect(await screen.findAllByText('Movie Mix')).not.toHaveLength(0); + expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.objectContaining({ + body: JSON.stringify(scheduleItemRequest(added)), + method: 'POST' + })); + expect(fetchCount('/api/schedules/5/items')).toBeGreaterThan(2); + }); + + it('shows ProblemDetails when adding a schedule item fails', async () => { + mockDashboardApi({ + collections: [{ id: 3, name: 'Movie Mix' }], + mutationFailures: { + '/api/schedules/5/items': { + detail: 'Collection is required', + status: 422, + title: 'Validation failed' + } + }, + scheduleItems: [], + schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); + expect(await screen.findByText('No schedule items')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Add item' })); + + expect(await screen.findByText('Collection is required')).toBeInTheDocument(); + }); + + it('deletes schedule items and refetches after success', async () => { + mockDashboardApi({ + confirm: true, + scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons' })], + scheduleItemsAfterDelete: [], + schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); + expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0); + + fireEvent.click(screen.getByRole('button', { name: 'Remove Saturday Cartoons' })); + + expect(await screen.findByText('No schedule items')).toBeInTheDocument(); + expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items/11', expect.objectContaining({ + method: 'DELETE' + })); + }); + + it('shows ProblemDetails when deleting a schedule item fails', async () => { + mockDashboardApi({ + confirm: true, + mutationFailures: { + '/api/schedules/5/items/11': { + detail: 'Item is in use by an active playout', + status: 422, + title: 'Validation failed' + } + }, + scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons' })], + schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); + expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0); + + fireEvent.click(screen.getByRole('button', { name: 'Remove Saturday Cartoons' })); + + expect(await screen.findByText('Item is in use by an active playout')).toBeInTheDocument(); + }); + it('shows the dashboard loading state while requests are pending', async () => { vi.spyOn(window, 'fetch').mockImplementation(() => new Promise(() => {})); @@ -802,31 +1057,174 @@ function jsonResponse(body: unknown, status = 200): Response { }); } +function schedule(overrides: Record = {}): Record { + return { + fixedStartTimeBehavior: 'Flexible', + id: 1, + keepMultiPartEpisodesTogether: true, + name: 'Default Schedule', + randomStartPoint: false, + shuffleScheduleItems: false, + treatCollectionsAsShows: false, + ...overrides + }; +} + +function scheduleItem(overrides: Record = {}): Record { + const collection = { id: 2, name: 'Saturday Cartoons', ...(overrides.collection as object | undefined) }; + + return { + collection, + collectionType: 'Collection', + customTitle: null, + discardToFillAttempts: null, + durationEstimate: '00:25:00', + fallbackFiller: null, + fillWithGroupMode: 'None', + fixedStartTimeBehavior: null, + graphicsElements: [], + guideMode: 'Normal', + id: 11, + index: 0, + marathonBatchSize: null, + marathonGroupBy: 'None', + marathonShuffleGroups: false, + marathonShuffleItems: false, + mediaItem: null, + midRollFiller: null, + multipleCount: null, + multipleMode: 'Count', + multiCollection: null, + name: collection.name, + playbackOrder: 'Shuffle', + playlist: null, + playoutDuration: null, + playoutMode: 'One', + postRollFiller: null, + preferredAudioLanguageCode: null, + preferredAudioTitle: null, + preferredSubtitleLanguageCode: null, + preRollFiller: null, + rerunCollection: null, + searchQuery: null, + searchTitle: null, + smartCollection: null, + startTime: null, + startType: 'Dynamic', + subtitleMode: null, + tailFiller: null, + tailMode: 'None', + watermarkIds: [], + watermarks: [], + ...overrides + }; +} + +function scheduleItemRequest(item: Record): Record { + const collection = item.collection as { id?: number } | null | undefined; + const multiCollection = item.multiCollection as { id?: number } | null | undefined; + const smartCollection = item.smartCollection as { id?: number } | null | undefined; + const rerunCollection = item.rerunCollection as { id?: number } | null | undefined; + const mediaItem = item.mediaItem as { id?: number } | null | undefined; + const playlist = item.playlist as { id?: number } | null | undefined; + const watermarks = item.watermarks as Array<{ id: number }> | null | undefined; + const graphicsElements = item.graphicsElements as Array<{ id: number }> | null | undefined; + const fillerId = (value: unknown) => (value as { id?: number } | null | undefined)?.id ?? null; + + return { + collectionId: collection?.id ?? null, + collectionType: item.collectionType ?? 'Collection', + customTitle: item.customTitle ?? null, + discardToFillAttempts: item.discardToFillAttempts ?? null, + fallbackFillerId: fillerId(item.fallbackFiller), + fillWithGroupMode: item.fillWithGroupMode ?? 'None', + fixedStartTimeBehavior: item.fixedStartTimeBehavior ?? null, + graphicsElementIds: 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: mediaItem?.id ?? null, + midRollFillerId: fillerId(item.midRollFiller), + multipleCount: item.multipleCount ?? null, + multipleMode: item.multipleMode ?? 'Count', + multiCollectionId: multiCollection?.id ?? null, + playbackOrder: item.playbackOrder ?? 'Shuffle', + playlistId: playlist?.id ?? null, + playoutDuration: item.playoutDuration ?? null, + playoutMode: item.playoutMode ?? 'One', + postRollFillerId: fillerId(item.postRollFiller), + preferredAudioLanguageCode: item.preferredAudioLanguageCode ?? null, + preferredAudioTitle: item.preferredAudioTitle ?? null, + preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode ?? null, + preRollFillerId: fillerId(item.preRollFiller), + rerunCollectionId: rerunCollection?.id ?? null, + searchQuery: item.searchQuery ?? null, + searchTitle: item.searchTitle ?? null, + smartCollectionId: smartCollection?.id ?? null, + startTime: item.startTime ?? null, + startType: item.startType ?? 'Dynamic', + subtitleMode: item.subtitleMode ?? null, + tailFillerId: fillerId(item.tailFiller), + tailMode: item.tailMode ?? 'None', + watermarkIds: watermarks?.map((watermark) => watermark.id) ?? [], + }; +} + function mockDashboardApi({ + addScheduleItemResponse = null, channels = [], channelStates = [], + collections = [], confirm = false, + fillerPresets = [], health = [], mediaSources = [], + multiCollections = [], mutationFailures = {}, playouts = { page: [], totalCount: 0 }, prompt = null, + replaceScheduleItemsResponse = null, + scheduleItemFailure = null, + scheduleItemFailuresBeforeSuccess = 0, + scheduleItems = [], + scheduleItemsAfterAdd = null, + scheduleItemsAfterDelete = null, + scheduleItemsTotalDuration = null, + schedules = [], + smartCollections = [], version = { apiVersion: 3, appVersion: '26.4.0' } }: { + addScheduleItemResponse?: unknown; channels?: unknown[]; channelStates?: unknown[]; + collections?: unknown[]; confirm?: boolean; + fillerPresets?: unknown[]; health?: unknown[]; mediaSources?: unknown[]; + multiCollections?: unknown[]; mutationFailures?: Record; playouts?: unknown; prompt?: string | null; + replaceScheduleItemsResponse?: unknown; + scheduleItemFailure?: unknown; + scheduleItemFailuresBeforeSuccess?: number; + scheduleItems?: unknown[]; + scheduleItemsAfterAdd?: unknown[] | null; + scheduleItemsAfterDelete?: unknown[] | null; + scheduleItemsTotalDuration?: string | null; + schedules?: unknown[]; + smartCollections?: unknown[]; version?: unknown; } = {}) { vi.spyOn(window, 'confirm').mockReturnValue(confirm); vi.spyOn(window, 'prompt').mockReturnValue(prompt); + let currentScheduleItems = scheduleItems; + let remainingScheduleItemFailures = scheduleItemFailuresBeforeSuccess; - vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { const path = input.toString(); if (path === '/api/channels') { @@ -837,6 +1235,77 @@ function mockDashboardApi({ return Promise.resolve(jsonResponse(channelStates)); } + if (path === '/api/schedules') { + return Promise.resolve(jsonResponse(schedules)); + } + + if (path.match(/^\/api\/schedules\/\d+$/)) { + const id = Number(path.split('/').at(-1)); + const found = schedules.find((item) => (item as { id?: number }).id === id); + return Promise.resolve(found ? jsonResponse(found) : jsonResponse({ + detail: `Schedule ${id} was not found`, + status: 404, + title: 'Not found' + }, 404)); + } + + if (path.match(/^\/api\/schedules\/\d+\/items$/)) { + const method = init?.method ?? 'GET'; + + if (method !== 'GET' && path in mutationFailures) { + return Promise.resolve(jsonResponse(mutationFailures[path], 422)); + } + + if (method === 'POST') { + currentScheduleItems = scheduleItemsAfterAdd ?? (addScheduleItemResponse ? [...currentScheduleItems, addScheduleItemResponse] : currentScheduleItems); + return Promise.resolve(jsonResponse(addScheduleItemResponse ?? null, 201)); + } + + if (method === 'PUT') { + currentScheduleItems = (replaceScheduleItemsResponse as unknown[] | null) ?? currentScheduleItems; + return Promise.resolve(jsonResponse(currentScheduleItems)); + } + + if (remainingScheduleItemFailures > 0) { + remainingScheduleItemFailures -= 1; + return Promise.resolve(jsonResponse(scheduleItemFailure, 404)); + } + + return Promise.resolve(jsonResponse({ + items: currentScheduleItems, + totalDurationEstimate: scheduleItemsTotalDuration + })); + } + + if (path.match(/^\/api\/schedules\/\d+\/items\/\d+$/)) { + if (path in mutationFailures) { + return Promise.resolve(jsonResponse(mutationFailures[path], 422)); + } + + currentScheduleItems = scheduleItemsAfterDelete ?? currentScheduleItems; + return Promise.resolve(new Response(null, { status: 204 })); + } + + if (path === '/api/collections') { + return Promise.resolve(jsonResponse(collections)); + } + + if (path === '/api/smart-collections') { + return Promise.resolve(jsonResponse(smartCollections)); + } + + if (path === '/api/multi-collections') { + return Promise.resolve(jsonResponse(multiCollections)); + } + + if (path === '/api/filler-presets') { + return Promise.resolve(jsonResponse(fillerPresets)); + } + + if (path === '/api/watermarks') { + return Promise.resolve(jsonResponse([])); + } + if (path === '/api/media-sources') { return Promise.resolve(jsonResponse(mediaSources)); } diff --git a/web/src/App.tsx b/web/src/App.tsx index fd832dd0d..98e743893 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,11 +3,13 @@ import { useMemo, useRef, useState, + type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'; import { + ArrowDownWideNarrow, Bell, CalendarClock, Cast, @@ -15,9 +17,11 @@ import { ChevronDown, CircleHelp, ClipboardCopy, + Copy, Folder, FolderInput, FolderTree, + GripVertical, Hash, Info, LayoutDashboard, @@ -31,10 +35,12 @@ import { RefreshCw, Search, Settings, + Shuffle, Stethoscope, + Timer, Trash2, TriangleAlert, - Tv, + Tv } from 'lucide-react'; import chicoryIconUrl from '../../design-system/assets/chicorytv-icon.svg'; import { @@ -44,12 +50,15 @@ import { Checkbox, ChannelLogo, IconButton, + Input, NavItem, NavSection, ProgressBar, + Select, Spinner, Stat, - StatusDot + StatusDot, + Tabs } from './components'; import { useChannelsQuery, @@ -59,14 +68,23 @@ import { bulkRenumberChannels, deleteChannel, messageFromError, + addScheduleItem, + deleteScheduleItem, + replaceScheduleItems, + useScheduleScreenQuery, useDashboardHealthQuery, useDashboardQuery, useDashboardVersionQuery, type ChannelState, type ChannelSummary, type DashboardChannel, + type SchedulePickerData, type DashboardChannelState, - type DashboardHealthQueryState + type DashboardHealthQueryState, + type MediaCollection, + type ProgramSchedule, + type ProgramScheduleItem, + type ScheduleItemRequest } from './api'; import { applyDesignSystemTheme, @@ -1171,6 +1189,607 @@ function ChannelTableRow({ ); } +type ScheduleInspectorTab = 'content' | 'playback' | 'filler' | 'overrides'; + +function SchedulesLoadingState() { + return ( + +
+ + Loading schedules +
+
+ ); +} + +function SchedulesErrorState({ error, refresh }: { error: string; refresh: () => void }) { + return ( + Schedules unavailable} + subtitle="Live API request failed" + actions={} + > +
+ API request failed + {error} +
+
+ ); +} + +function SchedulesEmptyState() { + return ( + No schedules} subtitle="The API returned an empty schedule list."> +
No schedules returned
+
+ ); +} + +function ScheduleScreen() { + const query = useScheduleScreenQuery(); + const [selectedItemId, setSelectedItemId] = useState(null); + const [dragItemId, setDragItemId] = useState(null); + const [overItemId, setOverItemId] = useState(null); + const [inspectorTab, setInspectorTab] = useState('content'); + const [mutationError, setMutationError] = useState(null); + const [mutating, setMutating] = useState(false); + + if (query.status === 'loading') { + return ; + } + + if (query.status === 'error') { + return ; + } + + const { activeSchedule, items, pickers, schedules, totalDurationEstimate } = query.data; + + if (!activeSchedule) { + return ; + } + + 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 refreshAfterMutation = async (operation: () => Promise) => { + setMutationError(null); + setMutating(true); + + try { + await operation(); + query.refresh(); + } catch (error: unknown) { + setMutationError(messageFromError(error)); + } finally { + setMutating(false); + } + }; + + const persistOrder = async (nextItems: ProgramScheduleItem[]) => { + const previousSelectedId = effectiveSelectedItemId; + setSelectedItemId((current) => current ?? previousSelectedId ?? nextItems[0]?.id ?? null); + + try { + setMutationError(null); + setMutating(true); + const result = await replaceScheduleItems(activeSchedule.id, { + items: nextItems.map(scheduleItemToRequest) + }); + setSelectedItemId(previousSelectedId ?? result[0]?.id ?? null); + query.refresh(); + } catch (error: unknown) { + setSelectedItemId(previousSelectedId); + setMutationError(messageFromError(error)); + } finally { + setMutating(false); + } + }; + + const moveItem = (itemId: number, direction: -1 | 1) => { + if (mutating) { + 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 || mutating) { + 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 = firstCollection(pickers.collections); + + if (!collection) { + setMutationError('A collection is required before adding a schedule item'); + return; + } + + void refreshAfterMutation(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 refreshAfterMutation(async () => { + await deleteScheduleItem(activeSchedule.id, itemId); + setSelectedItemId(null); + }); + }; + + return ( +
+
+ +
+

{activeSchedule.name ?? 'Unnamed schedule'}

+

+ {schedules.length} schedule{schedules.length === 1 ? '' : 's'} · {orderedItems.length} item{orderedItems.length === 1 ? '' : 's'} · programs {totalDurationEstimate ?? 'unknown'} +

+
+ + +
+ + {mutationError && ( +
+
+ )} + +
+
+
+ Lineup · drag to reorder + +
+ + {orderedItems.length === 0 ? ( +
No schedule items
+ ) : ( +
+ {orderedItems.map((item, index) => ( + + ))} +
+ )} +
+ + setInspectorTab(value as ScheduleInspectorTab)} + /> +
+
+ ); +} + +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) => { + event.preventDefault(); + onOver(itemId); + }; + + return ( +
+ +
{ + onStartDrag(null); + onOver(null); + }} + onDragOver={onDragOver} + onDragStart={() => onStartDrag(itemId)} + onDrop={() => { + if (itemId != null) { + onDrop(itemId); + } + }} + > + +
+ + {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 ( +
+
Select or add a schedule item
+
+ ); + } + + const name = scheduleItemName(item); + const fill = scheduleFillDescriptor(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} +
+ +
+ {tab === 'content' && ( + <> +
+ +
+ + +
+ +
+ + )} + {tab === 'playback' && ( + <> + +
+ + +
+
+ {fill.icon} + {playbackExplanation(item)} +
+ + )} + {tab === 'filler' && ( +
+ + +
+ )} + {tab === 'overrides' && ( +
+ + + +
+ )} +
+
+ ); +} + +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: