diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 0000d7b66..be56e4055 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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`, diff --git a/libs/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts index fe429c0e6..41ef73a48 100644 --- a/libs/ui/epg/src/index.ts +++ b/libs/ui/epg/src/index.ts @@ -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'; diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts index 1c61d74a6..a4dfb2191 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts @@ -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; diff --git a/libs/ui/epg/src/lib/epg-programme-dialog.service.ts b/libs/ui/epg/src/lib/epg-programme-dialog.service.ts new file mode 100644 index 000000000..14deeda08 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-programme-dialog.service.ts @@ -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 { + return this.dialog + .open(EpgItemDescriptionComponent, { width: '540px', data }) + .afterClosed(); + } +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts new file mode 100644 index 000000000..17f0ce75b --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts @@ -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; +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.ts new file mode 100644 index 000000000..cfbed9b86 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.ts @@ -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())}`; +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts new file mode 100644 index 000000000..1c730a98a --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts @@ -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; +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts new file mode 100644 index 000000000..b09d9d6bb --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts @@ -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(); + }); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts new file mode 100644 index 000000000..5488f59a7 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts @@ -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); + } +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts index ab0497ead..087def864 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts @@ -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(); - }); - }); }); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts index 9fd3e70ca..9bfef738f 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts @@ -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(); readonly collapsedChange = output(); - private readonly dialog = inject(MatDialog); + private readonly programmeDialog = inject(EpgProgrammeDialogService); private readonly translate = inject(TranslateService); readonly ribbon = viewChild>('ribbon'); @@ -119,9 +122,17 @@ export class EpgTimelineComponent { readonly selectedKey = signal(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; -} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts index 354e59fad..0b7411e36 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts @@ -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';