diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts new file mode 100644 index 000000000..50cd57aba --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts new file mode 100644 index 000000000..e6b1df8e0 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts @@ -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 = { + 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' + ); +} diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts new file mode 100644 index 000000000..efd80d6b4 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts @@ -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'); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts new file mode 100644 index 000000000..a2c01cf1a --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts @@ -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'); +} diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts new file mode 100644 index 000000000..e2ddc4000 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts @@ -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; + readonly scopes: Signal; + readonly scopeId: Signal; + setScope(id: string): void; + /** Programmes overlapping the window, keyed by `EpgGuideChannel.id`. */ + loadPrograms(window: EpgGuideWindow): Promise>; + /** Ids of channels with at least one programme in the window. */ + loadCoverage(window: EpgGuideWindow): Promise>; + readonly activeChannelId: Signal; + /** Switch playback; the guide stays open. */ + activate(channelId: string): void; + /** Optional programme search; the toolbar hides its field when absent. */ + searchPrograms?(query: string): Promise; + readonly catchUp?: EpgGuideCatchUp; +} + +export const EPG_GUIDE_SOURCE = new InjectionToken( + 'EPG_GUIDE_SOURCE' +);