mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
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:
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'
|
||||
);
|
||||
Reference in new issue
Block a user