Files
iptvnator/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts
T
6c8f5028c7 perf(epg): render only the timeline programmes near the visible range (J3) (#1817)
* perf(epg): render only the timeline programmes near the visible range (J3)

Selecting a live channel rendered every programme block of its schedule
(about 240 for the Xtream mock) while the stream was starting: about 6,000
of J3's 6,199 renderer.domMutationsToPlaying. The ribbon now renders the
blocks, ticks and day dividers within half a viewport of the visible range;
the track keeps the full schedule's width, so positions, the scrollbar and
scroll-to-now are unchanged.

A resize reported before the scroll-to-now must not re-centre the window
(it once jumped to the schedule's start and back); resizes only widen it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): sweep the EPG ribbon to see every programme

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): estimate the ribbon window once per channel or mount

The live estimate followed the centred day and the 30 s now tick while the
ribbon was not yet scrolled: a small scroll across midnight re-centred the
window on the next day's noon and left the visible range empty, and the
host width was re-read (a forced layout) on every tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): keyboard Tab reaches programmes beyond the rendered EPG range

Greptile flagged that windowing the ribbon could strand keyboard users at
the last rendered block. Focusing a block scrolls it into view, which
re-windows before the next key press; the new test walks ten unrendered
programmes past the range with Tab and with Shift+Tab, and fails if focus
ever leaves the ribbon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): re-centre the ribbon window in the same pass as a zoom

Review follow-up (Codex): a zoom (button, Ctrl/⌘ wheel, coalesced wheel
burst) or a group expansion changed the scale before the anchored
scrollLeft landed on a later frame, so the window was the previous
centre at the new scale until the next scroll event re-measured it. The
ribbon could flash empty or show the wrong section. The zoom controller
now hands the window the minute its anchored scroll will centre, and a
group expansion the group's centre, together with the new scale
(TimelineWindowController.centreOnMinute).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): window the scroll position a zoom can actually reach

Review follow-up (Greptile, Codex): a zoom-out anchored right of centre
at the ribbon's start computed a negative scroll position. The window
centred on it, the browser kept scrollLeft at 0, and with the position
unchanged no scroll event re-windowed, so the visible right-hand part
stayed empty. The centre now uses the position clamped at 0, and once
the frame applies scrollLeft the window re-centres on what the browser
kept, which also covers the clamp at the end of the track.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): close the programme popover when the window drops its block

Review follow-up (Codex): the ribbon window can remove a focused narrow
block on scroll, and a removed node fires no focusout, so the fixed
tooltip kept showing a programme no longer on screen. The popover is now
a linkedSignal over the rendered items that keeps its state only while
its block (by key) is still rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): re-measure the ribbon window when the axis origin moves

Review follow-up (Codex): a time offset that carries the first programme
across midnight moves the axis origin and every track position while
scrollLeft and the ribbon stay put, so no scroll event fires and the
window kept its epoch-time centre at a different pixel position. The
window identity now includes the axis start; when only that changes,
the viewport is measured from the ribbon instead of re-estimated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:07:25 +02:00

155 lines
5.1 KiB
TypeScript

import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
HostBinding,
input,
linkedSignal,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslatePipe } from '@ngx-translate/core';
import { areProgramsSame } from '../epg-program.utils';
import {
TimelineBlock,
TimelineDayDivider,
TimelineRenderBlock,
TimelineRenderGroup,
TimelineRenderItem,
TimelineTick,
} from './epg-timeline.utils';
interface PopoverState {
/** The render item it describes. */
readonly key: string;
readonly title: string;
readonly desc: string | null;
readonly startMs: number;
readonly stopMs: number;
readonly durationMin: number;
readonly left: number;
readonly top: number;
}
/**
* The scrolling ribbon canvas: ticks, day dividers, programme blocks (with
* width-adaptive content tiers), short-run group chips, the live playhead, and
* the hover/focus popover that reveals truncated programme names.
*/
@Component({
selector: 'app-epg-timeline-track',
templateUrl: './epg-timeline-track.component.html',
styleUrl: './epg-timeline-track.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DatePipe, MatIcon, TranslatePipe],
})
export class EpgTimelineTrackComponent {
readonly items = input<TimelineRenderItem[]>([]);
readonly ticks = input<TimelineTick[]>([]);
readonly dividers = input<TimelineDayDivider[]>([]);
readonly scale = input(1);
readonly trackWidthPx = input(0);
readonly playheadLeftPx = input(0);
readonly nowLabel = input('');
readonly selectedKey = input<string | null>(null);
readonly activeProgram = input<EpgProgram | null>(null);
readonly currentLocale = input('en');
readonly blockClick = output<TimelineBlock>();
readonly watchClick = output<TimelineBlock>();
readonly infoClick = output<TimelineBlock>();
readonly groupExpand = output<TimelineRenderGroup>();
/**
* Cleared when its block leaves the rendered items: the ribbon window can
* remove a focused block on scroll, and a removed node fires no
* `focusout`, so the tooltip would stay on a programme no longer shown.
*/
readonly popover = linkedSignal<TimelineRenderItem[], PopoverState | null>({
source: this.items,
computation: (items, previous) => {
const shown = previous?.value;
return shown && items.some((item) => item.key === shown.key)
? shown
: null;
},
});
@HostBinding('style.width.px') get hostWidth(): number {
return this.trackWidthPx();
}
readonly activeKey = computed(() => {
const active = this.activeProgram();
if (!active) {
return null;
}
const match = this.items().find(
(item): item is TimelineRenderBlock =>
item.kind === 'block' &&
areProgramsSame(item.block.program, active)
);
return match?.key ?? null;
});
isSelected(item: TimelineRenderBlock): boolean {
return this.selectedKey() === item.block.key;
}
isPlaying(item: TimelineRenderBlock): boolean {
return this.activeKey() === item.key;
}
/**
* Emit block activation for keyboard Enter only when the block itself is
* focused. Enter on a nested watch/info button bubbles here too — ignore it
* so keyboard users get the button's own action, matching mouse clicks
* (which `stopPropagation()` on those buttons).
*/
onBlockActivate(event: Event, block: TimelineBlock): void {
if (event.target === event.currentTarget) {
this.blockClick.emit(block);
}
}
onBlockEnter(item: TimelineRenderBlock, event: Event): void {
if (item.tier === 'wide') {
this.popover.set(null);
return;
}
const target = event.currentTarget as HTMLElement | null;
if (!target) {
return;
}
const rect = target.getBoundingClientRect();
const width = 248;
const estHeight = 132;
// The EPG panel sits at the bottom of the screen, so flip the popover
// above the block when there isn't room for it below.
const below = rect.bottom + 8;
const flipAbove = below + estHeight > window.innerHeight;
this.popover.set({
key: item.key,
title: item.block.program.title,
desc: item.block.program.desc,
startMs: item.block.startMs,
stopMs: item.block.stopMs,
durationMin: Math.round(item.block.durationMin),
left: Math.max(
12,
Math.min(
window.innerWidth - width - 12,
rect.left + rect.width / 2 - width / 2
)
),
top: flipAbove ? Math.max(12, rect.top - estHeight - 8) : below,
});
}
onBlockLeave(): void {
this.popover.set(null);
}
}