fix(epg): scroll the programme guide to now on open and on Now/N

The guide opened at midnight and the Now button and N key never moved the
timeline horizontally:

- scrollToNow issued a horizontal smooth scroll followed by the CDK's
  vertical scrollToIndex; in Chromium the second smooth scroll cancels the
  first one's animation. Both axes now go in one scrollTo call.
- The initial jump ran in afterNextRender before the virtual viewport had
  rendered any rows, so the scroll width was the client width and
  scrollLeft was clamped to 0. It now waits for the first rendered range.

Closes #1733

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-01 06:44:34 +02:00
1 parent 5d50995c30
commit 7a6b3d9dc8
7 files changed
+210 -39

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: epg
issues: [1733]
---
The multi-channel programme guide now opens at the current time, and the Now
button and the N key scroll the timeline back to it instead of leaving it at
midnight.
@@ -1,3 +1,4 @@
import { Page } from '@playwright/test';
import {
buildM3uContent,
channelItemByTitle,
@@ -40,6 +41,39 @@ function xmltvWithCurrentProgramme(
`;
}
/** True when the now-line is painted inside the visible programme lane. */
function nowLineInLane(page: Page): Promise<boolean> {
return page.evaluate(() => {
const lane = document
.querySelector('.epg-guide__now-clip')
?.getBoundingClientRect();
const line = document
.querySelector('.epg-guide__now-line')
?.getBoundingClientRect();
return (
!!lane &&
!!line &&
line.left >= lane.left &&
line.right <= lane.right
);
});
}
/** Scroll the lane to whichever end of the day is farther from now. */
async function scrollAwayFromNow(page: Page): Promise<void> {
await page.evaluate(() => {
const viewport = document.querySelector(
'.epg-guide__viewport'
) as HTMLElement;
const badge = document.querySelector(
'.epg-guide__now-badge'
) as HTMLElement;
const nowLeft = parseFloat(badge.style.left);
const end = viewport.scrollWidth - viewport.clientWidth;
viewport.scrollTo({ left: nowLeft > end / 2 ? 0 : end });
});
}
test('@epg @electron opens the programme guide with the playlist channels, switches channels and keeps the player mounted', async ({
dataDir,
}) => {
@@ -133,6 +167,23 @@ test('@epg @electron opens the programme guide with the playlist channels, switc
timeout: 20000,
});
// The guide opens on "now", and the Now button and N jump back to it
// on both axes at once (#1733: the lane stayed at midnight).
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true);
await scrollAwayFromNow(app.mainWindow);
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(false);
await guide.locator('.guide-toolbar__now').click();
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true);
await scrollAwayFromNow(app.mainWindow);
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(false);
// Keys are left alone while a toolbar button holds the focus.
await app.mainWindow.evaluate(() =>
(document.activeElement as HTMLElement | null)?.blur()
);
await app.mainWindow.keyboard.press('n');
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true);
// "Only with EPG" hides the silent channel once coverage is known.
const toggle = guide.locator('.guide-toolbar__toggle input');
await expect(toggle).toBeEnabled({ timeout: 20000 });
@@ -2,7 +2,7 @@ import {
guideBlockRevealScrollLeft,
guideNowScrollLeft,
guideRowNeedsReveal,
scrollElementLeft,
scrollElementTo,
} from './epg-guide-scroll.util';
function block(leftPx: number, widthPx: number) {
@@ -73,25 +73,44 @@ describe('guideBlockRevealScrollLeft', () => {
});
});
describe('scrollElementLeft', () => {
describe('scrollElementTo', () => {
it('uses scrollTo when the element implements it', () => {
const scrollTo = jest.fn();
const element = { scrollTo, scrollLeft: 0 } as unknown as HTMLElement;
scrollElementLeft(element, 120, true);
scrollElementTo(element, { left: 120 }, true);
expect(scrollTo).toHaveBeenCalledWith({
left: 120,
behavior: 'smooth',
});
scrollElementLeft(element, 10, false);
scrollElementTo(element, { left: 10 }, false);
expect(scrollTo).toHaveBeenLastCalledWith({
left: 10,
behavior: 'auto',
});
});
it('falls back to assigning scrollLeft (jsdom has no scrollTo)', () => {
const element = { scrollLeft: 0 } as unknown as HTMLElement;
scrollElementLeft(element, 42, true);
it('scrolls both axes in a single call so neither animation cancels the other', () => {
const scrollTo = jest.fn();
const element = { scrollTo } as unknown as HTMLElement;
scrollElementTo(element, { left: 640, top: 180 }, true);
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(scrollTo).toHaveBeenCalledWith({
left: 640,
top: 180,
behavior: 'smooth',
});
});
it('falls back to assigning the offsets (jsdom has no scrollTo)', () => {
const element = {
scrollLeft: 0,
scrollTop: 5,
} as unknown as HTMLElement;
scrollElementTo(element, { left: 42 }, true);
expect(element.scrollLeft).toBe(42);
expect(element.scrollTop).toBe(5);
scrollElementTo(element, { left: 7, top: 90 }, false);
expect(element.scrollLeft).toBe(7);
expect(element.scrollTop).toBe(90);
});
});
@@ -48,18 +48,34 @@ export function guideBlockRevealScrollLeft(
return Math.max(0, block.leftPx - REVEAL_PADDING_PX);
}
/** A scroll target; an omitted `top` leaves the vertical offset alone. */
export interface GuideScrollTarget {
readonly left: number;
readonly top?: number;
}
/**
* `Element.scrollTo` is not implemented everywhere the guide renders (jsdom in
* unit tests), so fall back to assigning `scrollLeft` directly.
* Scroll both axes with one call: a second smooth scroll on the same element
* cancels the first one's animation in Chromium, which left the lane at
* midnight whenever "now" also moved to the playing row. `Element.scrollTo`
* is not implemented everywhere the guide renders (jsdom in unit tests), so
* fall back to assigning the offsets directly.
*/
export function scrollElementLeft(
export function scrollElementTo(
element: HTMLElement,
left: number,
{ left, top }: GuideScrollTarget,
animate: boolean
): void {
if (typeof element.scrollTo === 'function') {
element.scrollTo({ left, behavior: animate ? 'smooth' : 'auto' });
element.scrollTo({
left,
...(top === undefined ? {} : { top }),
behavior: animate ? 'smooth' : 'auto',
});
return;
}
element.scrollLeft = left;
if (top !== undefined) {
element.scrollTop = top;
}
}
@@ -173,21 +173,65 @@ describe('EpgGuideViewportController', () => {
expect(test.ensureLoaded).not.toHaveBeenCalled();
});
it('scrolls the lane and the playing row to now, and does nothing off-day', () => {
it('scrolls the lane and the playing row to now in one call, and does nothing off-day', () => {
const test = harness();
test.controller.scrollToNow(900, true);
// 1000 - 200 visible, a third of it kept to the left of the line; the
// playing row 40 keeps three rows above it. A second, vertical smooth
// scroll would cancel the horizontal one in Chromium (#1733).
expect(test.scrollTo).toHaveBeenCalledTimes(1);
expect(test.scrollTo).toHaveBeenCalledWith({
left: 900 - 800 / 3,
top: 37 * 60,
behavior: 'smooth',
});
expect(test.scrollToIndex).not.toHaveBeenCalled();
test.scrollTo.mockClear();
test.controller.scrollToNow(null, false);
expect(test.scrollTo).not.toHaveBeenCalled();
});
it('scrolls only the lane to now when no channel is playing', () => {
const test = harness();
test.host.activeRow = () => -1;
test.controller.scrollToNow(900, false);
// 1000 - 200 visible, a third of it kept to the left of the line.
expect(test.scrollTo).toHaveBeenCalledWith({
left: 900 - 800 / 3,
behavior: 'auto',
});
expect(test.scrollToIndex).toHaveBeenCalledWith(37, 'auto');
});
test.scrollTo.mockClear();
test.scrollToIndex.mockClear();
test.controller.scrollToNow(null, false);
expect(test.scrollTo).not.toHaveBeenCalled();
expect(test.scrollToIndex).not.toHaveBeenCalled();
it('waits for the first rendered rows before the initial jump, once', () => {
const test = harness();
const callback = jest.fn();
test.controller.whenRowsRendered(
test.viewport,
test.destroyRef,
callback
);
// The CDK reports an empty range before it has measured itself.
test.renderedRange$.next({ start: 0, end: 0 });
expect(callback).not.toHaveBeenCalled();
test.renderedRange$.next({ start: 0, end: 12 });
test.renderedRange$.next({ start: 4, end: 16 });
expect(callback).toHaveBeenCalledTimes(1);
});
it('drops the initial jump when the host is destroyed first', () => {
const test = harness();
const callback = jest.fn();
test.controller.whenRowsRendered(
test.viewport,
test.destroyRef,
callback
);
test.destroy();
test.renderedRange$.next({ start: 0, end: 12 });
expect(callback).not.toHaveBeenCalled();
});
it('gives the DOM focus to the cell holding the roving tabindex', () => {
@@ -2,6 +2,7 @@ import { ListRange } from '@angular/cdk/collections';
import { DestroyRef } from '@angular/core';
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { first } from 'rxjs';
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
import { EpgGuideFocus } from './epg-guide-keyboard.controller';
import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util';
@@ -9,7 +10,7 @@ import {
guideBlockRevealScrollLeft,
guideNowScrollLeft,
guideRowNeedsReveal,
scrollElementLeft,
scrollElementTo,
} from './epg-guide-scroll.util';
import { EpgGuideChannel } from './epg-guide-source';
@@ -99,6 +100,27 @@ export class EpgGuideViewportController {
this.host.ensureLoaded(rows.slice(start, end));
}
/**
* Call `callback` once, when the viewport first reports rows to render.
* The CDK attaches its scroll strategy a microtask after init and renders
* rows in a later pass, so a scroll issued on the guide's first render
* finds neither content width nor height and is clamped to the top-left —
* the guide then opened at midnight. The callback still has to wait for
* that render (`afterNextRender`) before it scrolls.
*/
whenRowsRendered(
viewport: CdkVirtualScrollViewport,
destroyRef: DestroyRef,
callback: () => void
): void {
viewport.renderedRangeStream
.pipe(
first((range) => range.end > range.start),
takeUntilDestroyed(destroyRef)
)
.subscribe(() => callback());
}
/** Put the now-line into view, and the playing channel's row with it. */
scrollToNow(nowLeftPx: number | null, animate: boolean): void {
const viewport = this.host.viewport();
@@ -106,22 +128,25 @@ export class EpgGuideViewportController {
return;
}
const element = viewport.elementRef.nativeElement;
scrollElementLeft(
const activeRow = this.host.activeRow();
scrollElementTo(
element,
guideNowScrollLeft(
element.clientWidth,
nowLeftPx,
this.host.channelColumnPx()
),
{
left: guideNowScrollLeft(
element.clientWidth,
nowLeftPx,
this.host.channelColumnPx()
),
// The fixed-size strategy's `scrollToIndex` offset, applied in
// the same call as the horizontal one (see `scrollElementTo`).
top:
activeRow >= 0
? Math.max(0, activeRow - ACTIVE_ROW_MARGIN) *
this.host.rowHeightPx()
: undefined,
},
animate
);
const activeRow = this.host.activeRow();
if (activeRow >= 0) {
viewport.scrollToIndex(
Math.max(0, activeRow - ACTIVE_ROW_MARGIN),
animate ? 'smooth' : 'auto'
);
}
}
/**
@@ -170,7 +195,7 @@ export class EpgGuideViewportController {
this.host.channelColumnPx()
);
if (typeof left === 'number') {
scrollElementLeft(element, left, true);
scrollElementTo(element, { left }, true);
}
}
}
@@ -229,11 +229,18 @@ export class EpgGuideComponent implements OnDestroy {
if (!viewport) {
return;
}
untracked(() =>
this.viewportController.watch(viewport, this.destroyRef)
);
untracked(() => {
this.viewportController.watch(viewport, this.destroyRef);
this.viewportController.whenRowsRendered(
viewport,
this.destroyRef,
() =>
afterNextRender(() => this.jumpNow(false), {
injector: this.injector,
})
);
});
});
afterNextRender(() => this.jumpNow(false));
}
ngOnDestroy(): void {