diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 8f800880e..fdc48bc6f 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1993,6 +1993,350 @@ describe('ChicoryTV SPA scaffold', () => { expect(container.querySelector('.lucide-triangle-alert')).toBeInTheDocument(); expect(container.querySelector('.lucide-circle-x')).toBeInTheDocument(); }); + + // ---- Channel Builder (#89) ----------------------------------------------- + const renderBuilder = async () => { + window.history.replaceState(null, '', '/app/new-channel'); + const utils = render(); + await screen.findByPlaceholderText('Search shows & movies…'); + return utils; + }; + + // The shell's nav link and top-bar primary action also read "New Channel" / + // "Create Channel"; scope these to the builder toolbar. + const createBtn = () => + within(document.querySelector('.ctv-builder-toolbar') as HTMLElement).getByRole('button', { + name: 'Create Channel' + }); + const builderTitle = () => screen.getByText('New Channel', { selector: '.ctv-builder-title' }); + const numberField = () => screen.getByLabelText('Number', { exact: false }) as HTMLInputElement; + + const builderDefaults = () => ({ + channels: [channelSummary()], + channelTemplates: [channelTemplate()], + defaultChannelTemplate: channelTemplate(), + ffmpegProfiles: [ffmpegProfile()] + }); + + it('renders the builder three-column layout with library items and the default template', async () => { + mockDashboardApi({ + ...builderDefaults(), + browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })] + }); + + const { container } = await renderBuilder(); + + expect(builderTitle()).toBeInTheDocument(); + expect(screen.getByText('Lineup')).toBeInTheDocument(); + expect(screen.getByText('Channel Template')).toBeInTheDocument(); + expect(screen.getByText('Standard')).toBeInTheDocument(); + expect(await screen.findByText('Looney Tunes')).toBeInTheDocument(); + expect(screen.getByText('Tom & Jerry')).toBeInTheDocument(); + expect(container.querySelectorAll('.ctv-builder-col')).toHaveLength(3); + expect(screen.getByText('This channel has no content yet. Double-click or drag titles from the library to build the lineup.')).toBeInTheDocument(); + }); + + it('computes the AUTO channel number from the max integer part of existing channels', async () => { + mockDashboardApi({ + ...builderDefaults(), + channels: [channelSummary({ id: 1, number: '5' }), channelSummary({ id: 2, number: '13.1' })], + browseItems: [browseItem()] + }); + + await renderBuilder(); + + const numberInput = numberField(); + expect(numberInput.value).toBe('14'); + expect(screen.getByText('AUTO')).toBeInTheDocument(); + }); + + it('adds a library item to the lineup on double-click and dedupes repeats', async () => { + mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] }); + + const { container } = await renderBuilder(); + + fireEvent.doubleClick(await screen.findByText('Looney Tunes')); + expect(container.querySelector('.ctv-builder-summary')?.textContent).toContain('1 in lineup'); + expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1); + + fireEvent.doubleClick(screen.getAllByText('Looney Tunes')[0]); + expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1); + }); + + it('removes a lineup item and clears the lineup through the confirm dialog', async () => { + mockDashboardApi({ + ...builderDefaults(), + browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })] + }); + + const { container } = await renderBuilder(); + + fireEvent.doubleClick(await screen.findByText('Looney Tunes')); + fireEvent.doubleClick(screen.getByText('Tom & Jerry')); + expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(2); + + fireEvent.click(within(container.querySelectorAll('.ctv-builder-lineup-row')[0] as HTMLElement).getByRole('button', { name: 'Remove' })); + expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1); + + fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + fireEvent.click(await screen.findByRole('button', { name: 'Clear lineup' })); + await waitFor(() => expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(0)); + }); + + it('filters library results through the debounced search query', async () => { + mockDashboardApi({ + ...builderDefaults(), + browseHandler: (search) => { + const query = (search.get('query') ?? '').toLowerCase(); + const all = [ + browseItem(), + browseItem({ id: 2, mediaItemId: 2, mediaType: 'Movie', collectionType: 'Movie', title: 'Blade Runner', libraryName: 'Movies' }) + ]; + const page = query ? all.filter((item) => String(item.title).toLowerCase().includes(query)) : all; + return { page, totalCount: page.length }; + } + }); + + await renderBuilder(); + + expect(await screen.findByText('Blade Runner')).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText('Search shows & movies…'), { target: { value: 'blade' } }); + + await waitFor(() => expect(screen.queryByText('Looney Tunes')).not.toBeInTheDocument()); + expect(screen.getByText('Blade Runner')).toBeInTheDocument(); + }); + + it('reorders lineup items with native drag and drop', async () => { + mockDashboardApi({ + ...builderDefaults(), + browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })] + }); + + const { container } = await renderBuilder(); + + fireEvent.doubleClick(await screen.findByText('Looney Tunes')); + fireEvent.doubleClick(screen.getByText('Tom & Jerry')); + + let rows = container.querySelectorAll('.ctv-builder-lineup-row'); + expect(rows[0].querySelector('.ctv-builder-row-title')?.textContent).toBe('Looney Tunes'); + + fireEvent.dragStart(rows[0]); + fireEvent.dragOver(rows[1]); + fireEvent.drop(rows[1]); + + rows = container.querySelectorAll('.ctv-builder-lineup-row'); + expect(rows[0].querySelector('.ctv-builder-row-title')?.textContent).toBe('Tom & Jerry'); + expect(rows[1].querySelector('.ctv-builder-row-title')?.textContent).toBe('Looney Tunes'); + }); + + it('validates the channel number for format and uniqueness', async () => { + mockDashboardApi({ + ...builderDefaults(), + channels: [channelSummary({ number: '5' })], + browseItems: [browseItem()] + }); + + await renderBuilder(); + + const numberInput = numberField(); + + fireEvent.change(numberInput, { target: { value: 'abc' } }); + expect(await screen.findByText(/Use digits/)).toBeInTheDocument(); + + fireEvent.change(numberInput, { target: { value: '5' } }); + expect(await screen.findByText(/already in use/)).toBeInTheDocument(); + }); + + it('keeps Create disabled until a name and at least one lineup item exist', async () => { + mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] }); + + await renderBuilder(); + + const createButton = createBtn(); + expect(createButton).toBeDisabled(); + + fireEvent.doubleClick(await screen.findByText('Looney Tunes')); + fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro Cartoons' } }); + + expect(createButton).toBeEnabled(); + }); + + it('preselects the default template and flags an override when the Shuffle toggle diverges', async () => { + mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] }); + + const { container } = await renderBuilder(); + + // default template has shuffleScheduleItems=false -> no override tag yet + expect(screen.queryByText('overrides template')).not.toBeInTheDocument(); + + const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement; + fireEvent.click(shuffleRow); + + expect(await screen.findByText('overrides template')).toBeInTheDocument(); + }); + + it('applies a selected template shuffle default and clears the override', async () => { + mockDashboardApi({ + ...builderDefaults(), + channelTemplates: [ + channelTemplate(), + channelTemplate({ id: 11, isDefault: false, name: 'Music videos', shuffleScheduleItems: true }) + ], + browseItems: [browseItem()] + }); + + const { container } = await renderBuilder(); + + const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement; + fireEvent.click(shuffleRow); + expect(await screen.findByText('overrides template')).toBeInTheDocument(); + + // open the template picker and select the shuffle-by-default template + fireEvent.click(screen.getByText('Standard')); + fireEvent.click(await screen.findByText('Music videos')); + + await waitFor(() => expect(screen.queryByText('overrides template')).not.toBeInTheDocument()); + }); + + it('marks a rerun-collection row invalid in a multi-item lineup and blocks Create', async () => { + mockDashboardApi({ + ...builderDefaults(), + browseItems: [ + browseItem(), + browseItem({ id: 9, title: 'Saturday Block', mediaType: 'RerunCollection', collectionType: 'RerunFirstRun', rerunCollectionId: 9, mediaItemId: undefined }) + ] + }); + + await renderBuilder(); + + fireEvent.doubleClick(await screen.findByText('Looney Tunes')); + fireEvent.doubleClick(screen.getByText('Saturday Block')); + fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Mixed' } }); + + expect(await screen.findByText('Only valid alone')).toBeInTheDocument(); + expect(createBtn()).toBeDisabled(); + }); + + it('clears a MultiCollection shuffle requirement when the Shuffle toggle is turned on', async () => { + mockDashboardApi({ + ...builderDefaults(), + browseItems: [ + browseItem({ id: 7, title: 'Prime Time', mediaType: 'MultiCollection', collectionType: 'MultiCollection', multiCollectionId: 7, mediaItemId: undefined }) + ] + }); + + const { container } = await renderBuilder(); + + fireEvent.doubleClick(await screen.findByText('Prime Time')); + fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Multi' } }); + + expect(await screen.findByText('Requires Shuffle')).toBeInTheDocument(); + expect(createBtn()).toBeDisabled(); + + const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement; + fireEvent.click(shuffleRow); + + await waitFor(() => expect(screen.queryByText('Requires Shuffle')).not.toBeInTheDocument()); + expect(createBtn()).toBeEnabled(); + }); + + it('posts a well-formed create request and navigates to channels on success', async () => { + mockDashboardApi({ + ...builderDefaults(), + channels: [channelSummary({ number: '5' })], + browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })] + }); + + await renderBuilder(); + + fireEvent.doubleClick(await screen.findByText('Looney Tunes')); + fireEvent.doubleClick(screen.getByText('Tom & Jerry')); + fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: ' Retro Cartoons ' } }); + + fireEvent.click(createBtn()); + + await waitFor(() => expect(window.location.pathname).toBe('/app/channels')); + + const body = requestBodyFor('/api/channels/from-lineup'); + expect(body.name).toBe('Retro Cartoons'); + expect(body.number).toBe('6'); + expect(body.group).toBe('ErsatzTV'); + expect(body.templateId).toBe(10); + expect(body.logo).toEqual({ contentType: '', path: '' }); + expect((body.advanced as Record).playbackOrder).toBe('Chronological'); + expect((body.advanced as Record).playoutMode).toBe('Continuous'); + const lineup = body.lineup as Array>; + expect(lineup).toHaveLength(2); + expect(lineup[0].mediaItemId).toBe(1); + expect(lineup[1].mediaItemId).toBe(2); + expect(lineup[0].mediaType).toBe('TelevisionShow'); + }); + + it('highlights the offending lineup row and shows the detail on a 422 ProblemDetails', async () => { + mockDashboardApi({ + ...builderDefaults(), + browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })], + fromLineupFailure: { detail: 'lineup[1] references a missing media item.', status: 422, title: 'Invalid lineup' } + }); + + const { container } = await renderBuilder(); + + fireEvent.doubleClick(await screen.findByText('Looney Tunes')); + fireEvent.doubleClick(screen.getByText('Tom & Jerry')); + fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } }); + + fireEvent.click(createBtn()); + + expect(await screen.findByText('lineup[1] references a missing media item.')).toBeInTheDocument(); + await waitFor(() => { + const rows = container.querySelectorAll('.ctv-builder-lineup-row'); + expect(rows[1].classList.contains('ctv-builder-lineup-row-error')).toBe(true); + }); + expect(window.location.pathname).toBe('/app/new-channel'); + }); + + it('uploads the channel image then references the returned path in the create body', async () => { + mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] }); + + const { container } = await renderBuilder(); + + fireEvent.doubleClick(await screen.findByText('Looney Tunes')); + fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } }); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['logo-bytes'], 'logo.png', { type: 'image/png' }); + fireEvent.change(fileInput, { target: { files: [file] } }); + + fireEvent.click(createBtn()); + + await waitFor(() => expect(window.location.pathname).toBe('/app/channels')); + + const uploadCall = vi + .mocked(window.fetch) + .mock.calls.find(([input]) => input.toString() === '/api/artwork/uploads'); + expect(uploadCall).toBeDefined(); + const formData = uploadCall?.[1]?.body as FormData; + expect(formData).toBeInstanceOf(FormData); + expect(formData.get('target')).toBe('logo'); + expect((formData.get('file') as File).name).toBe('logo.png'); + + const body = requestBodyFor('/api/channels/from-lineup'); + expect(body.logo).toEqual({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' }); + }); + + it('renders the builder under the cool and dual design themes', async () => { + mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] }); + + await renderBuilder(); + + applyDesignSystemTheme('cool'); + expect(document.documentElement).toHaveAttribute('data-theme', 'cool'); + expect(builderTitle()).toBeInTheDocument(); + + applyDesignSystemTheme('dual'); + expect(document.documentElement).toHaveAttribute('data-theme', 'dual'); + expect(screen.getByPlaceholderText('Search shows & movies…')).toBeInTheDocument(); + }); }); function jsonResponse(body: unknown, status = 200): Response { @@ -2047,6 +2391,93 @@ function mediaSource(overrides: Record = {}): Record = {}): Record { + return { + categories: '', + fFmpegProfile: '1080p H.264', + group: 'ErsatzTV', + id: 1, + isEnabled: true, + language: 'English', + name: 'Movies', + number: '5', + showInEpg: true, + sortNumber: 5, + streamingMode: 'HttpLiveStreamingSegmenter', + ...overrides + }; +} + +// ChannelTemplateResponseModel +function channelTemplate(overrides: Record = {}): Record { + return { + description: 'General-purpose 1080p H.264.', + fFmpegProfileId: 100, + fallbackFillerId: null, + fixedStartTimeBehavior: 'Strict', + id: 10, + idleBehavior: 'StopOnDisconnect', + isDefault: true, + isSystem: true, + midRollFillerId: null, + musicVideoCreditsMode: 'None', + musicVideoCreditsTemplate: null, + name: 'Standard', + playoutMode: 'Continuous', + playoutSource: 'Generated', + postRollFillerId: null, + preRollFillerId: null, + preferredAudioLanguageCode: null, + preferredAudioTitle: null, + preferredSubtitleLanguageCode: null, + randomStartPoint: false, + shuffleScheduleItems: false, + songVideoMode: 'Default', + streamSelector: null, + streamSelectorMode: 'Default', + streamingMode: 'HttpLiveStreamingSegmenter', + subtitleMode: 'None', + transcodeMode: 'OnDemand', + watermarkId: null, + ...overrides + }; +} + +// LibraryBrowseItemResponseModel - Newtonsoft omits null fields, so exactly one +// typed id is populated and nullable metadata (duration/itemCount) is absent +// unless the caller supplies it. +function browseItem(overrides: Record = {}): Record { + return { + artwork: '', + collectionType: 'TelevisionShow', + id: 1, + libraryId: 31, + libraryName: 'Cartoons', + mediaItemId: 1, + mediaType: 'TelevisionShow', + title: 'Looney Tunes', + ...overrides + }; +} + +// FFmpegFullProfileResponseModel (only id/name are read by the builder) +function ffmpegProfile(overrides: Record = {}): Record { + return { id: 100, name: '1080p H.264', ...overrides }; +} + +// Read the JSON body captured for a mutating request to `path`. +function requestBodyFor(path: string): Record { + const call = vi + .mocked(window.fetch) + .mock.calls.find(([input, init]) => input.toString() === path && init?.body != null); + if (!call) { + throw new Error(`no request captured for ${path}`); + } + return JSON.parse(call[1]?.body as string) as Record; +} + // PlayoutListItemResponseModel: rail rows have no detail-only fields (playoutMode/scheduleFile). function listPlayout(overrides: Record = {}): Record { return { @@ -2190,8 +2621,17 @@ function scheduleItemRequest(item: Record): Record { page: unknown[]; totalCount: number }) | null; + browseItems?: unknown[]; channels?: unknown[]; channelStates?: unknown[]; + channelTemplates?: unknown[]; + createTemplateResponse?: unknown; + defaultChannelTemplate?: unknown; + ffmpegProfiles?: unknown[]; + fromLineupFailure?: { detail?: string; status?: number; title?: string } | null; + fromLineupResponse?: unknown; + watermarks?: unknown[]; collections?: unknown[]; confirm?: boolean; fillerPresets?: unknown[]; @@ -2285,6 +2734,43 @@ function mockDashboardApi({ return Promise.resolve(jsonResponse(channelStates)); } + // ---- Channel Builder (#89) endpoints (before the generic /api/channels/) ---- + if (path === '/api/channels/from-lineup') { + if (fromLineupFailure) { + return Promise.resolve(jsonResponse(fromLineupFailure, fromLineupFailure.status ?? 422)); + } + return Promise.resolve(jsonResponse(fromLineupResponse, 201)); + } + + if (path.startsWith('/api/library/browse')) { + const search = new URL(path, window.location.origin).searchParams; + const result = browseHandler + ? browseHandler(search) + : { page: browseItems, totalCount: browseItems.length }; + return Promise.resolve(jsonResponse(result)); + } + + if (path === '/api/channel-templates') { + if ((init?.method ?? 'GET') === 'POST') { + return Promise.resolve(jsonResponse(createTemplateResponse ?? channelTemplate(), 201)); + } + return Promise.resolve(jsonResponse(channelTemplates)); + } + + if (path === '/api/channel-templates/default') { + return Promise.resolve( + defaultChannelTemplate ? jsonResponse(defaultChannelTemplate) : jsonResponse(null, 404) + ); + } + + if (path === '/api/ffmpeg/profiles') { + return Promise.resolve(jsonResponse(ffmpegProfiles)); + } + + if (path === '/api/artwork/uploads') { + return Promise.resolve(jsonResponse({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' }, 201)); + } + if (path.startsWith('/api/guide?')) { if (remainingGuideFailures > 0) { remainingGuideFailures -= 1; @@ -2352,7 +2838,7 @@ function mockDashboardApi({ } if (path === '/api/watermarks') { - return Promise.resolve(jsonResponse([])); + return Promise.resolve(jsonResponse(watermarks)); } if (path === '/api/media-sources') { diff --git a/web/src/App.tsx b/web/src/App.tsx index 13fbc8cb3..b7c4a61a1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -53,6 +53,8 @@ import { Tv } from 'lucide-react'; import chicoryIconUrl from '../../design-system/assets/chicorytv-icon.svg'; +import { ChannelBuilderScreen } from './builder/ChannelBuilder'; +import { navigateToPath } from './routing'; import { Badge, Button, @@ -271,10 +273,6 @@ function routeHref(route: ScreenRoute): string { return route.id === 'dashboard' ? '/app' : route.path; } -function navigateToPath(path: string) { - window.history.pushState(null, '', path); - window.dispatchEvent(new PopStateEvent('popstate')); -} function SidebarNavGroup({ activeRoute, @@ -2822,6 +2820,10 @@ function ScreenContent({ return ; } + if (route.id === 'builder') { + return ; + } + if (route.id === 'guide') { return ; } diff --git a/web/src/builder/ChannelBuilder.tsx b/web/src/builder/ChannelBuilder.tsx new file mode 100644 index 000000000..cb8f4be9d --- /dev/null +++ b/web/src/builder/ChannelBuilder.tsx @@ -0,0 +1,1972 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type ReactNode +} from 'react'; +import { + Check, + ChevronDown, + ChevronRight, + ChevronUp, + Circle, + CircleCheck, + Eye, + Film, + FolderTree, + GripVertical, + Image as ImageIcon, + Layers, + Library, + LayoutGrid, + ListOrdered, + ListVideo, + Music, + Plus, + Radio, + Repeat, + Rows3, + Save, + Search, + Shuffle, + SlidersHorizontal, + Sparkles, + SquarePen, + Tv, + X +} from 'lucide-react'; +import { navigateToPath } from '../routing'; +import { + ApiError, + createChannelFromLineup, + createChannelTemplate, + getChannelTemplates, + getChannels, + getDefaultChannelTemplate, + getFFmpegProfiles, + getFillerPresets, + getLibraryBrowseItems, + getMediaSources, + getWatermarks, + messageFromError, + uploadArtwork, + type ChannelSummary, + type ChannelTemplate, + type CreateChannelFromLineupRequest, + type CreateChannelTemplateRequest, + type FFmpegProfile, + type FillerPreset, + type LibraryBrowseItem, + type LibraryBrowseMediaType, + type MediaSource, + type Watermark +} from '../api'; +import { + Badge, + Button, + ChannelLogo, + Dialog, + ConfirmDialog, + IconButton, + Input, + Select, + Spinner, + Switch, + Tooltip +} from '../components'; + +// ---- Enum unions (hand-listed from generated v1.d.ts; keep in sync) -------- +const PLAYBACK_ORDERS = [ + 'None', + 'Chronological', + 'Random', + 'Shuffle', + 'ShuffleInOrder', + 'MultiEpisodeShuffle', + 'SeasonEpisode', + 'RandomRotation', + 'Marathon' +] as const; +type PlaybackOrder = (typeof PLAYBACK_ORDERS)[number]; + +const STREAMING_MODES = [ + 'TransportStream', + 'HttpLiveStreamingDirect', + 'HttpLiveStreamingSegmenter', + 'TransportStreamHybrid' +] as const; +type StreamingMode = (typeof STREAMING_MODES)[number]; + +const SUBTITLE_MODES = ['None', 'Forced', 'Default', 'Any'] as const; +type SubtitleMode = (typeof SUBTITLE_MODES)[number]; + +const STREAM_SELECTOR_MODES = ['Default', 'Custom', 'Troubleshooting'] as const; +type StreamSelectorMode = (typeof STREAM_SELECTOR_MODES)[number]; + +const MUSIC_VIDEO_CREDITS_MODES = ['None', 'GenerateSubtitles'] as const; +type MusicVideoCreditsMode = (typeof MUSIC_VIDEO_CREDITS_MODES)[number]; + +const SONG_VIDEO_MODES = ['Default', 'WithProgress'] as const; +type SongVideoMode = (typeof SONG_VIDEO_MODES)[number]; + +const IDLE_BEHAVIORS = ['StopOnDisconnect', 'KeepRunning'] as const; +type IdleBehavior = (typeof IDLE_BEHAVIORS)[number]; + +const FIXED_START_TIME_BEHAVIORS = ['Strict', 'Flexible'] as const; +type FixedStartTimeBehavior = (typeof FIXED_START_TIME_BEHAVIORS)[number]; + +const TRANSCODE_MODES = ['OnDemand'] as const; +type TranscodeMode = (typeof TRANSCODE_MODES)[number]; + +type PlayoutMode = 'Continuous' | 'OnDemand'; + +// Streaming mode friendly labels for chips / selects. +const STREAMING_MODE_LABELS: Record = { + TransportStream: 'MPEG-TS', + HttpLiveStreamingDirect: 'HLS Direct', + HttpLiveStreamingSegmenter: 'HLS Segmenter', + TransportStreamHybrid: 'MPEG-TS Hybrid' +}; + +// ---- Media-type presentation ---------------------------------------------- +const TYPE_ICON: Record = { + Movie: Film, + TelevisionShow: Tv, + TelevisionSeason: Tv, + Artist: Music, + Collection: FolderTree, + SmartCollection: Sparkles, + MultiCollection: Layers, + RerunCollection: Repeat, + Playlist: ListVideo +}; + +const TYPE_LABEL: Record = { + Movie: 'Movie', + TelevisionShow: 'Show', + TelevisionSeason: 'Season', + Artist: 'Artist', + Collection: 'Collection', + SmartCollection: 'Smart', + MultiCollection: 'Multi', + RerunCollection: 'Rerun', + Playlist: 'Playlist' +}; + +const COLLECTION_MEDIA_TYPES: LibraryBrowseMediaType[] = [ + 'Collection', + 'SmartCollection', + 'MultiCollection', + 'RerunCollection', + 'Playlist' +]; + +// ---- Small pure helpers ---------------------------------------------------- +const mono: CSSProperties = { fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums' }; + +// deterministic low-chroma poster hue from a title (ported from prototype) +function hueOf(value: string): number { + let hue = 0; + for (let index = 0; index < value.length; index += 1) { + hue = (hue * 31 + value.charCodeAt(index)) % 360; + } + return hue; +} + +// Parse a .NET TimeSpan (`[d.]hh:mm:ss[.fffffff]`) or ISO8601 duration to seconds. +function parseDurationSeconds(value: string | null | undefined): number | null { + if (!value) { + return null; + } + + const timeSpan = /^(?:(\d+)\.)?(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?$/.exec(value); + if (timeSpan) { + const [, days, hours, minutes, seconds] = timeSpan; + return ( + (days ? Number(days) * 86400 : 0) + Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds) + ); + } + + const iso = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(value); + if (iso) { + const [, days, hours, minutes, seconds] = iso; + return ( + (days ? Number(days) * 86400 : 0) + + (hours ? Number(hours) * 3600 : 0) + + (minutes ? Number(minutes) * 60 : 0) + + (seconds ? Number(seconds) : 0) + ); + } + + return null; +} + +function formatDuration(totalSeconds: number): string { + const totalMinutes = Math.round(totalSeconds / 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours && minutes) { + return `${hours}h ${String(minutes).padStart(2, '0')}m`; + } + if (hours) { + return `${hours}h`; + } + return `${minutes}m`; +} + +// Meta line for a browse item: duration and/or item count (both nullable -> omitted). +function itemMeta(item: LibraryBrowseItem): string { + const parts: string[] = []; + const durationSeconds = parseDurationSeconds(item.duration); + if (durationSeconds) { + parts.push(formatDuration(durationSeconds)); + } + if (item.itemCount) { + parts.push(`${item.itemCount} items`); + } + return parts.join(' · '); +} + +function lineupKey(item: LibraryBrowseItem): string { + return `${item.mediaType}:${item.id}`; +} + +// The integer part of a channel number that parses; used for AUTO suggestion. +function suggestChannelNumber(channels: ChannelSummary[]): string { + let max = 0; + let seen = false; + for (const channel of channels) { + const parsed = Number.parseFloat(channel.number); + if (Number.isFinite(parsed)) { + seen = true; + max = Math.max(max, Math.floor(parsed)); + } + } + return seen ? String(max + 1) : '1'; +} + +const NUMBER_PATTERN = /^[0-9]+(\.[0-9]{1,2})?$/; + +// ---- Segmented control (builder-local, sliding highlight) ------------------ +interface SegmentedOption { + value: T; + label?: string; + icon?: ReactNode; + title?: string; +} + +function Segmented({ + value, + options, + onChange +}: { + value: T; + options: SegmentedOption[]; + onChange: (next: T) => void; +}) { + const index = Math.max( + 0, + options.findIndex((option) => option.value === value) + ); + + return ( +
+
+ {options.map((option) => { + const on = option.value === value; + return ( + + ); + })} +
+ ); +} + +// ---- Poster placeholder ---------------------------------------------------- +function Poster({ item, width, height, mini }: { item: LibraryBrowseItem; width: number | string; height: number; mini?: boolean }) { + const hue = hueOf(item.title); + const Icon = TYPE_ICON[item.mediaType] ?? Film; + + return ( +
+ {item.artwork ? ( + + ) : ( +
+ )} + + + {!mini && ( +
+
{item.title}
+ {itemMeta(item) &&
{itemMeta(item)}
} +
+ )} +
+ ); +} + +// ---- Library card ---------------------------------------------------------- +function LibCard({ + item, + added, + compact, + onAdd, + onDragStart, + onDragEnd +}: { + item: LibraryBrowseItem; + added: boolean; + compact: boolean; + onAdd: () => void; + onDragStart: () => void; + onDragEnd: () => void; +}) { + const meta = itemMeta(item); + + if (compact) { + return ( +
+ +
+
{item.title}
+
+ {TYPE_LABEL[item.mediaType]} + {meta && ( + <> + {' · '} + {meta} + + )} +
+
+ + {added ? ( + +
+ ); + } + + return ( +
+
+ + {added && ( +
+
+ )} +
+
+ ); +} + +// ---- Library browse data hook --------------------------------------------- +interface BrowseState { + status: 'loading' | 'error' | 'success'; + items: LibraryBrowseItem[]; + totalCount: number; + error: string | null; +} + +async function loadCollections(query: string): Promise<{ page: LibraryBrowseItem[]; totalCount: number }> { + const results = await Promise.all( + COLLECTION_MEDIA_TYPES.map((mediaType) => getLibraryBrowseItems({ query, mediaType, pageSize: 100 })) + ); + const merged = results + .flatMap((result) => result.page) + .sort((left, right) => left.title.localeCompare(right.title, undefined, { sensitivity: 'base' })); + return { page: merged, totalCount: merged.length }; +} + +function useLibraryBrowse(source: 'library' | 'collections', query: string, libraryId: number | null) { + const [state, setState] = useState({ status: 'loading', items: [], totalCount: 0, error: null }); + const [loadingMore, setLoadingMore] = useState(false); + const reqRef = useRef(0); + const pageRef = useRef(0); + + const runFetch = useCallback( + (pageNum: number, reqId: number, append: boolean) => { + const promise = + source === 'collections' + ? loadCollections(query) + : getLibraryBrowseItems({ + query: query || undefined, + libraryId: libraryId ?? undefined, + pageNum, + pageSize: 100 + }); + + promise + .then((result) => { + if (reqRef.current !== reqId) { + return; + } + setState((prev) => ({ + status: 'success', + error: null, + items: append ? [...prev.items, ...result.page] : result.page, + totalCount: result.totalCount + })); + }) + .catch((error: unknown) => { + if (reqRef.current !== reqId) { + return; + } + setState({ status: 'error', items: [], totalCount: 0, error: messageFromError(error) }); + }) + .finally(() => { + if (reqRef.current === reqId && append) { + setLoadingMore(false); + } + }); + }, + [source, query, libraryId] + ); + + // Refetch when inputs change. Following the guide-hook pattern, setState only + // runs inside runFetch's async callbacks - the previous results stay visible + // until the new page resolves (initial 'loading' shows the first-load spinner). + useEffect(() => { + const reqId = reqRef.current + 1; + reqRef.current = reqId; + pageRef.current = 0; + runFetch(0, reqId, false); + }, [runFetch]); + + const loadMore = useCallback(() => { + const nextPage = pageRef.current + 1; + pageRef.current = nextPage; + setLoadingMore(true); + runFetch(nextPage, reqRef.current, true); + }, [runFetch]); + + const canLoadMore = source === 'library' && state.status === 'success' && state.items.length < state.totalCount; + + return { state, loadingMore, loadMore, canLoadMore }; +} + +// ---- Builder support data (templates, pickers, channels) ------------------- +interface BuilderData { + channels: ChannelSummary[]; + templates: ChannelTemplate[]; + defaultTemplate: ChannelTemplate | null; + ffmpegProfiles: FFmpegProfile[]; + fillerPresets: FillerPreset[]; + watermarks: Watermark[]; + mediaSources: MediaSource[]; +} + +// ---- Effective settings ---------------------------------------------------- +// Templates carry no playbackOrder; the Shuffle toggle *is* the template's +// declared shuffle intent (template.shuffleScheduleItems -> Shuffle/Chronological). +function templatePlaybackOrder(template: ChannelTemplate): PlaybackOrder { + return template.shuffleScheduleItems ? 'Shuffle' : 'Chronological'; +} + +// Advanced field keys wired into request.advanced (playbackOrder + playoutMode +// live in dedicated state and are ALWAYS sent, so they are excluded here). +const ADVANCED_KEYS = [ + 'fFmpegProfileId', + 'watermarkId', + 'fallbackFillerId', + 'preRollFillerId', + 'midRollFillerId', + 'postRollFillerId', + 'streamSelectorMode', + 'streamSelector', + 'preferredAudioLanguageCode', + 'preferredAudioTitle', + 'streamingMode', + 'preferredSubtitleLanguageCode', + 'subtitleMode', + 'musicVideoCreditsMode', + 'songVideoMode', + 'transcodeMode', + 'idleBehavior', + 'randomStartPoint', + 'shuffleScheduleItems', + 'fixedStartTimeBehavior' +] as const; +type AdvancedKey = (typeof ADVANCED_KEYS)[number]; + +type Overrides = Partial>; + +// The current effective value for an advanced field = user override (if any) +// else the selected template's value. +function effectiveValue(key: AdvancedKey, template: ChannelTemplate, overrides: Overrides): unknown { + if (key in overrides) { + return overrides[key]; + } + return (template as unknown as Record)[key]; +} + +const NONE_OPTION = { value: '', label: 'None' }; + +function toIdOverride(raw: string): number | null { + return raw ? Number(raw) : null; +} + +// ---- Main screen ----------------------------------------------------------- +interface BuilderDataState { + status: 'loading' | 'error' | 'success'; + data: BuilderData | null; + error: string | null; +} + +export function ChannelBuilderScreen() { + const [state, setState] = useState({ status: 'loading', data: null, error: null }); + const mountedRef = useRef(true); + + // Pure async loader: setState runs only in the .then/.catch callbacks, so the + // mount effect never triggers a synchronous state update ('loading' is the + // initial state). The Retry handler resets to loading itself (event handler). + const runLoad = useCallback(() => { + Promise.all([ + getChannels(), + getChannelTemplates(), + getDefaultChannelTemplate(), + getFFmpegProfiles(), + getFillerPresets(), + getWatermarks(), + getMediaSources() + ]) + .then(([channels, templates, defaultTemplate, ffmpegProfiles, fillerPresets, watermarks, mediaSources]) => { + if (!mountedRef.current) { + return; + } + setState({ + status: 'success', + data: { channels, templates, defaultTemplate, ffmpegProfiles, fillerPresets, watermarks, mediaSources }, + error: null + }); + }) + .catch((error: unknown) => { + if (!mountedRef.current) { + return; + } + setState({ status: 'error', data: null, error: messageFromError(error) }); + }); + }, []); + + useEffect(() => { + mountedRef.current = true; + runLoad(); + return () => { + mountedRef.current = false; + }; + }, [runLoad]); + + const retry = () => { + setState({ status: 'loading', data: null, error: null }); + runLoad(); + }; + + const setData = useCallback((updater: (prev: BuilderData | null) => BuilderData | null) => { + setState((prev) => (prev.status === 'success' ? { ...prev, data: updater(prev.data) } : prev)); + }, []); + + if (state.status === 'loading') { + return ( +
+
+ + Loading channel builder +
+
+ ); + } + + if (state.status === 'error' || !state.data) { + return ( +
+
+ Channel builder unavailable + {state.error} +
+ +
+
+
+ ); + } + + return ; +} + +function ChannelBuilder({ + data, + onDataChange +}: { + data: BuilderData; + onDataChange: (updater: (prev: BuilderData | null) => BuilderData | null) => void; +}) { + // ---- library browser state ---- + const [source, setSource] = useState<'library' | 'collections'>('library'); + const [searchInput, setSearchInput] = useState(''); + const [query, setQuery] = useState(''); + const [compact, setCompact] = useState(false); + const [libraryId, setLibraryId] = useState(null); + + // debounce search input -> query + useEffect(() => { + const timer = window.setTimeout(() => setQuery(searchInput.trim()), 280); + return () => window.clearTimeout(timer); + }, [searchInput]); + + const browse = useLibraryBrowse(source, query, libraryId); + + // ---- lineup ---- + const [lineup, setLineup] = useState([]); + const addedKeys = new Set(lineup.map(lineupKey)); + + // drag state + const [dragLib, setDragLib] = useState(null); + const [dragRow, setDragRow] = useState(null); + const [overRow, setOverRow] = useState(null); + + // ---- rail: identity ---- + const [name, setName] = useState(''); + // AUTO number: max integer part of existing channel numbers + 1 (empty -> "1"). + const suggestion = suggestChannelNumber(data.channels); + const [number, setNumber] = useState(() => suggestion); + + // ---- rail: image ---- + const [logoFile, setLogoFile] = useState(null); + const [logoPreview, setLogoPreview] = useState(null); + const [imageError, setImageError] = useState(null); + const [separateOpen, setSeparateOpen] = useState(false); + const [dropOver, setDropOver] = useState(false); + const fileInputRef = useRef(null); + + // ---- rail: template + settings ---- + const initialTemplate = data.defaultTemplate ?? data.templates.find((t) => t.isDefault) ?? data.templates[0] ?? null; + const [templateId, setTemplateId] = useState(initialTemplate?.id ?? null); + const template = data.templates.find((t) => t.id === templateId) ?? initialTemplate; + const [tplOpen, setTplOpen] = useState(false); + + const [playbackOrder, setPlaybackOrder] = useState( + template ? templatePlaybackOrder(template) : 'Chronological' + ); + const [playoutMode, setPlayoutMode] = useState((template?.playoutMode as PlayoutMode) ?? 'Continuous'); + const [overrides, setOverrides] = useState({}); + const [group, setGroup] = useState('ErsatzTV'); + const [categories, setCategories] = useState(''); + + const [advMode, setAdvMode] = useState<'closed' | 'view' | 'override'>('closed'); + + // ---- create/save flow ---- + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const [errorRowIndex, setErrorRowIndex] = useState(null); + const creatingRef = useRef(false); + + const [confirmClear, setConfirmClear] = useState(false); + const [confirmCancel, setConfirmCancel] = useState(false); + + const [saveOpen, setSaveOpen] = useState(false); + const [saveName, setSaveName] = useState(''); + const [saveDesc, setSaveDesc] = useState(''); + const [saveBusy, setSaveBusy] = useState(false); + const [saveError, setSaveError] = useState(null); + + const railScrollRef = useRef(null); + + // ---- derived ---- + const shuffleOn = playbackOrder === 'Shuffle'; + const alwaysOn = playoutMode === 'Continuous'; + + const applyTemplate = (next: ChannelTemplate) => { + setTemplateId(next.id); + setPlaybackOrder(templatePlaybackOrder(next)); + setPlayoutMode(next.playoutMode as PlayoutMode); + setOverrides({}); + setTplOpen(false); + }; + + const addItem = (item: LibraryBrowseItem) => { + setLineup((current) => (current.some((existing) => lineupKey(existing) === lineupKey(item)) ? current : [...current, item])); + }; + + const removeAt = (index: number) => { + setLineup((current) => current.filter((_, position) => position !== index)); + }; + + // drop onto lineup container (append) or a row (insert at index) + const dropAt = (targetIndex: number | null) => { + setLineup((current) => { + if (dragRow != null) { + const next = current.slice(); + const [moved] = next.splice(dragRow, 1); + next.splice(targetIndex ?? next.length, 0, moved); + return next; + } + if (dragLib != null) { + const item = browse.state.items.find((candidate) => lineupKey(candidate) === dragLib); + if (item && !current.some((existing) => lineupKey(existing) === dragLib)) { + const next = current.slice(); + next.splice(targetIndex ?? next.length, 0, item); + return next; + } + } + return current; + }); + setDragLib(null); + setDragRow(null); + setOverRow(null); + }; + + const totalDurationSeconds = lineup.reduce((sum, item) => sum + (parseDurationSeconds(item.duration) ?? 0), 0); + + // ---- validation ---- + const trimmedName = name.trim(); + const numberValid = NUMBER_PATTERN.test(number.trim()); + const numberUnique = !data.channels.some((channel) => channel.number === number.trim()); + const numberError = !number.trim() + ? null + : !numberValid + ? 'Use digits, optionally with up to two decimals (e.g. 5 or 13.1).' + : !numberUnique + ? 'That channel number is already in use.' + : null; + + const rowErrorFor = (item: LibraryBrowseItem): string | null => { + if ((item.mediaType === 'RerunCollection' || item.mediaType === 'Playlist') && lineup.length > 1) { + return 'Only valid alone'; + } + if (item.mediaType === 'MultiCollection' && !(playbackOrder === 'Shuffle' || playbackOrder === 'ShuffleInOrder')) { + return 'Requires Shuffle'; + } + return null; + }; + + const rowErrors = lineup.map(rowErrorFor); + const firstRowErrorIndex = rowErrors.findIndex((error) => error != null); + const hasRowError = firstRowErrorIndex >= 0; + + const createBlockedReason = + !trimmedName + ? 'Name the channel and add at least one item' + : lineup.length === 0 + ? 'Add at least one item to the lineup' + : !group.trim() + ? 'Channel group cannot be empty' + : numberError || !number.trim() + ? 'Enter a valid, unique channel number' + : templateId == null + ? 'Select a channel template' + : hasRowError + ? `Fix lineup item ${firstRowErrorIndex + 1}: ${rowErrors[firstRowErrorIndex]}` + : null; + + const canCreate = createBlockedReason == null; + + // ---- image handling ---- + const readImage = (file: File | undefined | null) => { + if (!file) { + return; + } + if (!file.type.startsWith('image/')) { + setImageError('Choose an image file.'); + return; + } + if (file.size >= 30 * 1024 * 1024) { + setImageError('Image is too large (max 30 MB).'); + return; + } + setImageError(null); + setLogoFile(file); + const reader = new FileReader(); + reader.onload = () => setLogoPreview(typeof reader.result === 'string' ? reader.result : null); + reader.readAsDataURL(file); + }; + + // ---- advanced panel accordion height ---- + const advFieldsRef = useRef(null); + const [advMaxHeight, setAdvMaxHeight] = useState(0); + // Measure the fields' natural height for the max-height accordion animation. + // Reading layout then writing state is the canonical useLayoutEffect use. + useLayoutEffect(() => { + const next = advMode === 'closed' ? 0 : (advFieldsRef.current?.scrollHeight ?? 0); + setAdvMaxHeight(next); + }, [advMode]); + + // ---- create ---- + const handleCreate = async () => { + if (creatingRef.current || !canCreate || templateId == null) { + return; + } + creatingRef.current = true; + setCreating(true); + setCreateError(null); + setErrorRowIndex(null); + + try { + let logo: CreateChannelFromLineupRequest['logo'] = { path: '', contentType: '' }; + if (logoFile) { + const uploaded = await uploadArtwork(logoFile, 'logo'); + logo = { path: uploaded.path, contentType: uploaded.contentType }; + } + + const advanced: CreateChannelFromLineupRequest['advanced'] = { playbackOrder, playoutMode }; + for (const key of ADVANCED_KEYS) { + if (key in overrides) { + (advanced as Record)[key] = overrides[key]; + } + } + + const body: CreateChannelFromLineupRequest = { + name: trimmedName, + number: number.trim(), + group: group.trim(), + categories: categories.trim(), + logo, + isEnabled: true, + showInEpg: true, + templateId, + advanced, + lineup: lineup.map((item) => ({ + mediaType: item.mediaType, + collectionType: item.collectionType, + collectionId: item.collectionId, + multiCollectionId: item.multiCollectionId, + smartCollectionId: item.smartCollectionId, + rerunCollectionId: item.rerunCollectionId, + mediaItemId: item.mediaItemId, + playlistId: item.playlistId + })) + }; + + await createChannelFromLineup(body); + navigateToPath('/app/channels'); + } catch (error: unknown) { + let message = messageFromError(error); + if (error instanceof ApiError) { + if (error.status === 413) { + message = 'Image is too large (max 30 MB).'; + } + const detail = error.detail; + const match = detail ? /lineup\[(\d+)\]/.exec(detail) : null; + if (match) { + setErrorRowIndex(Number(match[1])); + } + } + setCreateError(message); + creatingRef.current = false; + setCreating(false); + } + }; + + // ---- save template ---- + const openSaveTemplate = () => { + setSaveName(''); + setSaveDesc(''); + setSaveError(null); + setSaveOpen(true); + setTplOpen(false); + }; + + const handleSaveTemplate = async () => { + if (saveBusy || !template) { + return; + } + if (!saveName.trim()) { + setSaveError('Give the template a name.'); + return; + } + setSaveBusy(true); + setSaveError(null); + + const resolved = (key: AdvancedKey, fallback: T): T => + (key in overrides ? (overrides[key] as T) : ((template as unknown as Record)[key] as T)) ?? fallback; + + try { + const requestBody: CreateChannelTemplateRequest = { + name: saveName.trim(), + description: saveDesc.trim(), + fFmpegProfileId: resolved('fFmpegProfileId', template.fFmpegProfileId), + watermarkId: resolved('watermarkId', template.watermarkId), + fallbackFillerId: resolved('fallbackFillerId', template.fallbackFillerId), + preRollFillerId: resolved('preRollFillerId', template.preRollFillerId), + midRollFillerId: resolved('midRollFillerId', template.midRollFillerId), + postRollFillerId: resolved('postRollFillerId', template.postRollFillerId), + streamSelectorMode: resolved('streamSelectorMode', template.streamSelectorMode), + streamSelector: resolved('streamSelector', template.streamSelector), + preferredAudioLanguageCode: resolved('preferredAudioLanguageCode', template.preferredAudioLanguageCode), + preferredAudioTitle: resolved('preferredAudioTitle', template.preferredAudioTitle), + playoutSource: template.playoutSource, + // toggles are the source of truth for these two: + playoutMode, + streamingMode: resolved('streamingMode', template.streamingMode), + preferredSubtitleLanguageCode: resolved('preferredSubtitleLanguageCode', template.preferredSubtitleLanguageCode), + subtitleMode: resolved('subtitleMode', template.subtitleMode), + musicVideoCreditsMode: resolved('musicVideoCreditsMode', template.musicVideoCreditsMode), + musicVideoCreditsTemplate: template.musicVideoCreditsTemplate, + songVideoMode: resolved('songVideoMode', template.songVideoMode), + transcodeMode: resolved('transcodeMode', template.transcodeMode), + idleBehavior: resolved('idleBehavior', template.idleBehavior), + shuffleScheduleItems: shuffleOn, + randomStartPoint: resolved('randomStartPoint', template.randomStartPoint), + fixedStartTimeBehavior: resolved('fixedStartTimeBehavior', template.fixedStartTimeBehavior) + }; + + const created = await createChannelTemplate(requestBody); + const templates = await getChannelTemplates(); + onDataChange((prev) => (prev ? { ...prev, templates } : prev)); + applyTemplate(created); + setSaveOpen(false); + } catch (error: unknown) { + setSaveError(messageFromError(error)); + } finally { + setSaveBusy(false); + } + }; + + // ---- cancel ---- + const dirty = lineup.length > 0 || trimmedName.length > 0 || logoFile != null; + const handleCancel = () => { + if (dirty) { + setConfirmCancel(true); + } else { + navigateToPath('/app/channels'); + } + }; + + // ---- filter chips (library mode) ---- + const libraryChips = data.mediaSources.flatMap((mediaSource) => + mediaSource.libraries.map((lib) => ({ id: lib.id, name: lib.name })) + ); + + const showAuto = number.trim() === suggestion; + const items = browse.state.items; + const serverRowIndex = errorRowIndex; + + return ( +
+ {/* toolbar */} +
+ + +
+
New Channel
+
+ Build from your library — {lineup.length} in lineup ·{' '} + {formatDuration(totalDurationSeconds)} +
+
+ + + + +
+ + {createError && ( +
+ {createError} +
+ )} + + {/* body */} +
+ {/* LEFT — library browser */} +
+
+
+ { + setSource(next); + setLibraryId(null); + }} + options={[ + { value: 'library', label: 'Library', icon:
+ setSearchInput(event.target.value)} + leadingIcon={
+
+
+ {browse.state.status === 'loading' ? ( +
+ +
+ ) : browse.state.status === 'error' ? ( +
+ {browse.state.error} +
+ ) : items.length === 0 ? ( +
No titles match.
+ ) : ( + <> +
+ {items.map((item) => ( + addItem(item)} + onDragStart={() => setDragLib(lineupKey(item))} + onDragEnd={() => { + if (dragLib != null) { + const dragged = items.find((candidate) => lineupKey(candidate) === dragLib); + if (dragged) { + addItem(dragged); + } + } + setDragLib(null); + setDragRow(null); + setOverRow(null); + }} + /> + ))} +
+ {browse.canLoadMore && ( +
+ +
+ )} + + )} +
+
+
+ + {/* CENTER — lineup */} +
{ + if (dragLib != null) { + event.preventDefault(); + } + }} + onDrop={() => { + if (dragLib != null) { + dropAt(null); + } + }} + > +
+ Lineup + + + {shuffleOn ? + + + {lineup.length > 0 && ( + + )} +
+
+
+ {lineup.length === 0 ? ( +
+
+ ) : ( +
+ {lineup.map((item, index) => { + const rowError = rowErrors[index]; + const serverError = serverRowIndex === index; + return ( +
setDragRow(index)} + onDragEnd={() => { + setDragRow(null); + setOverRow(null); + }} + onDragOver={(event) => { + event.preventDefault(); + setOverRow(index); + }} + onDrop={(event) => { + event.stopPropagation(); + dropAt(index); + }} + > + + + + {shuffleOn ? ( + + +
+
{item.title}
+
+ {TYPE_LABEL[item.mediaType]} + {item.libraryName ? ` · ${item.libraryName}` : ''} +
+
+ {rowError && ( + + {rowError} + + )} + + {parseDurationSeconds(item.duration) + ? formatDuration(parseDurationSeconds(item.duration) as number) + : ''} + + removeAt(index)}> + +
+ ); + })} +
+
+
+ )} +
+
+
+ + {/* RIGHT — settings rail */} + +
+ + { + setLineup([]); + setConfirmClear(false); + }} + onCancel={() => setConfirmClear(false)} + /> + + { + setConfirmCancel(false); + navigateToPath('/app/channels'); + }} + onCancel={() => setConfirmCancel(false)} + /> + + setSaveOpen(false)} + title="Save as channel template" + width={420} + footer={ + <> + + + + } + > +
+ setSaveName(event.target.value)} placeholder="e.g. Retro Saturday AM" /> + setSaveDesc(event.target.value)} placeholder="Optional one-line summary" /> + {saveError && ( +
+ {saveError} +
+ )} +
+
+
+ ); +} + +// Config chips derived from real template fields (4-6 chips). +function templateChips( + template: ChannelTemplate, + ffmpegProfiles: FFmpegProfile[], + playbackOrder: PlaybackOrder, + playoutMode: PlayoutMode +): string[] { + const chips: string[] = []; + chips.push(STREAMING_MODE_LABELS[template.streamingMode as StreamingMode] ?? template.streamingMode); + const profile = ffmpegProfiles.find((candidate) => candidate.id === template.fFmpegProfileId); + if (profile?.name) { + chips.push(profile.name); + } + chips.push(playbackOrder === 'Shuffle' || playbackOrder === 'ShuffleInOrder' ? 'Shuffle' : 'Sequential'); + chips.push(playoutMode === 'Continuous' ? 'Always on' : 'On demand'); + const fillers: string[] = []; + if (template.preRollFillerId != null) { + fillers.push('Pre'); + } + if (template.midRollFillerId != null) { + fillers.push('Mid'); + } + if (template.postRollFillerId != null) { + fillers.push('Post'); + } + if (fillers.length > 0) { + chips.push(`${fillers.join('/')} filler`); + } + if (template.subtitleMode && template.subtitleMode !== 'None') { + chips.push(`Subtitles: ${template.subtitleMode}`); + } + return chips.slice(0, 6); +} + +// ---- Advanced panel -------------------------------------------------------- +function AdvancedPanel({ + template, + overrides, + setOverrides, + playbackOrder, + setPlaybackOrder, + playoutMode, + setPlayoutMode, + group, + setGroup, + categories, + setCategories, + mode, + setMode, + maxHeight, + fieldsRef, + ffmpegProfiles, + fillerPresets, + watermarks +}: { + template: ChannelTemplate; + overrides: Overrides; + setOverrides: (updater: (prev: Overrides) => Overrides) => void; + playbackOrder: PlaybackOrder; + setPlaybackOrder: (next: PlaybackOrder) => void; + playoutMode: PlayoutMode; + setPlayoutMode: (next: PlayoutMode) => void; + group: string; + setGroup: (next: string) => void; + categories: string; + setCategories: (next: string) => void; + mode: 'closed' | 'view' | 'override'; + setMode: (next: 'closed' | 'view' | 'override') => void; + maxHeight: number; + fieldsRef: React.RefObject; + ffmpegProfiles: FFmpegProfile[]; + fillerPresets: FillerPreset[]; + watermarks: Watermark[]; +}) { + const override = mode === 'override'; + const overrideCount = Object.keys(overrides).length; + const fieldCount = ADVANCED_KEYS.length + 2; // + playbackOrder + playoutMode (toggled) + + const setOverride = (key: AdvancedKey, value: unknown) => setOverrides((prev) => ({ ...prev, [key]: value })); + + const idSelectOptions = (rows: Array<{ id: number; name: string | null }>) => [ + NONE_OPTION, + ...rows.map((row) => ({ value: String(row.id), label: row.name ?? `#${row.id}` })) + ]; + + const fillerOptions = idSelectOptions(fillerPresets); + const watermarkOptions = idSelectOptions(watermarks); + const ffmpegOptions = ffmpegProfiles.map((profile) => ({ value: String(profile.id), label: profile.name ?? `#${profile.id}` })); + + const idValue = (key: AdvancedKey): string => { + const value = effectiveValue(key, template, overrides); + return value == null ? '' : String(value); + }; + const strValue = (key: AdvancedKey): string => { + const value = effectiveValue(key, template, overrides); + return value == null ? '' : String(value); + }; + const boolValue = (key: AdvancedKey): boolean => Boolean(effectiveValue(key, template, overrides)); + + return ( +
+
+
+
+ + +
+
+
+
+ {override + ? 'Editing these overrides the template for this channel only.' + : `Read-only — inherited from the ${template.name} template. Turn on Override to edit.`} +
+ + {override ? ( + <> + + ({ value, label: STREAMING_MODE_LABELS[value] }))} + onChange={(event) => setOverride('streamingMode', event.target.value as StreamingMode)} + /> + setOverride('fallbackFillerId', toIdOverride(event.target.value))} + /> + setOverride('midRollFillerId', toIdOverride(event.target.value))} + /> + setOverride('watermarkId', toIdOverride(event.target.value))} + /> + + + + setOverride('preferredAudioLanguageCode', event.target.value || null)} + placeholder="e.g. eng" + /> + setOverride('preferredSubtitleLanguageCode', event.target.value || null)} + placeholder="e.g. eng" + /> + setOverride('preferredAudioTitle', event.target.value || null)} + /> + setOverride('streamSelectorMode', event.target.value as StreamSelectorMode)} + /> + setOverride('streamSelector', event.target.value || null)} + /> + + + + setOverride('musicVideoCreditsMode', event.target.value as MusicVideoCreditsMode)} + /> + setOverride('idleBehavior', event.target.value as IdleBehavior)} + /> + setGroup(event.target.value)} error={group.trim() ? null : 'Required'} /> + setCategories(event.target.value)} placeholder="comma-separated" /> + + + ) : ( + <> + + p.id === template.fFmpegProfileId)?.name ?? `#${template.fFmpegProfileId}`} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + )} +
+
+
+ ); +} + +function AdvGroup({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +function ReadOnlyField({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function fillerName(rows: Array<{ id: number; name: string | null }>, id: number | null): string { + if (id == null) { + return 'None'; + } + const row = rows.find((candidate) => candidate.id === id); + return row?.name ?? `#${id}`; +} diff --git a/web/src/routing.ts b/web/src/routing.ts new file mode 100644 index 000000000..7e01538f0 --- /dev/null +++ b/web/src/routing.ts @@ -0,0 +1,7 @@ +// Client-side navigation shared by the shell (App.tsx) and screens that live in +// their own modules (e.g. the Channel Builder). Kept in its own file so screens +// don't import App.tsx (which would create an import cycle). +export function navigateToPath(path: string) { + window.history.pushState(null, '', path); + window.dispatchEvent(new PopStateEvent('popstate')); +} diff --git a/web/src/shell.css b/web/src/shell.css index d34c24a12..e9e6149c1 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -2131,6 +2131,818 @@ color: var(--status-live); } +/* ---- Channel Builder (#89) --------------------------------------------- */ +/* The builder is a full-height flagship screen. `.ctv-main` is a bounded, + scrolling grid track (100vh - topbar); the ThemeSwitcher + main padding sit + above us, so we bound our own height off the viewport to get three + independently scrolling columns rather than one long page scroll. */ +.ctv-builder { + display: flex; + flex-direction: column; + height: calc(100vh - 132px); + min-height: 460px; + margin: calc(var(--space-7, 16px) * -1) calc(var(--pad-page, 32px) * -1) calc(var(--pad-page, 32px) * -1); + border-top: 1px solid var(--border-hairline); +} + +.ctv-hide-sb { + scrollbar-width: thin; + scrollbar-color: var(--border-control) transparent; +} + +.ctv-hide-sb::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.ctv-hide-sb::-webkit-scrollbar-thumb { + background: var(--border-control); + border-radius: 8px; +} + +.ctv-hide-sb::-webkit-scrollbar-track { + background: transparent; +} + +.ctv-builder-toolbar { + display: flex; + align-items: center; + gap: var(--space-6, 12px); + padding: var(--space-6, 12px) 20px; + border-bottom: 1px solid var(--border-hairline); + flex: 0 0 auto; +} + +.ctv-builder-toolbar-tile { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + background: var(--ctv-accent-soft); + color: var(--ctv-accent); + flex: 0 0 auto; +} + +.ctv-builder-toolbar-titles { + flex: 1; + min-width: 0; +} + +.ctv-builder-title { + font: var(--weight-semibold) var(--text-md) / 1 var(--font-sans); + color: var(--text-primary); +} + +.ctv-builder-summary { + margin-top: 3px; + font: var(--text-xs) / 1 var(--font-sans); + color: var(--text-secondary); +} + +.ctv-mono { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.ctv-builder-error { + display: flex; + align-items: flex-start; + gap: var(--space-4, 8px); + padding: var(--space-4, 8px) 20px; + border-bottom: 1px solid var(--border-hairline); + background: var(--ctv-error-soft); + color: var(--status-error); + font: var(--text-xs) / 1.4 var(--font-sans); + flex: 0 0 auto; +} + +.ctv-builder-body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) 304px; +} + +.ctv-builder-col { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + overflow: hidden; + border-right: 1px solid var(--border-hairline); +} + +.ctv-builder-col-rail { + border-right: none; + background: var(--surface-card); +} + +.ctv-builder-col-head { + padding: 14px 16px 10px; + display: flex; + flex-direction: column; + gap: var(--space-5, 10px); + flex: 0 0 auto; +} + +.ctv-builder-col-head-row { + flex-direction: row; + align-items: center; +} + +.ctv-builder-scroll { + flex: 1; + min-height: 0; + min-width: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.ctv-builder-scroll-body { + padding: 4px 16px 16px; +} + +.ctv-builder-rail-scroll { + padding: 14px 16px 24px; + display: flex; + flex-direction: column; + gap: var(--space-7, 16px); +} + +.ctv-builder-headrow { + display: flex; + align-items: center; + gap: var(--space-5, 10px); + flex-wrap: wrap; + row-gap: var(--space-4, 8px); +} + +.ctv-builder-eyebrow { + font: var(--weight-semibold) var(--text-2xs) / 1 var(--font-sans); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-disabled); +} + +.ctv-builder-chiprow { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.ctv-builder-chip { + padding: 4px 10px; + border-radius: var(--radius-pill); + cursor: pointer; + font: var(--weight-medium) var(--text-2xs) / 1.4 var(--font-sans); + background: var(--ctv-surface-2); + color: var(--text-secondary); + border: 1px solid var(--border-hairline); +} + +.ctv-builder-chip-on { + background: var(--ctv-accent-soft); + color: var(--ctv-accent); + border-color: var(--ctv-accent-ring, rgba(224, 138, 60, 0.35)); +} + +.ctv-builder-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); + gap: var(--space-5, 10px); +} + +.ctv-builder-compact-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ctv-builder-empty { + padding: 40px 0; + text-align: center; + font: var(--text-xs) / 1.5 var(--font-sans); + color: var(--text-disabled); +} + +.ctv-builder-loadmore { + margin-top: var(--space-6, 12px); + display: flex; + justify-content: center; +} + +/* poster placeholder */ +.ctv-builder-poster { + position: relative; + flex: 0 0 auto; + overflow: hidden; + border-radius: var(--radius-sm); + border: 1px solid var(--border-hairline); +} + +.ctv-builder-poster img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.ctv-builder-poster-icon { + position: absolute; + display: inline-flex; + z-index: 1; +} + +.ctv-builder-poster-scrim { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 18px 8px 8px; + background: linear-gradient(to top, rgba(11, 13, 17, 0.92), transparent); + z-index: 1; +} + +.ctv-builder-poster-title { + font: var(--weight-semibold) var(--text-xs) / 1.15 var(--font-sans); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-builder-poster-meta { + margin-top: 3px; + font: var(--text-2xs) / 1 var(--font-mono); + color: var(--text-secondary); +} + +.ctv-builder-libcard { + position: relative; + cursor: grab; + transition: transform var(--dur-fast) var(--ease-standard, ease); +} + +.ctv-builder-libcard:hover { + transform: translateY(-2px); +} + +.ctv-builder-libcard-poster { + position: relative; + border-radius: var(--radius-sm); +} + +.ctv-builder-libcard-compact { + display: flex; + align-items: center; + gap: var(--space-5, 10px); + padding: 6px 8px; + border-radius: var(--radius-sm); + cursor: grab; + background: var(--surface-card); + border: 1px solid var(--border-hairline); +} + +.ctv-builder-compact-title { + font: var(--weight-medium) var(--text-xs) / 1.15 var(--font-sans); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-builder-compact-meta { + margin-top: 2px; + font: var(--text-2xs) / 1 var(--font-sans); + color: var(--text-secondary); +} + +.ctv-builder-added-check { + position: absolute; + top: 6px; + right: 6px; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--action-primary); + color: var(--text-on-accent); + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-sm); + animation: ctv-pop 260ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes ctv-pop { + from { + transform: scale(0.2); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +/* lineup */ +.ctv-builder-lineup-list { + display: flex; + flex-direction: column; + gap: 7px; +} + +.ctv-builder-lineup-row { + display: flex; + align-items: center; + gap: 11px; + padding: 9px 10px; + border-radius: var(--radius-md); + background: var(--surface-card); + border: 1px solid var(--border-hairline); + transition: opacity var(--dur-fast); +} + +.ctv-builder-lineup-row-error { + border-color: var(--status-error); +} + +.ctv-builder-grip { + display: inline-flex; + cursor: grab; + color: var(--ctv-text-faint); + flex: 0 0 auto; +} + +.ctv-builder-order { + width: 22px; + flex: 0 0 auto; + text-align: center; + font: var(--weight-medium) var(--text-xs) / 1 var(--font-mono); + color: var(--text-secondary); +} + +.ctv-builder-row-main { + flex: 1; + min-width: 0; +} + +.ctv-builder-row-title { + font: var(--weight-semibold) var(--text-sm) / 1.2 var(--font-sans); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-builder-row-sub { + margin-top: 2px; + font: var(--text-2xs) / 1 var(--font-sans); + color: var(--text-secondary); +} + +.ctv-builder-row-dur { + font: var(--text-2xs) / 1 var(--font-mono); + color: var(--text-disabled); + flex: 0 0 auto; +} + +.ctv-builder-lineup-empty { + min-height: 220px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-6, 12px); + text-align: center; + border: 1px dashed var(--border-control); + border-radius: var(--radius-md); + color: var(--text-disabled); + padding: 24px; +} + +.ctv-builder-lineup-empty-over { + border-color: var(--ctv-accent); + background: var(--ctv-accent-soft); +} + +.ctv-builder-lineup-hint { + margin-top: 4px; + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px dashed var(--border-hairline); + display: flex; + align-items: center; + gap: var(--space-4, 8px); + color: var(--text-disabled); + font: var(--text-2xs) / 1 var(--font-sans); +} + +/* rail */ +.ctv-builder-rail-block { + display: flex; + flex-direction: column; + gap: var(--space-5, 10px); +} + +.ctv-builder-auto-chip { + font: 9px / 1 var(--font-mono); + padding: 2px 5px; + border-radius: 3px; + background: var(--ctv-accent-soft); + color: var(--ctv-accent); +} + +.ctv-builder-dropzone { + display: flex; + gap: var(--space-6, 12px); + padding: var(--space-6, 12px); + border-radius: var(--radius-sm); + cursor: pointer; + background: var(--ctv-bg-sunken); + border: 1px dashed var(--border-control); +} + +.ctv-builder-dropzone-over { + border-color: var(--ctv-accent); +} + +.ctv-builder-preview-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-5, 10px); +} + +.ctv-builder-preview-tile { + padding: 10px; + border-radius: var(--radius-sm); + background: var(--surface-card); + border: 1px solid var(--border-hairline); +} + +.ctv-builder-toggle-row { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 10px 0; + cursor: pointer; +} + +.ctv-builder-toggle-live { + gap: var(--space-5, 10px); + padding: 10px 11px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + transition: background 240ms cubic-bezier(0.16, 1, 0.3, 1), border-color 240ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.ctv-builder-toggle-live-on { + background: var(--ctv-live-soft); + border-color: var(--ctv-live); +} + +.ctv-builder-toggle-label { + font: var(--weight-medium) var(--text-sm) / 1.2 var(--font-sans); + color: var(--text-primary); +} + +.ctv-builder-toggle-help { + margin-top: 2px; + font: var(--text-2xs) / 1.35 var(--font-sans); + color: var(--text-secondary); +} + +.ctv-builder-overrides-tag { + font: var(--text-2xs) / 1 var(--font-sans); + color: var(--text-disabled); + padding: 2px 6px; + border-radius: var(--radius-xs); + background: var(--ctv-surface-2); + border: 1px solid var(--border-hairline); +} + +.ctv-builder-live-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + border-radius: var(--radius-pill); + background: var(--ctv-live-soft); + color: var(--ctv-live); + font: var(--weight-semibold) 9px / 1.4 var(--font-sans); + letter-spacing: 0.06em; +} + +.ctv-builder-hairline { + height: 1px; + background: var(--border-hairline); +} + +.ctv-builder-tpl-button { + width: 100%; + display: flex; + align-items: center; + gap: var(--space-5, 10px); + padding: 10px 11px; + border-radius: var(--radius-sm); + cursor: pointer; + text-align: left; + background: var(--ctv-bg-sunken); + border: 1px solid var(--border-control); +} + +.ctv-builder-tpl-tile { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--radius-xs); + background: var(--ctv-surface-3); + color: var(--ctv-accent); + flex: 0 0 auto; +} + +.ctv-builder-tpl-name { + font: var(--weight-medium) var(--text-sm) / 1 var(--font-sans); + color: var(--text-primary); +} + +.ctv-builder-tpl-desc { + margin-top: 3px; + font: var(--text-2xs) / 1.3 var(--font-sans); + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-builder-tpl-list { + margin-top: 6px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px; + border-radius: var(--radius-sm); + background: var(--ctv-bg-sunken); + border: 1px solid var(--border-hairline); +} + +.ctv-builder-tpl-option { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 9px; + border-radius: var(--radius-xs); + cursor: pointer; + text-align: left; + background: transparent; + border: none; +} + +.ctv-builder-tpl-option-on { + background: color-mix(in srgb, var(--action-primary) 14%, transparent); +} + +.ctv-builder-tpl-option-name { + font: var(--weight-medium) var(--text-xs) / 1 var(--font-sans); + color: var(--text-primary); +} + +.ctv-builder-tpl-save { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 9px; + border-radius: var(--radius-xs); + cursor: pointer; + background: none; + border: none; + color: var(--text-secondary); + font: var(--weight-medium) var(--text-xs) / 1 var(--font-sans); +} + +.ctv-builder-tpl-empty { + padding: 10px 11px; + border-radius: var(--radius-sm); + background: var(--ctv-bg-sunken); + border: 1px solid var(--border-hairline); + font: var(--text-2xs) / 1.4 var(--font-sans); + color: var(--text-secondary); +} + +.ctv-builder-config-chips { + margin-top: var(--space-4, 8px); + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.ctv-builder-config-chip { + padding: 3px 8px; + border-radius: var(--radius-xs); + background: var(--ctv-surface-2); + border: 1px solid var(--border-hairline); + font: var(--text-2xs) / 1.4 var(--font-sans); + color: var(--text-secondary); +} + +.ctv-builder-adv { + border-top: 1px solid var(--border-hairline); + padding-top: 14px; +} + +.ctv-builder-adv-head { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: var(--space-5, 10px); +} + +.ctv-builder-adv-head-title { + font: var(--weight-medium) var(--text-sm) / 1 var(--font-sans); + color: var(--text-primary); +} + +.ctv-builder-adv-count { + font: var(--text-2xs) / 1 var(--font-mono); + color: var(--text-disabled); +} + +.ctv-builder-adv-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4, 8px); +} + +.ctv-builder-adv-tab { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + font: var(--weight-medium) var(--text-xs) / 1 var(--font-sans); + background: var(--ctv-surface-2); + color: var(--text-secondary); + border: 1px solid var(--border-hairline); + transition: background 200ms cubic-bezier(0.16, 1, 0.3, 1), color 200ms, border-color 200ms; +} + +.ctv-builder-adv-tab-on { + background: color-mix(in srgb, var(--action-primary) 14%, transparent); + color: var(--action-primary); + border-color: color-mix(in srgb, var(--action-primary) 38%, transparent); +} + +.ctv-builder-adv-panel { + overflow: hidden; + transition: max-height 340ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.ctv-builder-adv-fields { + margin-top: var(--space-6, 12px); + display: flex; + flex-direction: column; + gap: 14px; +} + +.ctv-builder-adv-info { + display: flex; + align-items: center; + gap: var(--space-4, 8px); + font: var(--text-2xs) / 1.35 var(--font-sans); + color: var(--text-secondary); +} + +.ctv-builder-adv-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4, 8px); +} + +.ctv-builder-adv-readonly { + padding: 7px 9px; + border-radius: var(--radius-xs); + background: var(--ctv-bg-sunken); + border: 1px solid var(--border-hairline); + opacity: 0.72; +} + +.ctv-builder-adv-readonly-key { + font: var(--text-2xs) / 1 var(--font-sans); + color: var(--text-disabled); +} + +.ctv-builder-adv-readonly-value { + margin-top: 3px; + font: var(--weight-medium) var(--text-xs) / 1 var(--font-sans); + color: var(--text-primary); +} + +.ctv-builder-adv-switchrow { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4, 8px); + padding: 7px 9px; + border-radius: var(--radius-xs); + background: var(--ctv-bg-sunken); + border: 1px solid var(--border-hairline); + font: var(--text-xs) / 1.2 var(--font-sans); + color: var(--text-primary); +} + +/* segmented control (builder-local) */ +.ctv-builder-seg { + position: relative; + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + padding: 2px; + border-radius: var(--radius-sm); + background: var(--ctv-bg-sunken); + border: 1px solid var(--border-hairline); +} + +.ctv-builder-seg-thumb { + position: absolute; + top: 2px; + bottom: 2px; + left: 2px; + background: var(--ctv-surface-3); + border-radius: var(--radius-xs); + box-shadow: var(--shadow-sm); + transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1); + pointer-events: none; +} + +.ctv-builder-seg-btn { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 5px 10px; + border: none; + background: transparent; + cursor: pointer; + color: var(--text-secondary); + transition: color 220ms cubic-bezier(0.16, 1, 0.3, 1); + font: var(--weight-medium) var(--text-2xs) / 1 var(--font-sans); +} + +.ctv-builder-seg-btn-on { + color: var(--text-primary); +} + +.ctv-builder-linkbtn { + background: none; + border: none; + cursor: pointer; + font: var(--text-2xs) / 1 var(--font-sans); + color: var(--text-secondary); + padding: 0; +} + +.ctv-builder-disclosure { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 5px; + background: none; + border: none; + cursor: pointer; + padding: 2px 0; + font: var(--text-2xs) / 1 var(--font-sans); + color: var(--text-secondary); +} + +.ctv-builder-sep-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-5, 10px); + padding: 10px; + border-radius: var(--radius-sm); + background: var(--ctv-bg-sunken); + border: 1px solid var(--border-hairline); +} + +@media (prefers-reduced-motion: reduce) { + .ctv-builder-seg-thumb, + .ctv-builder-adv-panel, + .ctv-builder-libcard, + .ctv-builder-toggle-live, + .ctv-builder-added-check { + transition: none; + animation: none; + } +} + @media (max-width: 980px) { .ctv-app-shell { grid-template-columns: 1fr; @@ -2245,4 +3057,24 @@ grid-column: 2 / -1; justify-self: start; } + + .ctv-builder { + height: auto; + margin: calc(var(--space-6, 12px) * -1); + } + + .ctv-builder-body { + grid-template-columns: 1fr; + } + + .ctv-builder-col, + .ctv-builder-col-rail { + overflow: visible; + border-right: none; + border-bottom: 1px solid var(--border-hairline); + } + + .ctv-builder-scroll { + overflow: visible; + } }