mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
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:
1 parent
a4251d881c
commit
f8931f0ded
12 files changed
+705
-512
No files matched your search
@@ -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`,
|
||||
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user