feat(epg): add guide source contract, day layout maths and preferences

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 12:15:18 +02:00
1 parent 2701a82905
commit 59f27d1549
5 files changed
+425

No files matched your search

@@ -0,0 +1,82 @@
import { EpgProgram } from '@iptvnator/shared/interfaces';
import {
buildGuideDayAxis,
buildGuideRowBlocks,
buildGuideTicks,
EPG_GUIDE_ZOOM_DEFAULT,
guideNowLeftPx,
guideTrackWidthPx,
guideXForMs,
} from './epg-guide-layout.util';
const HOUR = 3_600_000;
function program(startMs: number, stopMs: number, title = 'P'): EpgProgram {
return {
start: new Date(startMs).toISOString(),
stop: new Date(stopMs).toISOString(),
channel: 'ch',
title,
desc: null,
category: null,
};
}
describe('epg-guide-layout.util', () => {
const axis = buildGuideDayAxis('2026-09-06');
it('builds a local-midnight day axis of exactly 24 hours', () => {
expect(axis.dayKey).toBe('2026-09-06');
expect(axis.endMs - axis.startMs).toBe(24 * HOUR);
expect(new Date(axis.startMs).getHours()).toBe(0);
});
it('places a tick every 30 minutes, hours emphasised', () => {
const ticks = buildGuideTicks(axis, 240);
expect(ticks).toHaveLength(48);
expect(ticks[0]).toEqual({ ms: axis.startMs, leftPx: 0, kind: 'hour' });
expect(ticks[1]).toEqual({
ms: axis.startMs + HOUR / 2,
leftPx: 120,
kind: 'half',
});
expect(guideTrackWidthPx(240)).toBe(5760);
});
it('maps instants to x by the hour width and hides "now" outside the day', () => {
expect(guideXForMs(axis, axis.startMs + 2 * HOUR, 240)).toBe(480);
expect(guideNowLeftPx(axis, axis.startMs + HOUR, 240)).toBe(240);
expect(guideNowLeftPx(axis, axis.startMs - 1, 240)).toBeNull();
expect(guideNowLeftPx(axis, axis.endMs, 240)).toBeNull();
});
it('lays out programmes overlapping the day, including boundary crossers, with tiers', () => {
const nowMs = axis.startMs + 16 * HOUR + 4 * 60_000;
const blocks = buildGuideRowBlocks(
[
program(axis.startMs - HOUR, axis.startMs + HOUR, 'Crosser'),
program(axis.startMs + 16 * HOUR, axis.startMs + 16.75 * HOUR, 'Now'),
program(axis.startMs + 17 * HOUR, axis.startMs + 17 * HOUR + 5 * 60_000, 'Micro'),
program(axis.endMs + HOUR, axis.endMs + 2 * HOUR, 'Tomorrow'),
],
{ axis, hourWidthPx: EPG_GUIDE_ZOOM_DEFAULT, nowMs, offsetMinutes: 0 }
);
expect(blocks.map((block) => block.block.program.title)).toEqual([
'Crosser',
'Now',
'Micro',
]);
expect(blocks[0].leftPx).toBe(-240);
expect(blocks[1].block.when).toBe('now');
expect(blocks[1].nowFillPercent).toBeCloseTo((4 / 45) * 100, 3);
expect(blocks[2].tier).toBe('narrow');
});
it('shifts programme times by the display offset before layout', () => {
const blocks = buildGuideRowBlocks(
[program(axis.startMs + 10 * HOUR, axis.startMs + 11 * HOUR)],
{ axis, hourWidthPx: 240, nowMs: axis.startMs, offsetMinutes: 60 }
);
expect(blocks[0].leftPx).toBe(11 * 240);
});
});
@@ -0,0 +1,128 @@
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { addDays, addMinutes } from 'date-fns';
import { parseEpgDateKey } from '../epg-date';
import {
buildTimelineBlocks,
TimelineAxis,
} from '../epg-timeline/epg-timeline.utils';
import {
buildTimelineRenderItems,
TimelineRenderBlock,
} from '../epg-timeline/epg-timeline-render.util';
const HOUR_MS = 3_600_000;
/** Pixels per hour. */
export const EPG_GUIDE_ZOOM_MIN = 120;
export const EPG_GUIDE_ZOOM_MAX = 480;
export const EPG_GUIDE_ZOOM_STEP = 20;
export const EPG_GUIDE_ZOOM_DEFAULT = 240;
export type EpgGuideDensity = 'comfortable' | 'compact';
export const EPG_GUIDE_ROW_HEIGHT_PX: Record<EpgGuideDensity, number> = {
comfortable: 60,
compact: 44,
};
export const EPG_GUIDE_CHANNEL_COLUMN_PX = 232;
/** Rows loaded ahead of the rendered range, in each direction. */
export const EPG_GUIDE_ROW_BUFFER = 10;
/** One selected day in DISPLAY time (local midnight to local midnight). */
export interface EpgGuideDayAxis extends TimelineAxis {
readonly dayKey: string;
}
export interface EpgGuideTick {
readonly ms: number;
readonly leftPx: number;
readonly kind: 'hour' | 'half';
}
export interface EpgGuideRowLayoutOptions {
readonly axis: EpgGuideDayAxis;
readonly hourWidthPx: number;
readonly nowMs: number;
readonly offsetMinutes: number;
readonly catchUpAvailable?: boolean;
}
export function buildGuideDayAxis(dayKey: string): EpgGuideDayAxis {
const start = parseEpgDateKey(dayKey);
return {
dayKey,
startMs: start.getTime(),
endMs: addDays(start, 1).getTime(),
};
}
export function guideTrackWidthPx(hourWidthPx: number): number {
return hourWidthPx * 24;
}
export function guideXForMs(
axis: TimelineAxis,
ms: number,
hourWidthPx: number
): number {
return ((ms - axis.startMs) / HOUR_MS) * hourWidthPx;
}
/** x of the now-line, or null when "now" is not on the selected day. */
export function guideNowLeftPx(
axis: TimelineAxis,
nowMs: number,
hourWidthPx: number
): number | null {
if (nowMs < axis.startMs || nowMs >= axis.endMs) {
return null;
}
return guideXForMs(axis, nowMs, hourWidthPx);
}
export function buildGuideTicks(
axis: TimelineAxis,
hourWidthPx: number
): EpgGuideTick[] {
const ticks: EpgGuideTick[] = [];
const start = new Date(axis.startMs);
for (let minute = 0; minute < 24 * 60; minute += 30) {
const ms = addMinutes(start, minute).getTime();
ticks.push({
ms,
leftPx: guideXForMs(axis, ms, hourWidthPx),
kind: minute % 60 === 0 ? 'hour' : 'half',
});
}
return ticks;
}
/**
* Positioned blocks for one channel row, sharing the timeline's block maths
* (`buildTimelineBlocks` → `buildTimelineRenderItems`) so both guides agree
* on tiers, minimum widths and the on-now fill. Programmes are shifted into
* display time by `offsetMinutes` and compared with the wall-clock `nowMs`
* (the display form of the EPG offset contract). Short-run grouping is off:
* a grid row has no room for group chips.
*/
export function buildGuideRowBlocks(
programs: readonly EpgProgram[],
options: EpgGuideRowLayoutOptions
): TimelineRenderBlock[] {
const { axis, hourWidthPx, nowMs, offsetMinutes } = options;
const blocks = buildTimelineBlocks(
programs,
axis,
nowMs,
offsetMinutes
).filter(
(block) => block.stopMs > axis.startMs && block.startMs < axis.endMs
);
const items = buildTimelineRenderItems(blocks, hourWidthPx / 60, {
allowGroup: false,
nowMs,
archivePlaybackAvailable: options.catchUpAvailable ?? false,
});
return items.filter(
(item): item is TimelineRenderBlock => item.kind === 'block'
);
}
@@ -0,0 +1,67 @@
import {
EPG_GUIDE_DENSITY_KEY,
EPG_GUIDE_DOCK_COLLAPSED_KEY,
EPG_GUIDE_ONLY_WITH_EPG_KEY,
EPG_GUIDE_ZOOM_KEY,
persistEpgGuideDockCollapsed,
persistEpgGuidePreferences,
restoreEpgGuideDockCollapsed,
restoreEpgGuidePreferences,
} from './epg-guide-preferences';
import { EPG_GUIDE_ZOOM_DEFAULT } from './epg-guide-layout.util';
describe('epg-guide-preferences', () => {
beforeEach(() => localStorage.clear());
it('falls back to comfortable density, default zoom and the toggle off', () => {
expect(restoreEpgGuidePreferences()).toEqual({
density: 'comfortable',
zoom: EPG_GUIDE_ZOOM_DEFAULT,
onlyWithEpg: false,
});
expect(restoreEpgGuideDockCollapsed()).toBe(false);
});
it('round-trips every preference and clamps the zoom', () => {
persistEpgGuidePreferences({
density: 'compact',
zoom: 9_999,
onlyWithEpg: true,
});
persistEpgGuideDockCollapsed(true);
expect(localStorage.getItem(EPG_GUIDE_DENSITY_KEY)).toBe('compact');
expect(localStorage.getItem(EPG_GUIDE_ONLY_WITH_EPG_KEY)).toBe('1');
expect(localStorage.getItem(EPG_GUIDE_DOCK_COLLAPSED_KEY)).toBe('1');
expect(restoreEpgGuidePreferences()).toEqual({
density: 'compact',
zoom: 480,
onlyWithEpg: true,
});
expect(restoreEpgGuideDockCollapsed()).toBe(true);
});
it('ignores corrupt stored values', () => {
localStorage.setItem(EPG_GUIDE_DENSITY_KEY, 'huge');
localStorage.setItem(EPG_GUIDE_ZOOM_KEY, 'abc');
expect(restoreEpgGuidePreferences().density).toBe('comfortable');
expect(restoreEpgGuidePreferences().zoom).toBe(EPG_GUIDE_ZOOM_DEFAULT);
});
it('survives a throwing storage', () => {
const broken = {
getItem: () => {
throw new Error('blocked');
},
setItem: () => {
throw new Error('blocked');
},
} as unknown as Storage;
expect(() =>
persistEpgGuidePreferences(
{ density: 'compact', zoom: 200, onlyWithEpg: false },
broken
)
).not.toThrow();
expect(restoreEpgGuidePreferences(broken).density).toBe('comfortable');
});
});
@@ -0,0 +1,83 @@
import {
EPG_GUIDE_ZOOM_DEFAULT,
EPG_GUIDE_ZOOM_MAX,
EPG_GUIDE_ZOOM_MIN,
EpgGuideDensity,
} from './epg-guide-layout.util';
export const EPG_GUIDE_DENSITY_KEY = 'epg-guide:density';
export const EPG_GUIDE_ZOOM_KEY = 'epg-guide:zoom';
export const EPG_GUIDE_ONLY_WITH_EPG_KEY = 'epg-guide:only-with-epg';
export const EPG_GUIDE_DOCK_COLLAPSED_KEY = 'epg-guide:dock-collapsed';
export interface EpgGuidePreferences {
density: EpgGuideDensity;
zoom: number;
onlyWithEpg: boolean;
}
function isDensity(value: unknown): value is EpgGuideDensity {
return value === 'comfortable' || value === 'compact';
}
export function clampGuideZoom(value: number): number {
if (!Number.isFinite(value)) {
return EPG_GUIDE_ZOOM_DEFAULT;
}
return Math.min(EPG_GUIDE_ZOOM_MAX, Math.max(EPG_GUIDE_ZOOM_MIN, value));
}
function read(storage: Storage, key: string): string | null {
try {
return storage.getItem(key);
} catch {
return null;
}
}
function write(storage: Storage, key: string, value: string): void {
try {
storage.setItem(key, value);
} catch {
// Storage may be unavailable (private mode, quota); the guide simply
// starts from defaults next time.
}
}
function defaultStorage(): Storage {
return globalThis.localStorage;
}
export function restoreEpgGuidePreferences(
storage: Storage = defaultStorage()
): EpgGuidePreferences {
const density = read(storage, EPG_GUIDE_DENSITY_KEY);
const zoom = Number(read(storage, EPG_GUIDE_ZOOM_KEY));
return {
density: isDensity(density) ? density : 'comfortable',
zoom: clampGuideZoom(zoom === 0 ? Number.NaN : zoom),
onlyWithEpg: read(storage, EPG_GUIDE_ONLY_WITH_EPG_KEY) === '1',
};
}
export function persistEpgGuidePreferences(
preferences: EpgGuidePreferences,
storage: Storage = defaultStorage()
): void {
write(storage, EPG_GUIDE_DENSITY_KEY, preferences.density);
write(storage, EPG_GUIDE_ZOOM_KEY, String(clampGuideZoom(preferences.zoom)));
write(storage, EPG_GUIDE_ONLY_WITH_EPG_KEY, preferences.onlyWithEpg ? '1' : '0');
}
export function restoreEpgGuideDockCollapsed(
storage: Storage = defaultStorage()
): boolean {
return read(storage, EPG_GUIDE_DOCK_COLLAPSED_KEY) === '1';
}
export function persistEpgGuideDockCollapsed(
collapsed: boolean,
storage: Storage = defaultStorage()
): void {
write(storage, EPG_GUIDE_DOCK_COLLAPSED_KEY, collapsed ? '1' : '0');
}
@@ -0,0 +1,65 @@
import { InjectionToken, Signal } from '@angular/core';
import { EpgProgram } from '@iptvnator/shared/interfaces';
/** One row of the guide. `id` is host-stable (M3U: `Channel.id`). */
export interface EpgGuideChannel {
id: string;
/** 1-based position inside the current scope; shown in the channel cell. */
number: number;
name: string;
logoUrl: string | null;
/**
* Programme lookup key (M3U: tvg-id, else tvg-name, else name). `null`
* means the host already knows there is no EPG binding, so the guide
* renders "no programme information" without asking.
*/
epgKey: string | null;
}
export type EpgGuideScopeKind = 'all' | 'group' | 'favorites';
export interface EpgGuideScope {
id: string;
label: string;
kind: EpgGuideScopeKind;
}
/** A request window. Instants are provider-clock ms (display offset removed). */
export interface EpgGuideWindow {
channels: EpgGuideChannel[];
fromMs: number;
toMs: number;
}
export interface EpgGuideCatchUp {
canWatch(channel: EpgGuideChannel, program: EpgProgram): boolean;
watch(channel: EpgGuideChannel, program: EpgProgram): void;
}
/**
* Everything the guide needs from its host. The host owns scope state,
* playback and the player; the guide owns rendering, caching and keyboard
* navigation. Portal hosts implement the same contract with their own EPG
* feeds (sub-project 2).
*/
export interface EpgGuideSource {
/** Scope-resolved channels in playlist order (radio/movies excluded by host). */
readonly channels: Signal<EpgGuideChannel[]>;
readonly scopes: Signal<EpgGuideScope[]>;
readonly scopeId: Signal<string>;
setScope(id: string): void;
/** Programmes overlapping the window, keyed by `EpgGuideChannel.id`. */
loadPrograms(window: EpgGuideWindow): Promise<Map<string, EpgProgram[]>>;
/** Ids of channels with at least one programme in the window. */
loadCoverage(window: EpgGuideWindow): Promise<Set<string>>;
readonly activeChannelId: Signal<string | null>;
/** Switch playback; the guide stays open. */
activate(channelId: string): void;
/** Optional programme search; the toolbar hides its field when absent. */
searchPrograms?(query: string): Promise<EpgProgram[]>;
readonly catchUp?: EpgGuideCatchUp;
}
export const EPG_GUIDE_SOURCE = new InjectionToken<EpgGuideSource>(
'EPG_GUIDE_SOURCE'
);