diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 9c74732f1..8f800880e 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -71,10 +71,147 @@ describe('ChicoryTV SPA scaffold', () => { render(); expect(screen.getByRole('heading', { name: 'Guide' })).toBeInTheDocument(); - expect(screen.getByText('EPG grid workspace')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Loading guide' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Guide' })).toHaveAttribute('aria-current', 'page'); }); + it('renders the Guide screen from a bounded JSON guide window', async () => { + vi.setSystemTime(new Date('2026-07-05T20:30:00Z')); + mockDashboardApi({ + channelStates: [ + { + channelId: 1, + channelNumber: '5.1', + onAir: true, + nowPlaying: { + finishUtc: '2026-07-05T21:00:00Z', + startUtc: '2026-07-05T20:00:00Z', + title: 'Saturday Morning Cartoons - s01e01 - Pilot' + } + }, + { channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null } + ], + guide: guideFixture() + }); + + const { container } = render(); + + fireEvent.click(screen.getByRole('link', { name: 'Guide' })); + + expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument(); + expect(screen.getByText('Retro Cartoons')).toBeInTheDocument(); + expect(screen.getByText('News 24')).toBeInTheDocument(); + expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0); + expect(screen.getByText('Pilot')).toBeInTheDocument(); + expect(screen.getAllByText('Kids').length).toBeGreaterThan(0); + expect(screen.getByText('Early Show')).toBeInTheDocument(); + expect(screen.getByText('No programmes in this window')).toBeInTheDocument(); + expect(screen.queryByText('Filler')).not.toBeInTheDocument(); + expect(container.querySelectorAll('.ctv-epg-programme-live')).toHaveLength(1); + + const guideCall = fetchCallsStartingWith('/api/guide?')[0]; + expect(guideCall).toBeDefined(); + const guideUrl = new URL(guideCall, window.location.origin); + expect(guideUrl.searchParams.get('start')).toBe('2026-07-05T19:30:00.000Z'); + expect(guideUrl.searchParams.get('end')).toBe('2026-07-06T08:30:00.000Z'); + expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object)); + }); + + it('renders the Guide screen when an on-air channel omits nowPlaying', async () => { + mockDashboardApi({ + channelStates: [ + { + channelId: 1, + channelNumber: '5.1', + onAir: true + } + ], + guide: guideFixture() + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Guide' })); + + expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument(); + expect(screen.getByText('Retro Cartoons')).toBeInTheDocument(); + }); + + it('moves the Guide now marker on timer ticks without polling the guide endpoint', async () => { + const intervalHandlers: Array<() => void> = []; + vi.setSystemTime(new Date('2026-07-05T20:30:00Z')); + vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler) => { + if (typeof handler === 'function') { + intervalHandlers.push(handler as () => void); + } + + return intervalHandlers.length; + }); + vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined); + mockDashboardApi({ guide: guideFixture() }); + + const { container } = render(); + + fireEvent.click(screen.getByRole('link', { name: 'Guide' })); + expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument(); + const guideFetchesBeforeTick = fetchCallsStartingWith('/api/guide?').length; + const marker = container.querySelector('.ctv-epg-now-marker') as HTMLElement; + expect(marker.style.left).toBe('508px'); + + vi.setSystemTime(new Date('2026-07-05T21:00:00Z')); + intervalHandlers.forEach((handler) => handler()); + + await waitFor(() => { + expect(marker.style.left).toBe('664px'); + }); + expect(fetchCallsStartingWith('/api/guide?')).toHaveLength(guideFetchesBeforeTick); + }); + + it('fetches one Guide window per explicit navigation and jumps back to now', async () => { + vi.setSystemTime(new Date('2026-07-05T20:30:00Z')); + mockDashboardApi({ guide: guideFixture() }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Guide' })); + expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument(); + expect(fetchCallsStartingWith('/api/guide?')).toHaveLength(1); + + fireEvent.click(screen.getByRole('button', { name: 'Next guide window' })); + await waitFor(() => { + expect(fetchCallsStartingWith('/api/guide?')).toHaveLength(2); + }); + + const nextUrl = new URL(fetchCallsStartingWith('/api/guide?')[1], window.location.origin); + expect(nextUrl.searchParams.get('start')).toBe('2026-07-06T08:30:00.000Z'); + expect(nextUrl.searchParams.get('end')).toBe('2026-07-06T21:30:00.000Z'); + + fireEvent.click(screen.getByRole('button', { name: 'Jump to now' })); + await waitFor(() => { + expect(fetchCallsStartingWith('/api/guide?')).toHaveLength(3); + }); + + const nowUrl = new URL(fetchCallsStartingWith('/api/guide?')[2], window.location.origin); + expect(nowUrl.searchParams.get('start')).toBe('2026-07-05T19:30:00.000Z'); + expect(nowUrl.searchParams.get('end')).toBe('2026-07-06T08:30:00.000Z'); + }); + + it('shows Guide API errors and retries', async () => { + mockDashboardApi({ + guide: guideFixture(), + guideFailuresBeforeSuccess: 1 + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Guide' })); + + expect(await screen.findByText('Request failed with status 500')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument(); + }); + it('shows a not-found screen for unknown app routes', () => { window.history.replaceState(null, '', '/app/channels/42'); @@ -2058,6 +2195,8 @@ function mockDashboardApi({ collections = [], confirm = false, fillerPresets = [], + guide = null, + guideFailuresBeforeSuccess = 0, health = [], libraryScanStatusFailAfterTrigger = false, libraryScanStatuses = [], @@ -2093,6 +2232,8 @@ function mockDashboardApi({ collections?: unknown[]; confirm?: boolean; fillerPresets?: unknown[]; + guide?: unknown; + guideFailuresBeforeSuccess?: number; health?: unknown[]; libraryScanStatusFailAfterTrigger?: boolean; libraryScanStatuses?: unknown[]; @@ -2127,6 +2268,7 @@ function mockDashboardApi({ let currentLibraryScanStatuses = libraryScanStatuses; let currentScheduleItems = scheduleItems; let remainingMediaSourcesFailures = mediaSourcesFailuresBeforeSuccess; + let remainingGuideFailures = guideFailuresBeforeSuccess; let remainingScheduleItemFailures = scheduleItemFailuresBeforeSuccess; let remainingPlayoutItemsFailures = playoutItemsFailuresBeforeSuccess; let scanStatusSequenceIndex = 0; @@ -2143,6 +2285,15 @@ function mockDashboardApi({ return Promise.resolve(jsonResponse(channelStates)); } + if (path.startsWith('/api/guide?')) { + if (remainingGuideFailures > 0) { + remainingGuideFailures -= 1; + return Promise.resolve(new Response(null, { status: 500 })); + } + + return Promise.resolve(jsonResponse(guide ?? guideFixture())); + } + if (path === '/api/schedules') { return Promise.resolve(jsonResponse(schedules)); } @@ -2325,3 +2476,62 @@ function mockDashboardApi({ function fetchCount(path: string): number { return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length; } + +function fetchCallsStartingWith(prefix: string): string[] { + return vi.mocked(window.fetch).mock.calls + .map(([input]) => input.toString()) + .filter((path) => path.startsWith(prefix)); +} + +function guideFixture(overrides: Record = {}) { + return { + channels: [ + { + name: 'Retro Cartoons', + number: '5.1', + programmes: [ + { + category: 'Kids', + fillerKind: 'None', + start: '2026-07-05T19:00:00Z', + stop: '2026-07-05T20:00:00Z', + subTitle: null, + title: 'Early Show' + }, + { + category: 'Kids', + fillerKind: 'None', + start: '2026-07-05T20:00:00Z', + stop: '2026-07-05T21:00:00Z', + subTitle: 'Pilot', + title: 'Saturday Morning Cartoons' + }, + { + category: null, + fillerKind: 'None', + start: '2026-07-05T22:00:00Z', + stop: '2026-07-05T23:00:00Z', + subTitle: null, + title: 'Saturday Morning Cartoons' + }, + { + category: null, + fillerKind: 'None', + start: '2026-07-06T08:00:00Z', + stop: '2026-07-06T09:00:00Z', + subTitle: null, + title: 'Window Edge Special' + } + ] + }, + { + name: 'News 24', + number: '24', + programmes: [] + } + ], + end: '2026-07-06T08:30:00Z', + start: '2026-07-05T19:30:00Z', + ...overrides + }; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 9a14922f7..13fbc8cb3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,6 +20,7 @@ import { Clock, ClipboardCopy, Copy, + Crosshair, Film, FileImage, Folder, @@ -87,9 +88,14 @@ import { useDashboardHealthQuery, useDashboardQuery, useDashboardVersionQuery, + defaultGuideWindowStart, + GUIDE_WINDOW_MS, + useGuideScreenQuery, useLibrariesScreenQuery, type ChannelState, type ChannelSummary, + type ChannelGuideChannel, + type ChannelGuideProgramme, type DashboardChannel, type SchedulePickerData, type DashboardChannelState, @@ -1831,6 +1837,282 @@ function formatScheduleCollectionType(value: string | undefined): string { return formatScheduleEnum(value ?? 'Collection'); } +const EPG_SLOT_MINUTES = 30; +const EPG_SLOT_WIDTH = 156; +const EPG_RAIL_WIDTH = 196; +const EPG_ROW_HEIGHT = 82; +const EPG_HEADER_HEIGHT = 34; +function GuideLoadingState() { + return ( + Loading guide} subtitle="Fetching a bounded JSON guide window and live channel state."> +
+ + Loading guide +
+
+ ); +} + +function GuideErrorState({ error, refresh }: { error: string; refresh: () => void }) { + return ( + Guide unavailable} + subtitle="The API returned an error while loading the JSON guide." + actions={} + > +
+
+
+ ); +} + +function GuideScreen() { + const query = useGuideScreenQuery(); + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const intervalId = window.setInterval(() => { + setNow(new Date()); + }, 60000); + + return () => { + window.clearInterval(intervalId); + }; + }, []); + + if (query.status === 'loading') { + return ; + } + + if (query.status === 'error') { + return ; + } + + const channels = query.data.guide.channels; + const channelStatesByNumber = new Map(query.data.channelStates.map((state) => [state.channelNumber, state])); + const windowStart = new Date(query.data.guide.start); + const windowEnd = new Date(query.data.guide.end); + const slots = guideSlots(windowStart, windowEnd); + const totalWidth = EPG_RAIL_WIDTH + slots.length * EPG_SLOT_WIDTH; + const nowOffset = offsetPx(now, windowStart); + const nowInWindow = now >= windowStart && now <= windowEnd; + + const moveWindow = (delta: number) => { + query.setWindowStart(new Date(query.windowStart.getTime() + delta * GUIDE_WINDOW_MS)); + }; + + const jumpToNow = () => { + query.setWindowStart(defaultGuideWindowStart(now)); + }; + + return ( +
+
+
+ + {formatGuideTime(windowEnd)} +
+ Now {formatGuideTime(now)} + + + +
+ +
+
+
+
+
+ {slots.map((slot) => ( +
+ {formatGuideTime(slot)} +
+ ))} +
+ + {nowInWindow && ( + + )} + + {channels.map((channel, index) => ( + + ))} +
+
+
+
+ ); +} + +function GuideChannelRow({ + channel, + channelState, + index, + windowEnd, + windowStart +}: { + channel: ChannelGuideChannel; + channelState: ChannelState | null; + index: number; + windowEnd: Date; + windowStart: Date; +}) { + const visibleProgrammes = channel.programmes + .map((programme) => clipProgramme(programme, windowStart, windowEnd)) + .filter((programme): programme is ClippedProgramme => programme !== null); + + return ( +
+
+ {channel.number} + + {channel.name} + {channelState?.onAir && } +
+
+ {visibleProgrammes.length === 0 ? ( + No programmes in this window + ) : visibleProgrammes.map((programme) => ( + + ))} +
+
+ ); +} + +interface ClippedProgramme { + category: null | string; + fillerKind: string; + start: Date; + stop: Date; + subTitle: null | string; + title: string; + visibleStart: Date; + visibleStop: Date; +} + +function GuideProgrammeBlock({ + channelState, + programme, + windowStart +}: { + channelState: ChannelState | null; + programme: ClippedProgramme; + windowStart: Date; +}) { + const filler = programme.fillerKind !== 'None'; + const live = Boolean(channelState?.onAir && programmeMatchesNowPlaying(programme, channelState.nowPlaying)); + const left = offsetPx(programme.visibleStart, windowStart) + 3; + const width = Math.max(24, offsetPx(programme.visibleStop, programme.visibleStart) - 6); + + return ( +
+
+ {live && } + {programme.title} +
+ {programme.subTitle && {programme.subTitle}} + {filler ? Filler : programme.category && {programme.category}} +
+ ); +} + +function guideSlots(start: Date, end: Date): Date[] { + const slots: Date[] = []; + const slotMs = EPG_SLOT_MINUTES * 60 * 1000; + + for (let value = start.getTime(); value < end.getTime(); value += slotMs) { + slots.push(new Date(value)); + } + + return slots; +} + +function formatGuideTime(value: Date): string { + return value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function offsetPx(value: Date, start: Date): number { + return (value.getTime() - start.getTime()) / (EPG_SLOT_MINUTES * 60 * 1000) * EPG_SLOT_WIDTH; +} + +function clipProgramme(programme: ChannelGuideProgramme, windowStart: Date, windowEnd: Date): ClippedProgramme | null { + const start = new Date(programme.start); + const stop = new Date(programme.stop); + + if (Number.isNaN(start.getTime()) || Number.isNaN(stop.getTime()) || stop <= windowStart || start >= windowEnd) { + return null; + } + + return { + category: programme.category, + fillerKind: programme.fillerKind, + start, + stop, + subTitle: programme.subTitle, + title: programme.title, + visibleStart: start < windowStart ? windowStart : start, + visibleStop: stop > windowEnd ? windowEnd : stop + }; +} + +function programmeMatchesNowPlaying(programme: ClippedProgramme, nowPlaying: ChannelState['nowPlaying'] | undefined): boolean { + if (!nowPlaying) { + return false; + } + + const nowPlayingStart = new Date(nowPlaying.startUtc); + const nowPlayingFinish = new Date(nowPlaying.finishUtc); + + return programme.start.getTime() === nowPlayingStart.getTime() && + programme.stop.getTime() === nowPlayingFinish.getTime(); +} + function LibrariesLoadingState() { return ( Loading libraries} subtitle="Fetching media sources and active scan status."> @@ -2540,6 +2822,10 @@ function ScreenContent({ return ; } + if (route.id === 'guide') { + return ; + } + if (route.id === 'schedules') { return ; } diff --git a/web/src/api/guide.ts b/web/src/api/guide.ts new file mode 100644 index 000000000..8c71be328 --- /dev/null +++ b/web/src/api/guide.ts @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getChannelStates, messageFromError, type ChannelState } from './channels'; +import { request } from './client'; +import type { components } from './generated/v1'; + +export type ChannelGuide = components['schemas']['ChannelGuideResponseModel']; +export type ChannelGuideChannel = components['schemas']['ChannelGuideChannelResponseModel']; +export type ChannelGuideProgramme = components['schemas']['ChannelGuideProgrammeResponseModel']; + +export interface GuideScreenData { + channelStates: ChannelState[]; + guide: ChannelGuide; +} + +export type GuideScreenQueryState = + | { + data: GuideScreenData; + error: null; + refresh: () => void; + setWindowStart: (start: Date) => void; + status: 'success'; + windowStart: Date; + windowEnd: Date; + } + | { data: null; error: string; refresh: () => void; status: 'error'; windowStart: Date; windowEnd: Date } + | { data: null; error: null; refresh: () => void; status: 'loading'; windowStart: Date; windowEnd: Date }; + +type GuideScreenState = + | { data: GuideScreenData; error: null; status: 'success' } + | { data: null; error: string; status: 'error' } + | { data: null; error: null; status: 'loading' }; + +export const GUIDE_PAST_MS = 60 * 60 * 1000; +export const GUIDE_WINDOW_MS = 13 * 60 * 60 * 1000; + +export function defaultGuideWindowStart(now = new Date()): Date { + return new Date(now.getTime() - GUIDE_PAST_MS); +} + +export function getGuide(start: Date, end: Date): Promise { + const query = new URLSearchParams({ + start: start.toISOString(), + end: end.toISOString() + }); + + return request(`/api/guide?${query.toString()}`); +} + +export async function getGuideScreenData(start: Date, end: Date): Promise { + const guide = await getGuide(start, end); + const channelStates = await getChannelStates().catch(() => []); + + return { channelStates, guide }; +} + +export function useGuideScreenQuery(channelStatePollMs = 30000): GuideScreenQueryState { + const [windowStart, setWindowStartState] = useState(() => defaultGuideWindowStart()); + const windowStartRef = useRef(windowStart); + const [state, setState] = useState({ + data: null, + error: null, + status: 'loading' + }); + const activeRef = useRef(true); + const requestIdRef = useRef(0); + + useEffect(() => { + activeRef.current = true; + + return () => { + activeRef.current = false; + }; + }, []); + + const windowEnd = new Date(windowStart.getTime() + GUIDE_WINDOW_MS); + + const load = useCallback((start = windowStartRef.current) => { + const end = new Date(start.getTime() + GUIDE_WINDOW_MS); + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + + getGuideScreenData(start, end) + .then((data) => { + if ( + activeRef.current && + requestIdRef.current === requestId && + windowStartRef.current.getTime() === start.getTime() + ) { + setState({ data, error: null, status: 'success' }); + } + }) + .catch((error: unknown) => { + if ( + activeRef.current && + requestIdRef.current === requestId && + windowStartRef.current.getTime() === start.getTime() + ) { + setState({ data: null, error: messageFromError(error), status: 'error' }); + } + }); + }, []); + + const loadChannelStates = useCallback(() => { + getChannelStates() + .then((channelStates) => { + if (!activeRef.current) { + return; + } + + setState((current) => { + if (current.status !== 'success') { + return current; + } + + return { + data: { ...current.data, channelStates }, + error: null, + status: 'success' + }; + }); + }) + .catch(() => { + // Keep the guide visible when the optional live-state refresh fails. + }); + }, []); + + useEffect(() => { + load(windowStart); + }, [load, windowStart]); + + useEffect(() => { + const intervalId = window.setInterval(loadChannelStates, Math.max(channelStatePollMs, 30000)); + + return () => { + window.clearInterval(intervalId); + }; + }, [channelStatePollMs, loadChannelStates]); + + const setWindowStart = useCallback((start: Date) => { + windowStartRef.current = start; + setState({ data: null, error: null, status: 'loading' }); + setWindowStartState(start); + }, []); + + const refresh = useCallback(() => { + setState({ data: null, error: null, status: 'loading' }); + load(windowStartRef.current); + }, [load]); + + if (state.status === 'success') { + return { data: state.data, error: null, refresh, setWindowStart, status: 'success', windowEnd, windowStart }; + } + + if (state.status === 'error') { + return { data: null, error: state.error, refresh, status: 'error', windowEnd, windowStart }; + } + + return { data: null, error: null, refresh, status: 'loading', windowEnd, windowStart }; +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index efcdd7471..860c705d3 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -2,6 +2,7 @@ export * from './auth'; export * from './channels'; export * from './client'; export * from './dashboard'; +export * from './guide'; export * from './libraries'; export * from './playouts'; export * from './schedules'; diff --git a/web/src/shell.css b/web/src/shell.css index 2dbfe68ff..d34c24a12 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -1892,6 +1892,245 @@ padding: var(--space-3, 6px) var(--space-5, 10px); } +.ctv-epg-screen { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: var(--space-6, 12px); +} + +.ctv-epg-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-5, 10px); + padding: var(--space-5, 10px) 0; + border-bottom: 1px solid var(--border-hairline); +} + +.ctv-epg-filter { + width: 190px; + flex: 0 1 190px; +} + +.ctv-epg-window { + min-width: 0; + max-width: 420px; + display: flex; + flex: 1 1 240px; + align-items: center; + gap: var(--space-4, 8px); +} + +.ctv-epg-window span { + color: var(--text-disabled); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: var(--text-2xs, 11px); + font-variant-numeric: tabular-nums; +} + +.ctv-epg-window input { + min-width: 0; + flex: 1; + accent-color: var(--action-primary); +} + +.ctv-epg-grid-shell { + min-height: 420px; + overflow: hidden; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-sm, 5px); + background: var(--surface-app); +} + +.ctv-epg-scroll { + width: 100%; + height: 100%; + overflow: auto; +} + +.ctv-epg-grid { + position: relative; + width: max-content; + min-height: 100%; +} + +.ctv-epg-time-head { + position: sticky; + top: 0; + z-index: 4; + display: flex; + border-bottom: 1px solid var(--border-hairline); + background: var(--surface-app); +} + +.ctv-epg-rail-head, +.ctv-epg-channel-rail { + position: sticky; + left: 0; + z-index: 3; + flex: 0 0 auto; + border-right: 1px solid var(--border-hairline); + background: var(--surface-card); +} + +.ctv-epg-rail-head { + z-index: 5; + background: var(--surface-app); +} + +.ctv-epg-time-slot { + flex: 0 0 auto; + display: flex; + align-items: center; + border-right: 1px solid var(--border-hairline); + color: var(--text-secondary); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: var(--text-xs, 12px); + font-variant-numeric: tabular-nums; + padding: 0 var(--space-5, 10px); +} + +.ctv-epg-now-marker { + position: absolute; + z-index: 2; + width: 2px; + background: var(--status-live); + pointer-events: none; +} + +.ctv-epg-now-marker span { + position: absolute; + top: -5px; + left: -4px; + width: 10px; + height: 10px; + border-radius: var(--radius-pill, 999px); + background: var(--status-live); + box-shadow: 0 0 8px var(--status-live); +} + +.ctv-epg-row { + display: flex; + border-bottom: 1px solid var(--border-hairline); +} + +.ctv-epg-channel-rail { + display: flex; + align-items: center; + gap: var(--space-5, 10px); + padding: 0 var(--space-6, 12px); +} + +.ctv-epg-channel-rail code { + width: 30px; + flex: 0 0 auto; + color: var(--status-live); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: var(--text-xs, 12px); + font-weight: var(--weight-medium, 500); + font-variant-numeric: tabular-nums; +} + +.ctv-epg-channel-rail > span:not(.ctv-channel-logo):not(.ctv-status-dot) { + min-width: 0; + overflow: hidden; + color: var(--text-primary); + font-size: var(--text-sm, 13px); + font-weight: var(--weight-medium, 500); + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-epg-track { + position: relative; + flex: 1; + min-width: 0; +} + +.ctv-epg-track-alt { + background: color-mix(in srgb, var(--surface-raised) 18%, transparent); +} + +.ctv-epg-empty { + position: absolute; + top: 50%; + left: var(--space-6, 12px); + transform: translateY(-50%); + color: var(--text-disabled); + font-size: var(--text-xs, 12px); +} + +.ctv-epg-programme { + position: absolute; + top: 5px; + bottom: 5px; + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; + overflow: hidden; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-sm, 5px); + background: var(--surface-raised); + padding: var(--space-4, 8px) var(--space-5, 10px); +} + +.ctv-epg-programme-live { + border-color: var(--status-live); + background: var(--ctv-live-soft); +} + +.ctv-epg-programme-filler { + background: var(--ctv-bg-sunken); +} + +.ctv-epg-programme-title { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3, 6px); +} + +.ctv-epg-programme-title strong, +.ctv-epg-programme span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-epg-programme-title strong { + color: var(--text-primary); + font-size: var(--text-xs, 12px); + font-weight: var(--weight-medium, 500); +} + +.ctv-epg-programme-filler .ctv-epg-programme-title strong { + color: var(--text-disabled); +} + +.ctv-epg-programme span { + color: var(--text-secondary); + font-size: var(--text-2xs, 11px); +} + +.ctv-epg-programme small { + margin-top: auto; + overflow: hidden; + color: var(--text-disabled); + font-size: 9px; + font-weight: var(--weight-medium, 500); + letter-spacing: var(--tracking-caps, 0.06em); + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.ctv-epg-programme-live small { + color: var(--status-live); +} + @media (max-width: 980px) { .ctv-app-shell { grid-template-columns: 1fr; @@ -1983,6 +2222,21 @@ justify-content: flex-start; } + .ctv-epg-toolbar { + align-items: stretch; + flex-direction: column; + } + + .ctv-epg-filter, + .ctv-epg-window { + width: 100%; + max-width: none; + } + + .ctv-epg-grid-shell { + min-height: 360px; + } + .ctv-schedule-block-main { grid-template-columns: 16px 38px minmax(0, 1fr); }