feat(web): Guide / EPG screen (#85) #129

Merged
timothy merged 2 commits from feat/85-epg into main 2026-07-05 23:12:26 +02:00
5 changed files with 911 additions and 1 deletions
+211 -1
View File
@@ -71,10 +71,147 @@ describe('ChicoryTV SPA scaffold', () => {
render(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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<string, unknown> = {}) {
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
};
}
+286
View File
@@ -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 (
<Card title={<h2>Loading guide</h2>} subtitle="Fetching a bounded JSON guide window and live channel state.">
<div className="ctv-dashboard-state">
<Spinner tone="muted" />
<span>Loading guide</span>
</div>
</Card>
);
}
function GuideErrorState({ error, refresh }: { error: string; refresh: () => void }) {
return (
<Card
title={<h2>Guide unavailable</h2>}
subtitle="The API returned an error while loading the JSON guide."
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Retry</Button>}
>
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
</Card>
);
}
function GuideScreen() {
const query = useGuideScreenQuery();
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const intervalId = window.setInterval(() => {
setNow(new Date());
}, 60000);
return () => {
window.clearInterval(intervalId);
};
}, []);
if (query.status === 'loading') {
return <GuideLoadingState />;
}
if (query.status === 'error') {
return <GuideErrorState error={query.error} refresh={query.refresh} />;
}
const channels = query.data.guide.channels;
const channelStatesByNumber = new Map(query.data.channelStates.map((state) => [state.channelNumber, state]));
const windowStart = new Date(query.data.guide.start);
const windowEnd = new Date(query.data.guide.end);
const slots = guideSlots(windowStart, windowEnd);
const totalWidth = EPG_RAIL_WIDTH + slots.length * EPG_SLOT_WIDTH;
const nowOffset = offsetPx(now, windowStart);
const nowInWindow = now >= windowStart && now <= windowEnd;
const moveWindow = (delta: number) => {
query.setWindowStart(new Date(query.windowStart.getTime() + delta * GUIDE_WINDOW_MS));
};
const jumpToNow = () => {
query.setWindowStart(defaultGuideWindowStart(now));
};
return (
<div className="ctv-epg-screen">
<section className="ctv-epg-toolbar" aria-label="Guide controls">
<div className="ctv-epg-filter">
<Select disabled label="Channel group" options={['All channels']} size="sm" value="All channels" />
</div>
<div className="ctv-epg-window">
<span>{formatGuideTime(windowStart)}</span>
<input
aria-label="Guide window position"
disabled
max="100"
min="0"
type="range"
value={nowInWindow ? Math.round((now.getTime() - windowStart.getTime()) / (windowEnd.getTime() - windowStart.getTime()) * 100) : 0}
readOnly
/>
<span>{formatGuideTime(windowEnd)}</span>
</div>
<Badge tone={nowInWindow ? 'accent' : 'neutral'} dot={nowInWindow}>Now {formatGuideTime(now)}</Badge>
<Button onClick={() => moveWindow(-1)} variant="secondary">Previous</Button>
<Button onClick={() => moveWindow(1)} variant="secondary">Next guide window</Button>
<Button onClick={jumpToNow} startIcon={<Crosshair aria-hidden="true" size={15} />} variant="primary">Jump to now</Button>
</section>
<section className="ctv-epg-grid-shell" aria-label="Guide timeline">
<div className="ctv-epg-scroll">
<div
aria-label="Channel guide"
className="ctv-epg-grid"
role="grid"
style={{ minWidth: totalWidth }}
>
<div className="ctv-epg-time-head" role="row" style={{ height: EPG_HEADER_HEIGHT }}>
<div className="ctv-epg-rail-head" style={{ width: EPG_RAIL_WIDTH }} />
{slots.map((slot) => (
<div className="ctv-epg-time-slot" key={slot.toISOString()} role="columnheader" style={{ width: EPG_SLOT_WIDTH }}>
{formatGuideTime(slot)}
</div>
))}
</div>
{nowInWindow && (
<div
aria-hidden="true"
className="ctv-epg-now-marker"
style={{
bottom: 0,
left: EPG_RAIL_WIDTH + nowOffset,
top: EPG_HEADER_HEIGHT
}}
>
<span />
</div>
)}
{channels.map((channel, index) => (
<GuideChannelRow
channel={channel}
channelState={channelStatesByNumber.get(channel.number) ?? null}
index={index}
key={channel.number}
windowEnd={windowEnd}
windowStart={windowStart}
/>
))}
</div>
</div>
</section>
</div>
);
}
function GuideChannelRow({
channel,
channelState,
index,
windowEnd,
windowStart
}: {
channel: ChannelGuideChannel;
channelState: ChannelState | null;
index: number;
windowEnd: Date;
windowStart: Date;
}) {
const visibleProgrammes = channel.programmes
.map((programme) => clipProgramme(programme, windowStart, windowEnd))
.filter((programme): programme is ClippedProgramme => programme !== null);
return (
<div className="ctv-epg-row" role="row" style={{ height: EPG_ROW_HEIGHT }}>
<div className="ctv-epg-channel-rail" role="rowheader" style={{ width: EPG_RAIL_WIDTH }}>
<code>{channel.number}</code>
<ChannelLogo name={channel.name} size={30} />
<span>{channel.name}</span>
{channelState?.onAir && <StatusDot status="live" size={7} />}
</div>
<div className={`ctv-epg-track${index % 2 ? ' ctv-epg-track-alt' : ''}`} role="gridcell">
{visibleProgrammes.length === 0 ? (
<span className="ctv-epg-empty">No programmes in this window</span>
) : visibleProgrammes.map((programme) => (
<GuideProgrammeBlock
key={`${programme.title}-${programme.start.getTime()}-${programme.stop.getTime()}`}
programme={programme}
channelState={channelState}
windowStart={windowStart}
/>
))}
</div>
</div>
);
}
interface ClippedProgramme {
category: null | string;
fillerKind: string;
start: Date;
stop: Date;
subTitle: null | string;
title: string;
visibleStart: Date;
visibleStop: Date;
}
function GuideProgrammeBlock({
channelState,
programme,
windowStart
}: {
channelState: ChannelState | null;
programme: ClippedProgramme;
windowStart: Date;
}) {
const filler = programme.fillerKind !== 'None';
const live = Boolean(channelState?.onAir && programmeMatchesNowPlaying(programme, channelState.nowPlaying));
const left = offsetPx(programme.visibleStart, windowStart) + 3;
const width = Math.max(24, offsetPx(programme.visibleStop, programme.visibleStart) - 6);
return (
<article
className={`ctv-epg-programme${live ? ' ctv-epg-programme-live' : ''}${filler ? ' ctv-epg-programme-filler' : ''}`}
style={{ left, width }}
title={`${programme.title}${programme.subTitle ? ` - ${programme.subTitle}` : ''}`}
>
<div className="ctv-epg-programme-title">
{live && <StatusDot status="live" size={6} />}
<strong>{programme.title}</strong>
</div>
{programme.subTitle && <span>{programme.subTitle}</span>}
{filler ? <Badge tone="neutral">Filler</Badge> : programme.category && <small>{programme.category}</small>}
</article>
);
}
function guideSlots(start: Date, end: Date): Date[] {
const slots: Date[] = [];
const slotMs = EPG_SLOT_MINUTES * 60 * 1000;
for (let value = start.getTime(); value < end.getTime(); value += slotMs) {
slots.push(new Date(value));
}
return slots;
}
function formatGuideTime(value: Date): string {
return value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function offsetPx(value: Date, start: Date): number {
return (value.getTime() - start.getTime()) / (EPG_SLOT_MINUTES * 60 * 1000) * EPG_SLOT_WIDTH;
}
function clipProgramme(programme: ChannelGuideProgramme, windowStart: Date, windowEnd: Date): ClippedProgramme | null {
const start = new Date(programme.start);
const stop = new Date(programme.stop);
if (Number.isNaN(start.getTime()) || Number.isNaN(stop.getTime()) || stop <= windowStart || start >= windowEnd) {
return null;
}
return {
category: programme.category,
fillerKind: programme.fillerKind,
start,
stop,
subTitle: programme.subTitle,
title: programme.title,
visibleStart: start < windowStart ? windowStart : start,
visibleStop: stop > windowEnd ? windowEnd : stop
};
}
function programmeMatchesNowPlaying(programme: ClippedProgramme, nowPlaying: ChannelState['nowPlaying'] | undefined): boolean {
if (!nowPlaying) {
return false;
}
const nowPlayingStart = new Date(nowPlaying.startUtc);
const nowPlayingFinish = new Date(nowPlaying.finishUtc);
return programme.start.getTime() === nowPlayingStart.getTime() &&
programme.stop.getTime() === nowPlayingFinish.getTime();
}
function LibrariesLoadingState() {
return (
<Card title={<h2>Loading libraries</h2>} subtitle="Fetching media sources and active scan status.">
@@ -2540,6 +2822,10 @@ function ScreenContent({
return <ChannelsScreen />;
}
if (route.id === 'guide') {
return <GuideScreen />;
}
if (route.id === 'schedules') {
return <ScheduleScreen />;
}
+159
View File
@@ -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<ChannelGuide> {
const query = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString()
});
return request<ChannelGuide>(`/api/guide?${query.toString()}`);
}
export async function getGuideScreenData(start: Date, end: Date): Promise<GuideScreenData> {
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<GuideScreenState>({
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 };
}
+1
View File
@@ -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';
+254
View File
@@ -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);
}