feat(web): Guide / EPG screen (#85) #129
+211
-1
@@ -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
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user