diff --git a/.changes/epg-guide-jump-to-now.md b/.changes/epg-guide-jump-to-now.md new file mode 100644 index 000000000..10ad6ce83 --- /dev/null +++ b/.changes/epg-guide-jump-to-now.md @@ -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. diff --git a/apps/electron-backend-e2e/src/epg-guide.e2e.ts b/apps/electron-backend-e2e/src/epg-guide.e2e.ts index 6e8b84692..79c36c5dc 100644 --- a/apps/electron-backend-e2e/src/epg-guide.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-guide.e2e.ts @@ -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 { + 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 { + 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 }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts index bc1463c88..b3bd38ba0 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts @@ -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); }); }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts index a7691e128..3b7f394c5 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts @@ -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; + } } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts index 5dca1419b..2a3281eaa 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts @@ -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', () => { diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts index 157d884b1..3689661f9 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts @@ -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); } } } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts index 512f01303..d1913221b 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts @@ -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 {