mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
* 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>
155 lines
5.1 KiB
TypeScript
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);
|
|
}
|
|
}
|