refactor(epg): split the timeline into reusable modules

Address the file-size review finding and prepare for a future EPG list view by
extracting the view-agnostic pieces out of the 538-line timeline component and
411-line utils file; both are now well under the line ceiling and the shared
building blocks are re-exported from `@iptvnator/ui/epg`.

- `epg-timeline.utils.ts` (227) keeps axis/blocks/date helpers; the
  short-programme render strategy (tiers, grouping, zoom bounds) moves to
  `epg-timeline-render.util.ts` (re-exported for API compatibility).
- `epg-archive.util.ts` — pure catch-up gating (`isWithinArchiveWindow`,
  `canCatchUpProgramme`, `epgDialogActionFor`) off `when`/`startMs` primitives.
- `epg-summary.util.ts` — `EpgTimelineSummary` + collapsed-summary progress maths.
- `epg-programme-dialog.service.ts` — `EpgProgrammeDialogService`, opens the
  shared details dialog and returns the chosen action.
- `epg-timeline-scroll.controller.ts` — `TimelineScrollController` owns ribbon
  scrolling + channel-select auto-focus (with its migrated spec).

Behaviour unchanged. epg-timeline.component.ts 538→393, epg-timeline.utils.ts
411→227. Tests: ui-epg 78 (10 suites), portal-shared-ui 47, playlist-m3u 30;
lint + web build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.8 committed 2026-07-01 16:54:24 +02:00
1 parent a4251d881c
commit f8931f0ded
12 files changed
+705 -512

No files matched your search

+18 -2
View File
@@ -274,6 +274,22 @@ surfaces: the M3U video player, the unified live tab, and the Xtream and Stalker
live-stream layouts. It replaces the former vertical `app-epg-list` /
`app-epg-view`.
The component stays presentation-focused; the reusable, view-agnostic pieces
(so a future list view can share them) are split out and re-exported from
`@iptvnator/ui/epg`:
- `epg-timeline.utils.ts` (axis/blocks/date helpers) + `epg-timeline-render.util.ts`
(short-programme tiers, grouping, zoom bounds) — the ribbon geometry.
- `epg-archive.util.ts` — catch-up gating (`isWithinArchiveWindow`,
`canCatchUpProgramme`, `epgDialogActionFor`) off `when`/`startMs` primitives.
- `epg-summary.util.ts` — `EpgTimelineSummary` + collapsed-summary progress maths
(`summaryProgress` / `summaryMinutesLeft` / …).
- `epg-programme-dialog.service.ts` — `EpgProgrammeDialogService`, opens the
shared details dialog and returns the chosen `live` / `timeshift` action.
- `epg-timeline-scroll.controller.ts` — `TimelineScrollController` (ribbon
scrolling + channel-select auto-focus); timeline-specific, kept out of the
component so it stays under the line ceiling.
- **One channel, preloaded window.** The panel always shows a single channel.
Each provider returns a multi-day window in roughly one call (M3U
`GET_CHANNEL_PROGRAMS`; Stalker `get_epg_info`; Xtream `get_simple_data_table`),
@@ -288,8 +304,8 @@ live-stream layouts. It replaces the former vertical `app-epg-list` /
zoom changes, or a host re-emitting the same data never re-jump the viewport;
switching channels (or returning after viewing an empty-day channel) re-centres.
The explicit "Now" button still animates (`behavior: 'smooth'`) since it is a
deliberate user action. See `EpgTimelineComponent.maybeAutoFocus` /
`focusCurrentProgram`.
deliberate user action. See `TimelineScrollController.maybeAutoFocus` /
`focusCurrentProgram` in `epg-timeline-scroll.controller.ts`.
- **Controlled component.** `app-epg-timeline` is presentation-only: it takes
`programs`, `archivePlaybackAvailable`, `archiveDays`, `activeProgram`,
`isLivePlayback`, `loading`, `emptyReason`, `selectedDate`, `collapsed`,
+11
View File
@@ -5,6 +5,17 @@ export * from './lib/epg-timeline/epg-timeline.component';
export * from './lib/epg-timeline/epg-timeline-empty-state.component';
export * from './lib/epg-timeline/epg-timeline-track.component';
export * from './lib/epg-timeline/epg-timeline.utils';
// Reusable EPG-view building blocks (shared by the timeline; a future list view
// can reuse them too).
export * from './lib/epg-timeline/epg-archive.util';
export * from './lib/epg-programme-dialog.service';
export {
formatClockTime,
summaryHasTimeRange,
summaryHasTitle,
summaryMinutesLeft,
summaryProgress,
} from './lib/epg-timeline/epg-summary.util';
export * from './lib/epg-progress-panel/epg-progress-panel.component';
export * from './lib/epg-source-status/epg-source-status.component';
export * from './lib/multi-epg/multi-epg-container.component';
@@ -11,7 +11,7 @@ import { EpgProgram } from '@iptvnator/shared/interfaces';
export type EpgItemDialogAction = 'live' | 'timeshift';
type EpgItemDialogData = EpgProgram & {
export type EpgItemDialogData = EpgProgram & {
channelName?: string | null;
channel_name?: string | null;
display_name?: string | null;
@@ -0,0 +1,24 @@
import { inject, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Observable } from 'rxjs';
import {
EpgItemDescriptionComponent,
EpgItemDialogAction,
EpgItemDialogData,
} from './epg-list/epg-item-description/epg-item-description.component';
/**
* Opens the shared programme-details dialog and returns the chosen action.
* Extracted so any EPG surface (the timeline today, a list view later) can open
* it the same way and react to `live` / `timeshift`.
*/
@Injectable({ providedIn: 'root' })
export class EpgProgrammeDialogService {
private readonly dialog = inject(MatDialog);
open(data: EpgItemDialogData): Observable<EpgItemDialogAction | undefined> {
return this.dialog
.open(EpgItemDescriptionComponent, { width: '540px', data })
.afterClosed();
}
}
@@ -0,0 +1,51 @@
import { EpgItemDialogAction } from '../epg-list/epg-item-description/epg-item-description.component';
import { TimelineWhen } from './epg-timeline.utils';
const DAY_MS = 24 * 60 * 60_000;
/**
* Catch-up / archive gating, view-agnostic so both the timeline and a future
* list view can share it. Works off primitives (`when`, `startMs`) rather than
* a timeline block.
*/
/** Whether a programme's start is inside the catch-up window (0 days = unlimited). */
export function isWithinArchiveWindow(
startMs: number,
archiveDays: number,
nowMs: number
): boolean {
if (archiveDays <= 0) {
return true; // capability flagged but no explicit window
}
return startMs >= nowMs - archiveDays * DAY_MS;
}
/** Whether a past programme is playable from the catch-up archive. */
export function canCatchUpProgramme(
when: TimelineWhen,
startMs: number,
archivePlaybackAvailable: boolean,
archiveDays: number,
nowMs: number
): boolean {
return (
archivePlaybackAvailable &&
when === 'past' &&
isWithinArchiveWindow(startMs, archiveDays, nowMs)
);
}
/** Primary action for the programme-details dialog given its playback state. */
export function epgDialogActionFor(
when: TimelineWhen,
canCatchUp: boolean
): EpgItemDialogAction | null {
if (when === 'now') {
return 'live';
}
if (canCatchUp) {
return 'timeshift';
}
return null;
}
@@ -0,0 +1,84 @@
/**
* Collapsed-summary payload (the current / archive programme shown when the
* panel is collapsed). View-agnostic so a future list view can reuse the same
* summary shape and progress maths.
*/
export interface EpgTimelineSummary {
readonly title?: string | null;
readonly start?: string | number | Date | null;
readonly stop?: string | number | Date | null;
readonly progress?: number | null;
}
function clampPercent(value: number): number {
return Math.min(100, Math.max(0, value));
}
function toTimeMs(
value: string | number | Date | null | undefined
): number | null {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed =
value instanceof Date
? value.getTime()
: typeof value === 'number'
? value
: Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
/** Whether the summary has a non-blank title. */
export function summaryHasTitle(
summary: EpgTimelineSummary | null | undefined
): boolean {
const title = summary?.title;
return typeof title === 'string' && title.trim().length > 0;
}
/** Whether the summary carries a start or stop time. */
export function summaryHasTimeRange(
summary: EpgTimelineSummary | null | undefined
): boolean {
return !!summary?.start || !!summary?.stop;
}
/** Progress 0–100 for the summary at `nowMs`, or null when it can't be computed. */
export function summaryProgress(
summary: EpgTimelineSummary | null | undefined,
nowMs: number
): number | null {
if (!summary) {
return null;
}
const explicit = Number(summary.progress);
if (Number.isFinite(explicit)) {
return clampPercent(explicit);
}
const startMs = toTimeMs(summary.start);
const stopMs = toTimeMs(summary.stop);
if (startMs === null || stopMs === null || stopMs <= startMs) {
return null;
}
return clampPercent(((nowMs - startMs) / (stopMs - startMs)) * 100);
}
/** Whole minutes left until the summary's stop, or null. */
export function summaryMinutesLeft(
summary: EpgTimelineSummary | null | undefined,
nowMs: number
): number | null {
const stopMs = toTimeMs(summary?.stop);
if (stopMs === null) {
return null;
}
return Math.max(0, Math.round((stopMs - nowMs) / 60_000));
}
/** HH:MM clock label for a timestamp. */
export function formatClockTime(ms: number): string {
const date = new Date(ms);
const pad = (value: number) => String(value).padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
@@ -0,0 +1,188 @@
import { TIMELINE_MINUTE_MS, TimelineBlock } from './epg-timeline.utils';
// ───────────────────────── short-programme strategy ─────────────────────────
// A proportional ribbon makes a 5-minute programme a 2px sliver. The fixes:
// A — a minimum block width so every programme stays clickable;
// B — content tiers chosen by rendered width (wide → micro);
// E — grouping runs of ≥4 consecutive short programmes when zoomed out.
// (C — the hover popover — and D — the zoom slider — live in the components.)
/** Smallest rendered block width; a sliver gets bumped up to this. */
export const TIMELINE_MIN_BLOCK_WIDTH_PX = 40;
/** Gap subtracted from each block so neighbours don't touch. */
export const TIMELINE_BLOCK_GAP_PX = 4;
/** Zoom (px/min) slider bounds. */
export const TIMELINE_ZOOM_MIN = 0.8;
export const TIMELINE_ZOOM_MAX = 6;
export const TIMELINE_ZOOM_STEP = 0.1;
/** Below this zoom, dense runs of short programmes collapse into a group chip. */
export const TIMELINE_GROUP_ZOOM_MAX = 1.3;
/** Zoom applied when a group chip is expanded. */
export const TIMELINE_GROUP_EXPAND_ZOOM = 3.4;
/** A programme is "short" below this duration (minutes). */
export const TIMELINE_SHORT_MAX_MIN = 10;
/** Minimum consecutive shorts before they group. */
export const TIMELINE_GROUP_MIN_RUN = 4;
export type TimelineTier = 'wide' | 'med' | 'narrow' | 'micro';
export interface TimelineRenderBlock {
readonly kind: 'block';
readonly key: string;
readonly block: TimelineBlock;
readonly leftPx: number;
readonly widthPx: number;
readonly tier: TimelineTier;
/** Live progress 0–100 for the on-now block, else 0. */
readonly nowFillPercent: number;
/** Past programme playable from the catch-up archive. */
readonly canCatchUp: boolean;
}
export interface TimelineRenderGroup {
readonly kind: 'group';
readonly key: string;
readonly leftPx: number;
readonly widthPx: number;
readonly count: number;
readonly startMs: number;
readonly stopMs: number;
}
export type TimelineRenderItem = TimelineRenderBlock | TimelineRenderGroup;
export interface TimelineRenderOptions {
readonly minWidthPx?: number;
readonly gapPx?: number;
readonly allowGroup?: boolean;
readonly nowMs?: number;
readonly archivePlaybackAvailable?: boolean;
/** Earliest start playable from the archive; -Infinity = whole past. */
readonly archiveWindowStartMs?: number;
}
/** Content tier for a block of the given rendered width. */
export function tierFor(widthPx: number): TimelineTier {
if (widthPx >= 132) return 'wide';
if (widthPx >= 70) return 'med';
if (widthPx >= 30) return 'narrow';
return 'micro';
}
/** Adaptive tick spacing (minutes) — denser as the user zooms in. */
export function timelineTickStepForScale(scale: number): number {
if (scale < 2.2) return 120;
if (scale < 3.5) return 60;
if (scale < 5) return 30;
return 15;
}
function nowFillFor(block: TimelineBlock, nowMs: number): number {
if (block.when !== 'now') {
return 0;
}
const total = block.stopMs - block.startMs;
if (total <= 0) {
return 0;
}
const pct = ((nowMs - block.startMs) / total) * 100;
return Math.min(100, Math.max(0, pct));
}
function toRenderBlock(
block: TimelineBlock,
scale: number,
minWidthPx: number,
gapPx: number,
nowMs: number,
archivePlaybackAvailable: boolean,
archiveWindowStartMs: number
): TimelineRenderBlock {
const rawWidth = Math.max(minWidthPx, block.durationMin * scale);
return {
kind: 'block',
key: block.key,
block,
leftPx: block.offsetMin * scale,
widthPx: Math.max(minWidthPx - gapPx, rawWidth - gapPx),
tier: tierFor(rawWidth),
nowFillPercent: nowFillFor(block, nowMs),
canCatchUp:
archivePlaybackAvailable &&
block.when === 'past' &&
block.startMs >= archiveWindowStartMs,
};
}
/**
* Turn timeline blocks into positioned render items, applying the minimum-width
* floor (A), per-width content tiers (B) and short-run grouping (E).
*/
export function buildTimelineRenderItems(
blocks: readonly TimelineBlock[],
scale: number,
options: TimelineRenderOptions = {}
): TimelineRenderItem[] {
const minWidthPx = options.minWidthPx ?? TIMELINE_MIN_BLOCK_WIDTH_PX;
const gapPx = options.gapPx ?? TIMELINE_BLOCK_GAP_PX;
const allowGroup = options.allowGroup ?? false;
const nowMs = options.nowMs ?? Date.now();
const archiveAvailable = options.archivePlaybackAvailable ?? false;
const archiveWindowStartMs =
options.archiveWindowStartMs ?? Number.NEGATIVE_INFINITY;
const items: TimelineRenderItem[] = [];
let run: TimelineBlock[] = [];
const pushBlock = (block: TimelineBlock) =>
items.push(
toRenderBlock(
block,
scale,
minWidthPx,
gapPx,
nowMs,
archiveAvailable,
archiveWindowStartMs
)
);
const flushRun = () => {
if (run.length >= TIMELINE_GROUP_MIN_RUN) {
const first = run[0];
const last = run[run.length - 1];
const spanPx = ((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
items.push({
kind: 'group',
key: `group-${first.key}-${last.key}`,
leftPx: first.offsetMin * scale,
widthPx: Math.max(minWidthPx - gapPx, spanPx - gapPx),
count: run.length,
startMs: first.startMs,
stopMs: last.stopMs,
});
} else {
run.forEach(pushBlock);
}
run = [];
};
for (const block of blocks) {
// Never fold the on-air programme into a "N short" group chip — that
// drops its `when: 'now'` highlight and leaves the auto-focused viewport
// centred on a chip with no visible "now" state. Keep it standalone.
if (
allowGroup &&
block.durationMin < TIMELINE_SHORT_MAX_MIN &&
block.when !== 'now'
) {
run.push(block);
continue;
}
flushRun();
pushBlock(block);
}
flushRun();
return items;
}
@@ -0,0 +1,136 @@
import { EpgProgram } from '@iptvnator/shared/interfaces';
import {
programsFocusKey,
TimelineScrollController,
} from './epg-timeline-scroll.controller';
import {
buildTimelineAxis,
buildTimelineBlocks,
} from './epg-timeline.utils';
function programAt(
startOffsetMin: number,
durationMin: number,
title = 'P'
): EpgProgram {
const start = new Date(Date.now() + startOffsetMin * 60_000);
const stop = new Date(start.getTime() + durationMin * 60_000);
return {
start: start.toISOString(),
stop: stop.toISOString(),
channel: 'ch',
title,
desc: null,
category: null,
};
}
describe('TimelineScrollController', () => {
describe('programsFocusKey (channel-change auto-focus identity)', () => {
const programFor = (channel: string): EpgProgram => ({
...programAt(0, 60),
channel,
});
it('is empty for an empty programme set so no focus is attempted', () => {
expect(programsFocusKey([])).toBe('');
});
it('is stable for the same programme set (no re-jump on re-render)', () => {
const programs = [programFor('a'), programFor('a')];
expect(programsFocusKey(programs)).toBe(
programsFocusKey([...programs])
);
});
it('differs when the channel changes (re-focus on channel switch)', () => {
expect(programsFocusKey([programFor('alpha')])).not.toBe(
programsFocusKey([programFor('beta')])
);
});
});
describe('maybeAutoFocus (channel-select centring)', () => {
let programs: EpgProgram[];
let controller: TimelineScrollController;
let scrollSpy: jest.SpyInstance;
beforeEach(() => {
programs = [];
const nowMs = Date.now();
controller = new TimelineScrollController({
ribbon: () => undefined,
scale: () => 1,
axis: () => buildTimelineAxis(programs, nowMs),
blocks: () =>
buildTimelineBlocks(
programs,
buildTimelineAxis(programs, nowMs),
nowMs
),
nowMs: () => nowMs,
viewDayKey: () => 'today',
commitDay: () => undefined,
});
scrollSpy = jest
.spyOn(controller, 'scrollToOffset')
.mockImplementation(() => undefined);
});
function focus(
scroller: HTMLElement | undefined,
next: EpgProgram[] = programs
): void {
programs = next; // keep axis/blocks accessors in sync with the arg
controller.maybeAutoFocus(scroller, next);
}
it('centres the current programme instantly (no scroll animation)', () => {
focus({} as HTMLElement, [programAt(0, 120, 'Now')]);
expect(scrollSpy).toHaveBeenCalledTimes(1);
const [, frac, smooth] = scrollSpy.mock.calls[0];
expect(frac).toBe(0.5); // centred in the viewport
expect(smooth).toBe(false); // instant, no animation
});
it('does not re-focus while the same channel stays loaded', () => {
const p = [programAt(0, 120, 'Now')];
const scroller = {} as HTMLElement;
focus(scroller, p);
focus(scroller, p); // host re-emits same data / now-tick
expect(scrollSpy).toHaveBeenCalledTimes(1);
});
it('does not re-focus the same channel when its ribbon remounts (no snap-back)', () => {
// Unmount + remount of the SAME channel — e.g. the user navigates from
// an empty day to a day with programmes — must not re-focus, or it
// would commit today again and snap the view back to the empty day.
const p = [programAt(0, 120, 'Now')];
const scroller = {} as HTMLElement;
focus(scroller, p); // focus #1
focus(undefined, p); // ribbon unmounts (empty-day navigation)
focus(scroller, p); // remount on another day → must NOT re-focus
expect(scrollSpy).toHaveBeenCalledTimes(1);
});
it('re-focuses when the channel changes', () => {
const scroller = {} as HTMLElement;
focus(scroller, [{ ...programAt(0, 120, 'A'), channel: 'a' }]);
focus(scroller, [{ ...programAt(0, 120, 'B'), channel: 'b' }]);
expect(scrollSpy).toHaveBeenCalledTimes(2);
});
it('does not focus or snap to today when today has no programmes', () => {
// Programmes only three days out → today is empty; auto-focus must
// leave the user's day navigation alone instead of forcing today.
focus({} as HTMLElement, [programAt(3 * 1440, 60)]);
expect(scrollSpy).not.toHaveBeenCalled();
});
it('skips focus entirely when the ribbon is not mounted', () => {
focus(undefined, [programAt(0, 120, 'Now')]);
expect(scrollSpy).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,128 @@
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { getTodayEpgDateKey, parseEpgDateKey } from '../epg-date';
import {
dayKeyAtOffset,
hasProgramsForDateKey,
TIMELINE_MINUTE_MS,
TimelineAxis,
TimelineBlock,
} from './epg-timeline.utils';
/** Accessors the controller reads from the host timeline component's signals. */
export interface TimelineScrollContext {
readonly ribbon: () => HTMLElement | undefined;
readonly scale: () => number;
readonly axis: () => TimelineAxis;
readonly blocks: () => readonly TimelineBlock[];
readonly nowMs: () => number;
readonly viewDayKey: () => string;
readonly commitDay: (dayKey: string) => void;
}
/** Stable identity of a channel's programme set (changes when the channel does). */
export function programsFocusKey(programs: readonly EpgProgram[]): string {
if (programs.length === 0) {
return '';
}
const first = programs[0];
const last = programs[programs.length - 1];
return `${first.channel ?? ''}|${programs.length}|${first.start}|${last.stop}`;
}
/**
* Owns the ribbon's horizontal scrolling and the channel-select auto-focus,
* keeping that stateful logic out of the presentation component. Instantiated
* with accessors to the component's signals.
*/
export class TimelineScrollController {
private scrollFrame = 0;
private lastFocusKey: string | null = null;
constructor(private readonly ctx: TimelineScrollContext) {}
/** Centre the current programme (or "now" if none airing) in the viewport. */
focusCurrentProgram(smooth: boolean): void {
const axis = this.ctx.axis();
const current = this.ctx.blocks().find((block) => block.when === 'now');
const targetMs = current
? (current.startMs + current.stopMs) / 2
: this.ctx.nowMs();
const offsetMin = (targetMs - axis.startMs) / TIMELINE_MINUTE_MS;
this.scrollToOffset(offsetMin, 0.5, smooth);
}
scrollToDateKey(dateKey: string, frac: number): void {
const axis = this.ctx.axis();
const noonMs =
parseEpgDateKey(dateKey).getTime() + 12 * 60 * TIMELINE_MINUTE_MS;
const offsetMin = (noonMs - axis.startMs) / TIMELINE_MINUTE_MS;
this.scrollToOffset(offsetMin, frac);
}
scrollToOffset(offsetMin: number, frac: number, smooth = true): void {
requestAnimationFrame(() => {
const scroller = this.ctx.ribbon();
if (!scroller) {
return;
}
const left =
offsetMin * this.ctx.scale() - scroller.clientWidth * frac;
scroller.scrollTo({
left: Math.max(0, left),
behavior: smooth ? 'smooth' : 'auto',
});
});
}
/** Update the centred day as the ribbon scrolls (rAF-throttled). */
onRibbonScroll(): void {
if (this.scrollFrame) {
return;
}
this.scrollFrame = requestAnimationFrame(() => {
this.scrollFrame = 0;
const scroller = this.ctx.ribbon();
if (!scroller) {
return;
}
const centerOffsetMin =
(scroller.scrollLeft + scroller.clientWidth / 2) /
this.ctx.scale();
const dayKey = dayKeyAtOffset(this.ctx.axis(), centerOffsetMin);
if (dayKey && dayKey !== this.ctx.viewDayKey()) {
this.ctx.commitDay(dayKey);
}
});
}
/**
* Centre the current programme when a channel's EPG (re)loads or the ribbon
* (re)mounts. Deduped by programme-set identity so the 30s "now" tick, zoom
* changes, or a host re-emitting the same data never re-jump the viewport.
*/
maybeAutoFocus(
scroller: HTMLElement | undefined,
programs: readonly EpgProgram[]
): void {
const key = programsFocusKey(programs);
// Skip (without clearing lastFocusKey) when there's no ribbon yet, no
// programmes, or we've already focused this channel. Re-running for the
// same channel — e.g. when the ribbon remounts after the user navigates
// to another day — must NOT re-focus, or `commitDay(today)` below would
// snap the view back to today and trap the user on an empty day.
if (!scroller || !key || key === this.lastFocusKey) {
return;
}
const todayKey = getTodayEpgDateKey();
// Only auto-focus when today actually has a programme to centre on.
// Otherwise (today empty, data on other days) leave the user's day
// navigation alone instead of forcing the view back to an empty today.
if (!hasProgramsForDateKey(programs, todayKey)) {
return;
}
this.lastFocusKey = key;
this.ctx.commitDay(todayKey);
// Instant: land already centred, no annoying scroll animation.
this.focusCurrentProgram(false);
}
}
@@ -3,7 +3,7 @@ import { MatDialog } from '@angular/material/dialog';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, of } from 'rxjs';
import { EpgTimelineComponent, programsFocusKey } from './epg-timeline.component';
import { EpgTimelineComponent } from './epg-timeline.component';
import { TimelineBlock } from './epg-timeline.utils';
function programAt(
@@ -211,122 +211,4 @@ describe('EpgTimelineComponent', () => {
});
expect(component.scale()).toBeGreaterThan(3);
});
describe('programsFocusKey (channel-change auto-focus identity)', () => {
function programFor(channel: string): EpgProgram {
return { ...programAt(0, 60), channel };
}
it('is empty for an empty programme set so no focus is attempted', () => {
expect(programsFocusKey([])).toBe('');
});
it('is stable for the same programme set (no re-jump on re-render)', () => {
const programs = [programFor('a'), programFor('a')];
expect(programsFocusKey(programs)).toBe(
programsFocusKey([...programs])
);
});
it('differs when the channel changes (re-focus on channel switch)', () => {
const a = programsFocusKey([programFor('alpha')]);
const b = programsFocusKey([programFor('beta')]);
expect(a).not.toBe(b);
});
});
describe('maybeAutoFocus (channel-select centring)', () => {
function focus(
scroller: HTMLElement | undefined,
programs = component.programs()
): void {
(component as unknown as {
maybeAutoFocus(s: HTMLElement | undefined, p: unknown[]): void;
}).maybeAutoFocus(scroller, programs as unknown[]);
}
it('centres the current programme instantly (no scroll animation)', () => {
setInputs({ programs: [programAt(0, 120, 'Now')] });
const spy = jest.spyOn(
component as unknown as { scrollToOffset: (...a: unknown[]) => void },
'scrollToOffset'
).mockImplementation(() => undefined);
focus({} as HTMLElement);
expect(spy).toHaveBeenCalledTimes(1);
const [, frac, smooth] = spy.mock.calls[0];
expect(frac).toBe(0.5); // centred in the viewport
expect(smooth).toBe(false); // instant, no animation
});
it('does not re-focus while the same channel stays loaded', () => {
setInputs({ programs: [programAt(0, 120, 'Now')] });
const spy = jest.spyOn(
component as unknown as { scrollToOffset: (...a: unknown[]) => void },
'scrollToOffset'
).mockImplementation(() => undefined);
const scroller = {} as HTMLElement;
focus(scroller);
focus(scroller); // host re-emits same data / now-tick
expect(spy).toHaveBeenCalledTimes(1);
});
it('does not re-focus the same channel when its ribbon remounts (no snap-back)', () => {
// Unmount + remount of the SAME channel — e.g. the user navigates from
// an empty day to a day with programmes — must not re-focus, or it
// would commit today again and snap the view back to the empty day.
setInputs({ programs: [programAt(0, 120, 'Now')] });
const spy = jest.spyOn(
component as unknown as { scrollToOffset: (...a: unknown[]) => void },
'scrollToOffset'
).mockImplementation(() => undefined);
const scroller = {} as HTMLElement;
const programs = component.programs();
focus(scroller, programs); // focus #1
focus(undefined, programs); // ribbon unmounts (empty-day navigation)
focus(scroller, programs); // remount on another day → must NOT re-focus
expect(spy).toHaveBeenCalledTimes(1);
});
it('re-focuses when the channel changes', () => {
const spy = jest.spyOn(
component as unknown as { scrollToOffset: (...a: unknown[]) => void },
'scrollToOffset'
).mockImplementation(() => undefined);
const scroller = {} as HTMLElement;
setInputs({ programs: [{ ...programAt(0, 120, 'A'), channel: 'a' }] });
focus(scroller);
setInputs({ programs: [{ ...programAt(0, 120, 'B'), channel: 'b' }] });
focus(scroller);
expect(spy).toHaveBeenCalledTimes(2);
});
it('does not focus or snap to today when today has no programmes', () => {
// Programmes only three days out → today is empty; auto-focus must
// leave the user's day navigation alone instead of forcing today.
setInputs({ programs: [programAt(3 * 1440, 60)] });
const spy = jest.spyOn(
component as unknown as { scrollToOffset: (...a: unknown[]) => void },
'scrollToOffset'
).mockImplementation(() => undefined);
focus({} as HTMLElement);
expect(spy).not.toHaveBeenCalled();
});
it('skips focus entirely when the ribbon is not mounted', () => {
setInputs({ programs: [programAt(0, 120, 'Now')] });
const spy = jest.spyOn(
component as unknown as { scrollToOffset: (...a: unknown[]) => void },
'scrollToOffset'
).mockImplementation(() => undefined);
focus(undefined);
expect(spy).not.toHaveBeenCalled();
});
});
});
@@ -13,7 +13,6 @@ import {
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { normalizeDateLocale } from '@iptvnator/pipes';
@@ -26,15 +25,26 @@ import {
parseEpgDateKey,
shiftEpgDateKey,
} from '../epg-date';
import {
EpgItemDialogAction,
EpgItemDescriptionComponent,
} from '../epg-list/epg-item-description/epg-item-description.component';
import { EpgItemDialogAction } from '../epg-list/epg-item-description/epg-item-description.component';
import type { EpgProgramActivationEvent } from '../epg-list/epg-list.component';
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
import {
EpgTimelineSummary,
formatClockTime,
summaryHasTimeRange,
summaryHasTitle,
summaryMinutesLeft,
summaryProgress,
} from './epg-summary.util';
import {
canCatchUpProgramme,
epgDialogActionFor,
} from './epg-archive.util';
import {
EpgTimelineEmptyReason,
EpgTimelineEmptyStateComponent,
} from './epg-timeline-empty-state.component';
import { TimelineScrollController } from './epg-timeline-scroll.controller';
import { EpgTimelineTrackComponent } from './epg-timeline-track.component';
import {
buildTimelineAxis,
@@ -42,7 +52,6 @@ import {
buildTimelineDayDividers,
buildTimelineRenderItems,
buildTimelineTicks,
dayKeyAtOffset,
hasProgramsForDateKey,
nearestDateKeyWithPrograms,
TIMELINE_DEFAULT_SCALE,
@@ -59,13 +68,7 @@ import {
type RenderState = 'loading' | 'ribbon' | EpgTimelineEmptyReason;
/** Collapsed-summary payload (structurally matches the legacy panel summary). */
export interface EpgTimelineSummary {
readonly title?: string | null;
readonly start?: string | number | Date | null;
readonly stop?: string | number | Date | null;
readonly progress?: number | null;
}
export type { EpgTimelineSummary } from './epg-summary.util';
@Component({
selector: 'app-epg-timeline',
@@ -104,7 +107,7 @@ export class EpgTimelineComponent {
readonly retry = output<void>();
readonly collapsedChange = output<boolean>();
private readonly dialog = inject(MatDialog);
private readonly programmeDialog = inject(EpgProgrammeDialogService);
private readonly translate = inject(TranslateService);
readonly ribbon = viewChild<ElementRef<HTMLElement>>('ribbon');
@@ -119,9 +122,17 @@ export class EpgTimelineComponent {
readonly selectedKey = signal<string | null>(null);
/** Day currently centred in the ribbon viewport. */
private readonly viewDayKey = signal(getTodayEpgDateKey());
private scrollFrame = 0;
/** Identity of the programme set we last auto-focused, to avoid re-jumps. */
private lastFocusKey: string | null = null;
/** Ribbon scrolling + channel-select auto-focus, extracted from the view. */
private readonly scroll = new TimelineScrollController({
ribbon: () => this.ribbon()?.nativeElement,
scale: () => this.scale(),
axis: () => this.axis(),
blocks: () => this.blocks(),
nowMs: () => this.nowMs(),
viewDayKey: () => this.viewDayKey(),
commitDay: (dayKey) => this.commitDay(dayKey),
});
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -172,10 +183,7 @@ export class EpgTimelineComponent {
if (scale < 3) return 'EPG.TIMELINE.ZOOM_HOURS';
return 'EPG.TIMELINE.ZOOM_DETAIL';
});
readonly nowLabel = computed(() => {
const date = new Date(this.nowMs());
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
});
readonly nowLabel = computed(() => formatClockTime(this.nowMs()));
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
readonly isViewToday = computed(
@@ -225,37 +233,16 @@ export class EpgTimelineComponent {
});
// ── collapsed-summary state ──
readonly hasSummary = computed(() => {
const title = this.summary()?.title;
return typeof title === 'string' && title.trim().length > 0;
});
readonly hasTimeRange = computed(() => {
const summary = this.summary();
return !!summary?.start || !!summary?.stop;
});
readonly progress = computed(() => {
const summary = this.summary();
if (!summary) {
return null;
}
const explicit = Number(summary.progress);
if (Number.isFinite(explicit)) {
return clampPercent(explicit);
}
const startMs = toTimeMs(summary.start);
const stopMs = toTimeMs(summary.stop);
if (startMs === null || stopMs === null || stopMs <= startMs) {
return null;
}
return clampPercent(((this.nowMs() - startMs) / (stopMs - startMs)) * 100);
});
readonly minutesLeft = computed(() => {
const stopMs = toTimeMs(this.summary()?.stop);
if (stopMs === null) {
return null;
}
return Math.max(0, Math.round((stopMs - this.nowMs()) / 60_000));
});
readonly hasSummary = computed(() => summaryHasTitle(this.summary()));
readonly hasTimeRange = computed(() =>
summaryHasTimeRange(this.summary())
);
readonly progress = computed(() =>
summaryProgress(this.summary(), this.nowMs())
);
readonly minutesLeft = computed(() =>
summaryMinutesLeft(this.summary(), this.nowMs())
);
constructor() {
effect((onCleanup) => {
@@ -274,41 +261,10 @@ export class EpgTimelineComponent {
effect(() => {
const scroller = this.ribbon()?.nativeElement;
const programs = this.programs();
untracked(() => this.maybeAutoFocus(scroller, programs));
untracked(() => this.scroll.maybeAutoFocus(scroller, programs));
});
}
/**
* Centre the current programme when a channel's EPG (re)loads or the ribbon
* (re)mounts. Deduped by programme-set identity so the 30s "now" tick, zoom
* changes, or a host re-emitting the same data never re-jump the viewport.
*/
private maybeAutoFocus(
scroller: HTMLElement | undefined,
programs: readonly EpgProgram[]
): void {
const key = programsFocusKey(programs);
// Skip (without clearing lastFocusKey) when there's no ribbon yet, no
// programmes, or we've already focused this channel. Re-running for the
// same channel — e.g. when the ribbon remounts after the user navigates
// to another day — must NOT re-focus, or `commitDay(today)` below would
// snap the view back to today and trap the user on an empty day.
if (!scroller || !key || key === this.lastFocusKey) {
return;
}
const todayKey = getTodayEpgDateKey();
// Only auto-focus when today actually has a programme to centre on.
// Otherwise (today empty, data on other days) leave the user's day
// navigation alone instead of forcing the view back to an empty today.
if (!hasProgramsForDateKey(programs, todayKey)) {
return;
}
this.lastFocusKey = key;
this.commitDay(todayKey);
// Instant: land already centred, no annoying scroll animation.
this.focusCurrentProgram(false);
}
toggleCollapsed(): void {
this.collapsedChange.emit(!this.collapsed());
}
@@ -338,14 +294,16 @@ export class EpgTimelineComponent {
const axis = this.axis();
const centreMs = (group.startMs + group.stopMs) / 2;
const offsetMin = (centreMs - axis.startMs) / TIMELINE_MINUTE_MS;
this.scrollToOffset(offsetMin, 0.5);
this.scroll.scrollToOffset(offsetMin, 0.5);
}
canCatchUp(block: TimelineBlock): boolean {
return (
this.archivePlaybackAvailable() &&
block.when === 'past' &&
this.isWithinArchiveWindow(block)
return canCatchUpProgramme(
block.when,
block.startMs,
this.archivePlaybackAvailable(),
this.archiveDays(),
this.nowMs()
);
}
@@ -372,22 +330,15 @@ export class EpgTimelineComponent {
}
openDetails(block: TimelineBlock): void {
const primaryAction = this.dialogActionFor(block);
const archiveUnavailableNote =
block.when === 'past' && !this.archivePlaybackAvailable();
this.dialog
.open(EpgItemDescriptionComponent, {
width: '540px',
data: {
...block.program,
channelName: this.channelName(),
channelLogo: this.channelLogo(),
primaryAction,
archiveUnavailableNote,
},
this.programmeDialog
.open({
...block.program,
channelName: this.channelName(),
channelLogo: this.channelLogo(),
primaryAction: this.dialogActionFor(block),
archiveUnavailableNote:
block.when === 'past' && !this.archivePlaybackAvailable(),
})
.afterClosed()
.subscribe((result: EpgItemDialogAction | undefined) => {
if (result === 'live') {
this.returnToLive.emit();
@@ -404,13 +355,13 @@ export class EpgTimelineComponent {
stepDay(direction: EpgDateNavigationDirection): void {
const nextKey = shiftEpgDateKey(this.viewDayKey(), direction);
this.commitDay(nextKey);
this.scrollToDateKey(nextKey, 0.5);
this.scroll.scrollToDateKey(nextKey, 0.5);
}
jumpToNow(): void {
this.commitDay(getTodayEpgDateKey());
// Deliberate user action → animate so the movement reads.
this.focusCurrentProgram(true);
this.scroll.focusCurrentProgram(true);
}
jumpToNearestDay(): void {
@@ -420,27 +371,12 @@ export class EpgTimelineComponent {
);
if (nearest) {
this.commitDay(nearest);
this.scrollToDateKey(nearest, 0.5);
this.scroll.scrollToDateKey(nearest, 0.5);
}
}
onRibbonScroll(): void {
if (this.scrollFrame) {
return;
}
this.scrollFrame = requestAnimationFrame(() => {
this.scrollFrame = 0;
const scroller = this.ribbon()?.nativeElement;
if (!scroller) {
return;
}
const centerOffsetMin =
(scroller.scrollLeft + scroller.clientWidth / 2) / this.scale();
const dayKey = dayKeyAtOffset(this.axis(), centerOffsetMin);
if (dayKey && dayKey !== this.viewDayKey()) {
this.commitDay(dayKey);
}
});
this.scroll.onRibbonScroll();
}
private commitDay(dayKey: string): void {
@@ -452,86 +388,6 @@ export class EpgTimelineComponent {
}
private dialogActionFor(block: TimelineBlock): EpgItemDialogAction | null {
if (block.when === 'now') {
return 'live';
}
if (this.canCatchUp(block)) {
return 'timeshift';
}
return null;
}
private isWithinArchiveWindow(block: TimelineBlock): boolean {
const days = this.archiveDays();
if (days <= 0) {
return true; // capability flagged but no explicit window
}
const limitMs = this.nowMs() - days * 24 * 60 * TIMELINE_MINUTE_MS;
return block.startMs >= limitMs;
}
/** Centre the current programme (or "now" if none airing) in the viewport. */
private focusCurrentProgram(smooth: boolean): void {
const axis = this.axis();
const current = this.blocks().find((block) => block.when === 'now');
const targetMs = current
? (current.startMs + current.stopMs) / 2
: this.nowMs();
const offsetMin = (targetMs - axis.startMs) / TIMELINE_MINUTE_MS;
this.scrollToOffset(offsetMin, 0.5, smooth);
}
private scrollToDateKey(dateKey: string, frac: number): void {
const axis = this.axis();
const noonMs = parseEpgDateKey(dateKey).getTime() + 12 * 60 * TIMELINE_MINUTE_MS;
const offsetMin = (noonMs - axis.startMs) / TIMELINE_MINUTE_MS;
this.scrollToOffset(offsetMin, frac);
}
private scrollToOffset(offsetMin: number, frac: number, smooth = true): void {
requestAnimationFrame(() => {
const scroller = this.ribbon()?.nativeElement;
if (!scroller) {
return;
}
const left = offsetMin * this.scale() - scroller.clientWidth * frac;
scroller.scrollTo({
left: Math.max(0, left),
behavior: smooth ? 'smooth' : 'auto',
});
});
return epgDialogActionFor(block.when, this.canCatchUp(block));
}
}
/** Stable identity of a channel's programme set (changes when the channel does). */
export function programsFocusKey(programs: readonly EpgProgram[]): string {
if (programs.length === 0) {
return '';
}
const first = programs[0];
const last = programs[programs.length - 1];
return `${first.channel ?? ''}|${programs.length}|${first.start}|${last.stop}`;
}
function pad(value: number): string {
return String(value).padStart(2, '0');
}
function clampPercent(value: number): number {
return Math.min(100, Math.max(0, value));
}
function toTimeMs(
value: string | number | Date | null | undefined
): number | null {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed =
value instanceof Date
? value.getTime()
: typeof value === 'number'
? value
: Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
@@ -222,189 +222,6 @@ export function nearestDateKeyWithPrograms(
return bestKey;
}
// ───────────────────────── short-programme strategy ─────────────────────────
// A proportional ribbon makes a 5-minute programme a 2px sliver. The fixes:
// A — a minimum block width so every programme stays clickable;
// B — content tiers chosen by rendered width (wide → micro);
// E — grouping runs of ≥4 consecutive short programmes when zoomed out.
// (C — the hover popover — and D — the zoom slider — live in the components.)
/** Smallest rendered block width; a sliver gets bumped up to this. */
export const TIMELINE_MIN_BLOCK_WIDTH_PX = 40;
/** Gap subtracted from each block so neighbours don't touch. */
export const TIMELINE_BLOCK_GAP_PX = 4;
/** Zoom (px/min) slider bounds. */
export const TIMELINE_ZOOM_MIN = 0.8;
export const TIMELINE_ZOOM_MAX = 6;
export const TIMELINE_ZOOM_STEP = 0.1;
/** Below this zoom, dense runs of short programmes collapse into a group chip. */
export const TIMELINE_GROUP_ZOOM_MAX = 1.3;
/** Zoom applied when a group chip is expanded. */
export const TIMELINE_GROUP_EXPAND_ZOOM = 3.4;
/** A programme is "short" below this duration (minutes). */
export const TIMELINE_SHORT_MAX_MIN = 10;
/** Minimum consecutive shorts before they group. */
export const TIMELINE_GROUP_MIN_RUN = 4;
export type TimelineTier = 'wide' | 'med' | 'narrow' | 'micro';
export interface TimelineRenderBlock {
readonly kind: 'block';
readonly key: string;
readonly block: TimelineBlock;
readonly leftPx: number;
readonly widthPx: number;
readonly tier: TimelineTier;
/** Live progress 0–100 for the on-now block, else 0. */
readonly nowFillPercent: number;
/** Past programme playable from the catch-up archive. */
readonly canCatchUp: boolean;
}
export interface TimelineRenderGroup {
readonly kind: 'group';
readonly key: string;
readonly leftPx: number;
readonly widthPx: number;
readonly count: number;
readonly startMs: number;
readonly stopMs: number;
}
export type TimelineRenderItem = TimelineRenderBlock | TimelineRenderGroup;
export interface TimelineRenderOptions {
readonly minWidthPx?: number;
readonly gapPx?: number;
readonly allowGroup?: boolean;
readonly nowMs?: number;
readonly archivePlaybackAvailable?: boolean;
/** Earliest start playable from the archive; -Infinity = whole past. */
readonly archiveWindowStartMs?: number;
}
/** Content tier for a block of the given rendered width. */
export function tierFor(widthPx: number): TimelineTier {
if (widthPx >= 132) return 'wide';
if (widthPx >= 70) return 'med';
if (widthPx >= 30) return 'narrow';
return 'micro';
}
/** Adaptive tick spacing (minutes) — denser as the user zooms in. */
export function timelineTickStepForScale(scale: number): number {
if (scale < 2.2) return 120;
if (scale < 3.5) return 60;
if (scale < 5) return 30;
return 15;
}
function nowFillFor(block: TimelineBlock, nowMs: number): number {
if (block.when !== 'now') {
return 0;
}
const total = block.stopMs - block.startMs;
if (total <= 0) {
return 0;
}
const pct = ((nowMs - block.startMs) / total) * 100;
return Math.min(100, Math.max(0, pct));
}
function toRenderBlock(
block: TimelineBlock,
scale: number,
minWidthPx: number,
gapPx: number,
nowMs: number,
archivePlaybackAvailable: boolean,
archiveWindowStartMs: number
): TimelineRenderBlock {
const rawWidth = Math.max(minWidthPx, block.durationMin * scale);
return {
kind: 'block',
key: block.key,
block,
leftPx: block.offsetMin * scale,
widthPx: Math.max(minWidthPx - gapPx, rawWidth - gapPx),
tier: tierFor(rawWidth),
nowFillPercent: nowFillFor(block, nowMs),
canCatchUp:
archivePlaybackAvailable &&
block.when === 'past' &&
block.startMs >= archiveWindowStartMs,
};
}
/**
* Turn timeline blocks into positioned render items, applying the minimum-width
* floor (A), per-width content tiers (B) and short-run grouping (E).
*/
export function buildTimelineRenderItems(
blocks: readonly TimelineBlock[],
scale: number,
options: TimelineRenderOptions = {}
): TimelineRenderItem[] {
const minWidthPx = options.minWidthPx ?? TIMELINE_MIN_BLOCK_WIDTH_PX;
const gapPx = options.gapPx ?? TIMELINE_BLOCK_GAP_PX;
const allowGroup = options.allowGroup ?? false;
const nowMs = options.nowMs ?? Date.now();
const archiveAvailable = options.archivePlaybackAvailable ?? false;
const archiveWindowStartMs =
options.archiveWindowStartMs ?? Number.NEGATIVE_INFINITY;
const items: TimelineRenderItem[] = [];
let run: TimelineBlock[] = [];
const pushBlock = (block: TimelineBlock) =>
items.push(
toRenderBlock(
block,
scale,
minWidthPx,
gapPx,
nowMs,
archiveAvailable,
archiveWindowStartMs
)
);
const flushRun = () => {
if (run.length >= TIMELINE_GROUP_MIN_RUN) {
const first = run[0];
const last = run[run.length - 1];
const spanPx = ((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
items.push({
kind: 'group',
key: `group-${first.key}-${last.key}`,
leftPx: first.offsetMin * scale,
widthPx: Math.max(minWidthPx - gapPx, spanPx - gapPx),
count: run.length,
startMs: first.startMs,
stopMs: last.stopMs,
});
} else {
run.forEach(pushBlock);
}
run = [];
};
for (const block of blocks) {
// Never fold the on-air programme into a "N short" group chip — that
// drops its `when: 'now'` highlight and leaves the auto-focused viewport
// centred on a chip with no visible "now" state. Keep it standalone.
if (
allowGroup &&
block.durationMin < TIMELINE_SHORT_MAX_MIN &&
block.when !== 'now'
) {
run.push(block);
continue;
}
flushRun();
pushBlock(block);
}
flushRun();
return items;
}
// Short-programme render strategy (tiers, grouping, zoom bounds) lives in a
// sibling module; re-exported here so existing imports keep working.
export * from './epg-timeline-render.util';