diff --git a/.changes/epg-timeline-render-near-view.md b/.changes/epg-timeline-render-near-view.md new file mode 100644 index 000000000..76931f7ca --- /dev/null +++ b/.changes/epg-timeline-render-near-view.md @@ -0,0 +1,8 @@ +--- +type: perf +area: epg +--- + +Live channels start playing with less work competing for the first frame: the +programme guide below the player now draws only the programmes around the part +you are looking at, and fills in the rest as you scroll. diff --git a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts index 2762bc823..e50145ea3 100644 --- a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts @@ -17,6 +17,7 @@ import { } from './electron-test-fixtures'; import { fetchXtreamEpgFixture, + fetchXtreamLiveFixture, fetchStalkerCategoryFixture, } from './portal-mock-fixtures'; @@ -463,3 +464,100 @@ test('@epg @stalker @theme @electron applies live themes to the shared Stalker g await closeElectronApp(app); } }); + +/** Title of the programme block holding keyboard focus, if any. */ +async function focusedTimelineBlockTitle(page: Page): Promise { + return page.evaluate(() => { + const block = document.activeElement?.closest( + 'app-epg-timeline .epg-timeline__block' + ); + return ( + block + ?.querySelector('.epg-timeline__block-title') + ?.textContent?.trim() ?? null + ); + }); +} + +/** Two frames: the focus scroll's measure, then the re-rendered blocks. */ +async function nextFrames(page: Page): Promise { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ) + ); +} + +test('@epg @xtream @electron reaches programmes beyond the rendered range with Tab', async ({ + dataDir, + request, +}) => { + // The ribbon renders only the programmes near the visible range; keyboard + // focus scrolls a block into view, which renders its neighbours before + // the next key press. A generated schedule (240 half-hour slots) is far + // wider than that range. + test.setTimeout(180000); + await resetMockServers(request, ['xtream']); + const credentials = { username: 'minimal', password: 'minimal' }; + const fixture = await fetchXtreamLiveFixture(request, credentials); + const app = await launchElectronApp(dataDir); + + try { + await app.mainWindow.route('https://test-streams.mux.dev/**', () => { + // Keep the external demo request pending; guide data is local. + }); + await addXtreamPortal(app.mainWindow, { + name: 'Xtream Timeline Keyboard', + ...credentials, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + const channelRow = channelItemByTitle( + app.mainWindow, + fixture.items[0]?.name ?? '' + ).first(); + await expect(channelRow).toBeVisible({ timeout: 20000 }); + await channelRow.click(); + + const timeline = app.mainWindow.locator('app-epg-timeline'); + const nowBlock = timeline.locator('.epg-timeline__block.is-now'); + await expect(nowBlock).toBeVisible({ timeout: 20000 }); + const titles = timeline.locator('.epg-timeline__block-title'); + const initial = new Set( + (await titles.allInnerTexts()).map((title) => title.trim()) + ); + expect(initial.size).toBeLessThan(120); + + // Walk well past the rendered range in both directions: ten + // programmes that were not in the DOM when the walk started. + for (const key of ['Tab', 'Shift+Tab']) { + // The previous walk moved the window away from now. + await timeline.locator('.epg-timeline__jump').click(); + await expect(nowBlock).toBeVisible(); + await nowBlock.focus(); + const reached = new Set(); + for (let press = 0; press < 400 && reached.size < 10; press++) { + await app.mainWindow.keyboard.press(key); + await nextFrames(app.mainWindow); + const inRibbon = await app.mainWindow.evaluate( + () => + !!document.activeElement?.closest( + 'app-epg-timeline .epg-timeline__ribbon' + ) + ); + expect(inRibbon, `${key} press ${press} left the ribbon`).toBe( + true + ); + const title = await focusedTimelineBlockTitle(app.mainWindow); + if (title && !initial.has(title)) { + reached.add(title); + } + } + expect(reached.size, `${key} programmes beyond the range`).toBe(10); + } + } finally { + await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index dec87b886..f1741be35 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -107,9 +107,10 @@ test('@epg @xtream @electron removes uploaded guide data and restores provider E await expect .poll(() => timelineBlockTitles(app.mainWindow)) .not.toContain('Temporary XMLTV Bulletin'); + // The programme on now: the ribbon renders the blocks around it. await expect .poll(() => timelineBlockTitles(app.mainWindow)) - .toContain(fixture.fullEpg[0].title); + .toContain(fixture.shortEpg[0].title); } finally { await closeElectronApp(app); await source.close(); @@ -281,14 +282,28 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { app.mainWindow.locator('app-epg-timeline') ).toBeVisible({ timeout: 20000 }); - // The timeline renders the full multi-day window as blocks, - // sorted by start time (no per-day filtering — it scrolls). + // The timeline lays out the full multi-day window sorted by start + // time (no per-day filtering — it scrolls) and renders the blocks + // near the visible range: an ordered run around the current + // programme, and every programme once the ribbon is swept. const allTitles = [...fixture.fullEpg] .sort((a, b) => a.startTimestamp - b.startTimestamp) .map((listing) => listing.title); await expect - .poll(() => timelineBlockTitles(app.mainWindow)) - .toEqual(allTitles); + .poll(async () => { + const titles = await timelineBlockTitles(app.mainWindow); + return ( + titles.includes(currentProgram.title) && + isContiguousRun(titles, allTitles) + ); + }) + .toBe(true); + expect(await sweepTimelineTitles(app.mainWindow)).toEqual( + new Set(allTitles) + ); + await app.mainWindow + .locator('app-epg-timeline .epg-timeline__jump') + .click(); // The current programme is highlighted as the "now" block. await expect( @@ -866,6 +881,56 @@ async function timelineBlockTitles( .then((titles) => titles.map((title) => title.trim()).filter(Boolean)); } +/** + * Scroll the ribbon from its start to its end in half-viewport steps and + * collect every programme title rendered on the way. + */ +async function sweepTimelineTitles( + page: Parameters[0] +): Promise> { + const ribbon = page.locator('app-epg-timeline .epg-timeline__ribbon'); + const seen = new Set(); + let left = 0; + for (;;) { + const { scrollLeft, maxLeft, step } = await ribbon.evaluate( + (element, target) => { + element.scrollLeft = target; + return { + scrollLeft: element.scrollLeft, + maxLeft: element.scrollWidth - element.clientWidth, + step: Math.max(1, element.clientWidth / 2), + }; + }, + left + ); + // Two frames: the scroll's measure, then the re-rendered blocks. + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ) + ); + (await timelineBlockTitles(page)).forEach((title) => seen.add(title)); + if (scrollLeft >= maxLeft) { + return seen; + } + left = scrollLeft + step; + } +} + +/** Whether `run` is a non-empty, in-order slice of `all`. */ +function isContiguousRun(run: string[], all: string[]): boolean { + if (run.length === 0) { + return false; + } + for (let start = 0; start + run.length <= all.length; start++) { + if (run.every((title, index) => all[start + index] === title)) { + return true; + } + } + return false; +} + async function getProgressWidthPercent( row: ReturnType ) { diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index d6e6c79d4..a9f26aa52 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -385,6 +385,18 @@ remain local when the meaning is explicit. layouts keep the full EPG-only panel. - Keep the EPG content mounted while collapsed so current-program state can continue updating. +- The ribbon renders only the blocks, ticks and day dividers within half a + viewport of the visible range (`TimelineWindowController` in + `epg-timeline-window.controller.ts`); the track keeps the full schedule's + width, so positions, the scrollbar and scroll-to-now are unchanged. A + channel's full schedule is often a few hundred programmes, and rendering + them all competed with the stream's first frame (J3 in + [performance journeys](performance-journeys.md#j3-playback-start-playback-to-the-first-frame)). + Tests and features must not assume an off-screen programme is in the DOM: + scroll the ribbon to it first. Keyboard access must keep working: focusing + a block scrolls it into view, which renders its neighbours before the next + Tab (`epg-timeline-interaction.e2e.ts` walks past the rendered range both + ways). ### Collapsible Live Sidebar diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts index 21a63db30..0bf804c2f 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts @@ -82,11 +82,52 @@ describe('EpgTimelineTrackComponent', () => { expect(component.popover()).toBeNull(); }); + // The ribbon window can remove a focused block on scroll; a removed node + // fires no focusout, so the popover must follow the rendered items. + it('closes the popover when its block leaves the rendered items', () => { + const item = renderBlock({ tier: 'narrow' }); + const other = renderBlock({ key: 'k2', tier: 'narrow' }); + fixture.componentRef.setInput('items', [item, other]); + fixture.detectChanges(); + component.onBlockEnter(item, { + currentTarget: { + getBoundingClientRect: () => ({ + left: 100, + width: 40, + top: 60, + bottom: 200, + }), + }, + } as unknown as Event); + + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + expect(component.popover()?.key).toBe('k1'); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[role="tooltip"]' + ) + ).not.toBeNull(); + + fixture.componentRef.setInput('items', [other]); + fixture.detectChanges(); + expect(component.popover()).toBeNull(); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[role="tooltip"]' + ) + ).toBeNull(); + }); + it('never shows a popover for a wide block', () => { const item = renderBlock({ tier: 'wide' }); component.onBlockEnter(item, { currentTarget: { - getBoundingClientRect: () => ({ left: 0, width: 200, bottom: 0 }), + getBoundingClientRect: () => ({ + left: 0, + width: 200, + bottom: 0, + }), }, } as unknown as Event); expect(component.popover()).toBeNull(); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts index efd599e9b..655a8d565 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts @@ -5,8 +5,8 @@ import { computed, HostBinding, input, + linkedSignal, output, - signal, } from '@angular/core'; import { MatIcon } from '@angular/material/icon'; import { EpgProgram } from '@iptvnator/shared/interfaces'; @@ -22,6 +22,8 @@ import { } from './epg-timeline.utils'; interface PopoverState { + /** The render item it describes. */ + readonly key: string; readonly title: string; readonly desc: string | null; readonly startMs: number; @@ -60,7 +62,20 @@ export class EpgTimelineTrackComponent { readonly infoClick = output(); readonly groupExpand = output(); - readonly popover = signal(null); + /** + * 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({ + 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(); @@ -116,6 +131,7 @@ export class EpgTimelineTrackComponent { 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, @@ -128,9 +144,7 @@ export class EpgTimelineTrackComponent { rect.left + rect.width / 2 - width / 2 ) ), - top: flipAbove - ? Math.max(12, rect.top - estHeight - 8) - : below, + top: flipAbove ? Math.max(12, rect.top - estHeight - 8) : below, }); } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts new file mode 100644 index 000000000..eb203605b --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.spec.ts @@ -0,0 +1,506 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +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 } from './epg-timeline.component'; +import { + marksInRange, + renderItemsInRange, + TIMELINE_WINDOW_BUFFER_VIEWPORTS, + timelineWindowRange, + viewportFromScroller, + viewportNeedsWindow, +} from './epg-timeline-window.controller'; +import { + TIMELINE_MINUTE_MS, + TimelineRenderItem, + TimelineTick, +} from './epg-timeline.utils'; + +const SLOT_MIN = 30; + +function programAt(startOffsetMin: number, title: string): EpgProgram { + const start = new Date(Date.now() + startOffsetMin * TIMELINE_MINUTE_MS); + const stop = new Date(start.getTime() + SLOT_MIN * TIMELINE_MINUTE_MS); + return { + start: start.toISOString(), + stop: stop.toISOString(), + channel: 'ch', + title, + desc: null, + category: null, + }; +} + +/** + * The Xtream mock's shape: 240 half-hour slots, two days back, three ahead. + * Now falls in the middle of slot 96. + */ +function mockSchedule(): EpgProgram[] { + const firstOffsetMin = -2 * 24 * 60 - SLOT_MIN / 2; + return Array.from({ length: 240 }, (_, index) => + programAt(firstOffsetMin + index * SLOT_MIN, `Slot ${index}`) + ); +} + +/** Half-hour slots from local midnight two days back: the axis start. */ +function midnightSchedule(): EpgProgram[] { + const midnight = new Date(Date.now() - 2 * 24 * 60 * TIMELINE_MINUTE_MS); + midnight.setHours(0, 0, 0, 0); + const startOffsetMin = + (midnight.getTime() - Date.now()) / TIMELINE_MINUTE_MS; + return Array.from({ length: 240 }, (_, index) => + programAt(startOffsetMin + index * SLOT_MIN, `Slot ${index}`) + ); +} + +function item(key: string, leftPx: number, widthPx: number) { + return { kind: 'group', key, leftPx, widthPx } as TimelineRenderItem; +} + +describe('timeline window helpers', () => { + const axis = { startMs: 0, endMs: 3 * 24 * 60 * TIMELINE_MINUTE_MS }; + + it('spans the viewport plus the buffer on both sides of the centre', () => { + const range = timelineWindowRange( + { centreMs: 1000 * TIMELINE_MINUTE_MS, widthPx: 800 }, + axis, + 2 + ); + const half = 800 * (0.5 + TIMELINE_WINDOW_BUFFER_VIEWPORTS); + expect(range).toEqual({ startPx: 2000 - half, endPx: 2000 + half }); + }); + + it('keeps the items overlapping the range, in order', () => { + const items = [ + item('before', 0, 90), + item('touching', 50, 60), + item('inside', 200, 50), + item('straddling', 280, 100), + item('after', 400, 50), + ]; + const kept = renderItemsInRange(items, { startPx: 100, endPx: 300 }); + expect(kept.map((entry) => entry.key)).toEqual([ + 'touching', + 'inside', + 'straddling', + ]); + }); + + it('keeps a mark whose label can still reach into the range', () => { + const marks: TimelineTick[] = [ + { ms: 1, offsetMin: 0 }, + { ms: 2, offsetMin: 400 }, + { ms: 3, offsetMin: 600 }, + { ms: 4, offsetMin: 1200 }, + ]; + const kept = marksInRange(marks, 1, { startPx: 500, endPx: 1000 }); + expect(kept.map((mark) => mark.ms)).toEqual([2, 3]); + }); + + it('measures the centre of the scrolled viewport in epoch time', () => { + expect(viewportFromScroller(1000, 400, axis, 2)).toEqual({ + centreMs: 600 * TIMELINE_MINUTE_MS, + widthPx: 400, + }); + }); + + it('re-windows after a quarter-viewport move or a wider ribbon only', () => { + const current = { centreMs: 0, widthPx: 800 }; + const at = (px: number, widthPx = 800) => ({ + centreMs: px * TIMELINE_MINUTE_MS, + widthPx, + }); + expect(viewportNeedsWindow(current, at(199), 1)).toBe(false); + expect(viewportNeedsWindow(current, at(-200), 1)).toBe(true); + expect(viewportNeedsWindow(current, at(0, 600), 1)).toBe(false); + expect(viewportNeedsWindow(current, at(0, 900), 1)).toBe(true); + }); +}); + +describe('EpgTimelineComponent ribbon windowing', () => { + let fixture: ComponentFixture; + let component: EpgTimelineComponent; + let ribbonWidth: number; + let frames: FrameRequestCallback[]; + + function flushFrames(): void { + while (frames.length > 0) { + frames.splice(0).forEach((callback) => callback(0)); + fixture.detectChanges(); + } + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [EpgTimelineComponent], + providers: [ + { + provide: MatDialog, + useValue: { + open: () => ({ afterClosed: () => of(undefined) }), + }, + }, + { + provide: TranslateService, + useValue: { + currentLang: 'en', + defaultLang: 'en', + onLangChange: new BehaviorSubject(null), + onTranslationChange: new BehaviorSubject(null), + onDefaultLangChange: new BehaviorSubject(null), + get: (key: string) => of(key), + instant: (key: string) => key, + }, + }, + ], + }); + ribbonWidth = 800; + jest.spyOn( + HTMLElement.prototype, + 'clientWidth', + 'get' + ).mockImplementation(() => ribbonWidth); + // Queue frame callbacks; `flushFrames` runs them like a frame would. + frames = []; + jest.spyOn(window, 'requestAnimationFrame').mockImplementation( + (callback) => frames.push(callback) + ); + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation( + () => undefined + ); + HTMLElement.prototype.scrollTo = function ( + this: HTMLElement, + options?: ScrollToOptions | number + ) { + if (typeof options === 'object' && options.left !== undefined) { + this.scrollLeft = options.left; + } + } as HTMLElement['scrollTo']; + + fixture = TestBed.createComponent(EpgTimelineComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function render(programs: EpgProgram[]): void { + fixture.componentRef.setInput('programs', programs); + fixture.detectChanges(); + flushFrames(); + } + + function ribbon(): HTMLElement { + return fixture.nativeElement.querySelector('.epg-timeline__ribbon'); + } + + function renderedTitles(): string[] { + return Array.from( + fixture.nativeElement.querySelectorAll( + '.epg-timeline__block-title' + ) as NodeListOf + ).map((title) => title.textContent?.trim() ?? ''); + } + + function scrollRibbonTo(left: number): void { + ribbon().scrollLeft = left; + ribbon().dispatchEvent(new Event('scroll')); + flushFrames(); + } + + function blockLeftPx(title: string): number { + const block = component + .renderItems() + .find( + (entry) => + entry.kind === 'block' && + entry.block.program.title === title + ); + if (!block) { + throw new Error(`no render item for ${title}`); + } + return block.leftPx; + } + + it('renders the programmes around now, not the whole schedule', () => { + render(mockSchedule()); + + const titles = renderedTitles(); + expect(component.renderItems()).toHaveLength(240); + expect(titles.length).toBeGreaterThan(0); + // Twice the ribbon width (800px) of 52.5px blocks, plus the edges. + expect(titles.length).toBeLessThanOrEqual(34); + expect(titles).toContain('Slot 96'); + expect(titles).not.toContain('Slot 0'); + expect(titles).not.toContain('Slot 239'); + // The track keeps the width of the whole schedule. + const track = fixture.nativeElement.querySelector( + 'app-epg-timeline-track' + ) as HTMLElement; + expect(track.style.width).toBe(`${component.trackWidthPx()}px`); + }); + + it('keeps the on-now block, including its live state', () => { + render(mockSchedule()); + + const nowTitle = fixture.nativeElement.querySelector( + '.epg-timeline__block.is-now .epg-timeline__block-title' + ) as HTMLElement; + expect(nowTitle.textContent?.trim()).toBe('Slot 96'); + }); + + it('renders the blocks of a range the user scrolls to', () => { + render(mockSchedule()); + + scrollRibbonTo(blockLeftPx('Slot 230') - ribbonWidth / 2); + + const titles = renderedTitles(); + expect(titles).toContain('Slot 230'); + expect(titles).toContain('Slot 239'); + expect(titles).not.toContain('Slot 96'); + }); + + /** Render items overlapping the ribbon's px range at the current scale. */ + function itemsIn(leftPx: number, widthPx: number): string[] { + return component + .renderItems() + .filter( + (entry) => + entry.leftPx + entry.widthPx >= leftPx && + entry.leftPx <= leftPx + widthPx + ) + .map((entry) => entry.key); + } + + // The anchored scroll lands on a later frame; the window must already + // cover what the ribbon will show at the new scale. + it('windows the anchored range in the same pass as a wheel zoom', () => { + render(mockSchedule()); + const prevScale = component.scale(); + const left = ribbon().scrollLeft; + // Zoom in to the maximum around the right edge of the ribbon. + const anchorPx = ribbonWidth - 20; + component.onRibbonWheel( + new WheelEvent('wheel', { + ctrlKey: true, + clientX: anchorPx, + deltaY: -1000, + }) + ); + fixture.detectChanges(); + + const nextScale = component.scale(); + expect(nextScale).toBeGreaterThan(prevScale); + const anchoredLeft = + ((left + anchorPx) / prevScale) * nextScale - anchorPx; + const windowed = component.ribbonWindow.items().map((e) => e.key); + expect(windowed).toEqual( + expect.arrayContaining(itemsIn(anchoredLeft, ribbonWidth)) + ); + }); + + // At the ribbon's start the anchored position is negative; the browser + // keeps scrollLeft at 0 and fires no scroll event to re-window. + it('windows the attainable range for a zoom-out at the ribbon start', () => { + render(midnightSchedule()); + // From the deepest zoom, so the unclamped centre would be far off. + component.onZoom(6); + flushFrames(); + scrollRibbonTo(0); + component.onRibbonWheel( + new WheelEvent('wheel', { + ctrlKey: true, + clientX: ribbonWidth - 20, + // 6 → about 1.4: above the grouping zoom, so blocks stay + // separate and a missing one shows. + deltaY: 727, + }) + ); + fixture.detectChanges(); + expect(component.scale()).toBeGreaterThan(1.3); + const visible = itemsIn(0, ribbonWidth); + expect(visible.length).toBeGreaterThan(0); + + const windowed = component.ribbonWindow.items().map((e) => e.key); + expect(windowed).toEqual(expect.arrayContaining(visible)); + }); + + it('re-windows on the scroll position the browser applied', () => { + render(mockSchedule()); + const end = blockLeftPx('Slot 239'); + component.onZoom(component.scale() * 2); + // The browser clamps the anchored position to the end of the track. + const scroller = ribbon(); + let applied = scroller.scrollLeft; + Object.defineProperty(scroller, 'scrollLeft', { + configurable: true, + get: () => applied, + set: () => (applied = end * 2), + }); + flushFrames(); + + expect(renderedTitles()).toContain('Slot 239'); + }); + + // A time offset can carry the first programme across midnight: the axis + // origin and every track position move under an unchanged scrollLeft, + // and no scroll event follows. + it('re-measures the ribbon when the axis origin moves', () => { + render(midnightSchedule()); + const startBefore = component.axis().startMs; + const left = ribbon().scrollLeft; + + fixture.componentRef.setInput('offsetMinutes', -720); + fixture.detectChanges(); + + expect(component.axis().startMs).not.toBe(startBefore); + expect(ribbon().scrollLeft).toBe(left); + const visible = itemsIn(left, ribbonWidth); + expect(visible.length).toBeGreaterThan(0); + expect(component.ribbonWindow.items().map((e) => e.key)).toEqual( + expect.arrayContaining(visible) + ); + }); + + it('windows an expanded group before the ribbon scrolls to it', () => { + render(mockSchedule()); + const first = component + .blocks() + .find((block) => block.program.title === 'Slot 200'); + const last = component + .blocks() + .find((block) => block.program.title === 'Slot 203'); + if (!first || !last) throw new Error('missing slots'); + jest.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation( + () => undefined + ); + + component.onGroupExpand({ + startMs: first.startMs, + stopMs: last.stopMs, + } as Parameters[0]); + fixture.detectChanges(); + + expect(renderedTitles()).toEqual( + expect.arrayContaining(['Slot 200', 'Slot 203']) + ); + expect(renderedTitles()).not.toContain('Slot 96'); + }); + + it('does not re-render for a scroll within a quarter viewport', () => { + render(mockSchedule()); + const before = component.ribbonWindow.items(); + + scrollRibbonTo(ribbon().scrollLeft + ribbonWidth / 8); + + expect(component.ribbonWindow.items()).toBe(before); + }); + + it('keeps the selected programme highlighted after scrolling away and back', () => { + render(mockSchedule()); + const selectedTitle = 'Slot 97'; + const selected = Array.from( + fixture.nativeElement.querySelectorAll( + '.epg-timeline__block' + ) as NodeListOf + ).find( + (block) => + block + .querySelector('.epg-timeline__block-title') + ?.textContent?.trim() === selectedTitle + ) as HTMLElement; + selected.click(); + fixture.detectChanges(); + const start = ribbon().scrollLeft; + + scrollRibbonTo(blockLeftPx('Slot 230')); + expect(renderedTitles()).not.toContain(selectedTitle); + scrollRibbonTo(start); + + const highlighted = fixture.nativeElement.querySelector( + '.epg-timeline__block.is-selected .epg-timeline__block-title' + ) as HTMLElement; + expect(highlighted.textContent?.trim()).toBe(selectedTitle); + }); + + it('marks a far-away catch-up programme as playing once it is in view', () => { + const schedule = mockSchedule(); + render(schedule); + fixture.componentRef.setInput('activeProgram', schedule[2]); + fixture.detectChanges(); + expect(renderedTitles()).not.toContain('Slot 2'); + + scrollRibbonTo(blockLeftPx('Slot 2')); + + const playing = fixture.nativeElement.querySelector( + '.epg-timeline__block.is-playing .epg-timeline__block-title' + ) as HTMLElement; + expect(playing.textContent?.trim()).toBe('Slot 2'); + }); + + it('widens the window when the ribbon grows', () => { + render(mockSchedule()); + const before = renderedTitles().length; + + ribbonWidth = 2400; + component.ribbonWindow.measureWidth(); + fixture.detectChanges(); + + expect(renderedTitles().length).toBeGreaterThan(before); + expect(renderedTitles()).toContain('Slot 96'); + }); + + it('keeps the window on now when a resize reports before the focus scroll', () => { + // The observer's first report can precede the auto-focus scroll, + // while the ribbon is still at its left edge: it must not move the + // window to the start of the schedule. + fixture.componentRef.setInput('programs', mockSchedule()); + fixture.detectChanges(); + expect(ribbon().scrollLeft).toBe(0); + + ribbonWidth = 1200; + component.ribbonWindow.measureWidth(); + fixture.detectChanges(); + + expect(renderedTitles()).toContain('Slot 96'); + expect(renderedTitles()).not.toContain('Slot 0'); + }); + + it('keeps the window where the ribbon is when a small scroll commits another day', () => { + // A scroll under the re-window step can still cross midnight; the + // committed day must not re-centre the window on its noon. + render(mockSchedule()); + const tomorrow = new Date(Date.now() + 24 * 60 * TIMELINE_MINUTE_MS); + const pad = (n: number) => String(n).padStart(2, '0'); + component.viewDayKey.set( + `${tomorrow.getFullYear()}-${pad(tomorrow.getMonth() + 1)}-${pad(tomorrow.getDate())}` + ); + fixture.detectChanges(); + + expect(renderedTitles()).toContain('Slot 96'); + }); + + it('does not follow the now tick away from an unscrolled ribbon', () => { + render(mockSchedule()); + + component.nowMs.set(Date.now() + 12 * 60 * TIMELINE_MINUTE_MS); + fixture.detectChanges(); + + expect(renderedTitles()).toContain('Slot 96'); + }); + + it('starts over around now when another channel loads', () => { + render(mockSchedule()); + scrollRibbonTo(blockLeftPx('Slot 230')); + expect(renderedTitles()).not.toContain('Slot 96'); + + render( + mockSchedule().map((program) => ({ ...program, channel: 'other' })) + ); + + expect(renderedTitles()).toContain('Slot 96'); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts new file mode 100644 index 000000000..61307a6a6 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-window.controller.ts @@ -0,0 +1,322 @@ +import { + computed, + effect, + ElementRef, + inject, + linkedSignal, + Signal, + untracked, +} from '@angular/core'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { getTodayEpgDateKey, parseEpgDateKey } from '../epg-date'; +import { programsFocusKey } from './epg-timeline-scroll.controller'; +import { + TIMELINE_MINUTE_MS, + TimelineAxis, + TimelineBlock, + TimelineDayDivider, + TimelineRenderItem, + TimelineTick, +} from './epg-timeline.utils'; + +// ───────────────────────── ribbon windowing ───────────────────────── +// A channel's schedule is often a few hundred programmes over several days, +// but the ribbon shows a few hours. Rendering every block costs thousands of +// DOM nodes the moment a channel is selected, while its stream is starting +// (J3 in docs/architecture/performance-journeys.md). Only blocks, ticks and +// dividers near the visible range are rendered; the track keeps its full +// width, so scrolling, the scrollbar and every position are unchanged. + +/** Viewport widths rendered beyond each edge of the visible ribbon. */ +export const TIMELINE_WINDOW_BUFFER_VIEWPORTS = 0.5; +/** + * Scrolling re-windows only once the centre moved this share of a viewport. + * Below the buffer, so the visible range is always rendered between steps. + */ +export const TIMELINE_WINDOW_STEP_FRACTION = 0.25; +/** Room for a tick or day label that starts left of the window. */ +const TIMELINE_LABEL_ALLOWANCE_PX = 160; + +/** The centre of the rendered range (epoch ms) and the viewport width it covers. */ +export interface TimelineViewport { + readonly centreMs: number; + readonly widthPx: number; +} + +export interface TimelinePxRange { + readonly startPx: number; + readonly endPx: number; +} + +export function timelineWindowRange( + viewport: TimelineViewport, + axis: TimelineAxis, + scale: number +): TimelinePxRange { + const centrePx = + ((viewport.centreMs - axis.startMs) / TIMELINE_MINUTE_MS) * scale; + const halfPx = viewport.widthPx * (0.5 + TIMELINE_WINDOW_BUFFER_VIEWPORTS); + return { startPx: centrePx - halfPx, endPx: centrePx + halfPx }; +} + +/** Render items overlapping the range, in their original order. */ +export function renderItemsInRange( + items: readonly TimelineRenderItem[], + range: TimelinePxRange +): TimelineRenderItem[] { + return items.filter( + (item) => + item.leftPx + item.widthPx >= range.startPx && + item.leftPx <= range.endPx + ); +} + +/** Ticks or day dividers whose label can reach into the range. */ +export function marksInRange( + marks: readonly T[], + scale: number, + range: TimelinePxRange +): T[] { + return marks.filter((mark) => { + const leftPx = mark.offsetMin * scale; + return ( + leftPx >= range.startPx - TIMELINE_LABEL_ALLOWANCE_PX && + leftPx <= range.endPx + ); + }); +} + +export function viewportFromScroller( + scrollLeft: number, + clientWidth: number, + axis: TimelineAxis, + scale: number +): TimelineViewport { + const centreMin = (scrollLeft + clientWidth / 2) / scale; + return { + centreMs: axis.startMs + centreMin * TIMELINE_MINUTE_MS, + widthPx: clientWidth, + }; +} + +/** + * Whether a measured viewport needs a new window: the centre moved far enough + * or the ribbon got wider than the covered width. A narrower ribbon keeps the + * (larger) window, so a resize never removes blocks still in view. + */ +export function viewportNeedsWindow( + current: TimelineViewport, + next: TimelineViewport, + scale: number +): boolean { + if (next.widthPx > current.widthPx) { + return true; + } + const movedPx = + (Math.abs(next.centreMs - current.centreMs) / TIMELINE_MINUTE_MS) * + scale; + return movedPx >= current.widthPx * TIMELINE_WINDOW_STEP_FRACTION; +} + +/** The timeline component's signals the controller reads. */ +export interface TimelineWindowHost { + readonly ribbon: Signal | undefined>; + readonly programs: Signal; + readonly axis: Signal; + readonly blocks: Signal; + readonly scale: Signal; + readonly nowMs: Signal; + readonly viewDayKey: Signal; + readonly renderItems: Signal; + readonly ticks: Signal; + readonly dividers: Signal; +} + +/** + * Picks the ribbon content to render. Before the ribbon reports a scroll + * position, the window is centred where the auto-focus lands (the programme + * on now, or noon of a viewed non-today day) and spans the timeline host's + * width (the browser window's before the host is laid out), an upper bound + * of the ribbon's. A new channel or a remounted ribbon starts over from that + * estimate. Construct it in an injection context (a component field): it + * reads the host element and observes the ribbon's size. + */ +export class TimelineWindowController { + private measureFrame = 0; + private readonly hostElement = inject>(ElementRef); + + private readonly identity = computed( + () => ({ + ribbon: this.scroller(), + key: programsFocusKey(this.ctx.programs()), + axisStartMs: this.ctx.axis().startMs, + }), + { + equal: (left, right) => + left.ribbon === right.ribbon && + left.key === right.key && + left.axisStartMs === right.axisStartMs, + } + ); + /** + * Estimated once per channel or ribbon mount, then moved only by the + * scroll and resize measurements: a live estimate would follow the 30 s + * now tick and the centred day away from what the ribbon shows. A moved + * axis origin (a time offset carrying the first programme across + * midnight) shifts every track position under an unchanged `scrollLeft`, + * with no scroll event, so the viewport is measured from the ribbon again. + */ + private readonly viewport = linkedSignal< + { ribbon: HTMLElement | undefined; key: string; axisStartMs: number }, + TimelineViewport + >({ + source: this.identity, + computation: (source, previous) => + untracked(() => + previous && + previous.source.ribbon === source.ribbon && + previous.source.key === source.key + ? (this.measuredViewport() ?? previous.value) + : this.initialViewport() + ), + }); + private readonly range = computed(() => + timelineWindowRange(this.viewport(), this.ctx.axis(), this.ctx.scale()) + ); + + readonly items = computed(() => + renderItemsInRange(this.ctx.renderItems(), this.range()) + ); + readonly ticks = computed(() => + marksInRange(this.ctx.ticks(), this.ctx.scale(), this.range()) + ); + readonly dividers = computed(() => + marksInRange(this.ctx.dividers(), this.ctx.scale(), this.range()) + ); + + constructor(private readonly ctx: TimelineWindowHost) { + effect((onCleanup) => { + const scroller = this.scroller(); + if (scroller) { + onCleanup(this.observe(scroller)); + } + }); + } + + /** Re-measure on the next frame (scroll); repeated calls coalesce. */ + scheduleMeasure(): void { + if (this.measureFrame) { + return; + } + this.measureFrame = requestAnimationFrame(() => { + this.measureFrame = 0; + this.measure(); + }); + } + + /** Follow the ribbon's scrolling and size; returns the cleanup. */ + private observe(ribbon: HTMLElement): () => void { + const onScroll = () => this.scheduleMeasure(); + ribbon.addEventListener('scroll', onScroll, { passive: true }); + const observer = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => this.measureWidth()); + observer?.observe(ribbon); + return () => { + ribbon.removeEventListener('scroll', onScroll); + observer?.disconnect(); + cancelAnimationFrame(this.measureFrame); + this.measureFrame = 0; + }; + } + + /** + * A resize widens the window around its current centre and never moves + * it: the observer's first report can come before the auto-focus scroll, + * while the ribbon is still at its left edge. + */ + measureWidth(): void { + const widthPx = this.scroller()?.clientWidth ?? 0; + const current = this.viewport(); + if (widthPx > current.widthPx) { + this.viewport.set({ centreMs: current.centreMs, widthPx }); + } + } + + /** + * Re-centres the window on the ribbon minute a scale change is about to + * show, in the same pass as the new scale. The scroll event that would + * re-measure arrives only after the anchored `scrollLeft` lands on a + * later frame; until then the window would be the previous centre at the + * new scale, which can be far from what the ribbon shows. + */ + centreOnMinute(offsetMin: number): void { + this.viewport.set({ + centreMs: this.ctx.axis().startMs + offsetMin * TIMELINE_MINUTE_MS, + widthPx: this.viewport().widthPx, + }); + } + + /** Follow the scroll position: re-centre once it moved far enough. */ + measure(): void { + const scroller = this.scroller(); + if (!scroller || scroller.clientWidth <= 0) { + return; + } + const scale = this.ctx.scale(); + const next = viewportFromScroller( + scroller.scrollLeft, + scroller.clientWidth, + this.ctx.axis(), + scale + ); + if (viewportNeedsWindow(this.viewport(), next, scale)) { + this.viewport.set(next); + } + } + + /** The viewport the ribbon shows now; null before it is laid out. */ + private measuredViewport(): TimelineViewport | null { + const scroller = this.scroller(); + if (!scroller || scroller.clientWidth <= 0) { + return null; + } + return viewportFromScroller( + scroller.scrollLeft, + scroller.clientWidth, + this.ctx.axis(), + this.ctx.scale() + ); + } + + private scroller(): HTMLElement | undefined { + return this.ctx.ribbon()?.nativeElement; + } + + private initialViewport(): TimelineViewport { + const hostWidth = this.hostElement.nativeElement.clientWidth; + const widthPx = + hostWidth > 0 + ? hostWidth + : typeof window === 'undefined' + ? 0 + : window.innerWidth; + const dayKey = this.ctx.viewDayKey(); + if (dayKey !== getTodayEpgDateKey()) { + return { + centreMs: + parseEpgDateKey(dayKey).getTime() + + 12 * 60 * TIMELINE_MINUTE_MS, + widthPx, + }; + } + const current = this.ctx.blocks().find((block) => block.when === 'now'); + return { + centreMs: current + ? (current.startMs + current.stopMs) / 2 + : this.ctx.nowMs(), + widthPx, + }; + } +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts index 5a236f0d6..940125aff 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-zoom.controller.ts @@ -3,13 +3,28 @@ import { TIMELINE_WHEEL_ZOOM_RATE, TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN, + TimelineZoomLevel, } from './epg-timeline-render.util'; +/** Translation key naming a zoom level in the toolbar tooltip. */ +export function timelineZoomLabelKey(level: TimelineZoomLevel): string { + switch (level) { + case 'day': + return 'EPG.TIMELINE.ZOOM_DAY'; + case 'hours': + return 'EPG.TIMELINE.ZOOM_HOURS'; + default: + return 'EPG.TIMELINE.ZOOM_DETAIL'; + } +} + export interface TimelineZoomContext { /** The horizontal scroller hosting the track (undefined until rendered). */ readonly ribbon: () => HTMLElement | undefined; readonly scale: () => number; readonly setScale: (scale: number) => void; + /** The ribbon minute the anchored scroll will centre, set with the scale. */ + readonly centreOn?: (offsetMin: number) => void; } /** @@ -84,6 +99,12 @@ export class TimelineZoomController { const currentLeft = this.pendingScrollLeft ?? scroller.scrollLeft; const anchorMin = (currentLeft + anchorPx) / prev; this.pendingScrollLeft = anchorMin * next - anchorPx; + // The browser clamps scrollLeft at 0: a zoom-out anchored right of + // centre at the ribbon's start lands there, not left of it. + this.ctx.centreOn?.( + (Math.max(0, this.pendingScrollLeft) + scroller.clientWidth / 2) / + next + ); if (this.frame === 0) { this.frame = requestAnimationFrame(() => this.flushScroll()); } @@ -97,6 +118,12 @@ export class TimelineZoomController { const scroller = this.ctx.ribbon(); if (scroller && left !== null) { scroller.scrollLeft = left; + // Where the browser put it: the end of the track clamps too, and + // an unchanged position fires no scroll event to re-window. + this.ctx.centreOn?.( + (scroller.scrollLeft + scroller.clientWidth / 2) / + this.ctx.scale() + ); } } } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html index 6819e63e8..13381f94a 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html @@ -209,9 +209,9 @@ (wheel)="onRibbonWheel($event)" > (null); /** Day centred in the ribbon, seeded from the controlled `selectedDate` so * a non-today date survives (re)mount and follows host changes. */ - private readonly viewDayKey = linkedSignal(() => { + readonly viewDayKey = linkedSignal(() => { const key = this.selectedDate()?.trim(); return key ? key : getTodayEpgDateKey(); }); @@ -141,6 +145,7 @@ export class EpgTimelineComponent { ribbon: () => this.ribbon()?.nativeElement, scale: () => this.scale(), setScale: (scale) => this.scale.set(scale), + centreOn: (offsetMin) => this.ribbonWindow.centreOnMinute(offsetMin), }); /** Ribbon scrolling + channel-select auto-focus, extracted from the view. */ @@ -200,6 +205,8 @@ export class EpgTimelineComponent { buildTimelineTicks(this.axis(), timelineTickStepForScale(this.scale())) ); readonly dividers = computed(() => buildTimelineDayDividers(this.axis())); + /** Only the ribbon content near the visible range is rendered. */ + readonly ribbonWindow = new TimelineWindowController(this); readonly trackWidthPx = computed(() => { const axis = this.axis(); return ( @@ -215,16 +222,9 @@ export class EpgTimelineComponent { readonly zoomLevel = computed(() => timelineZoomLevelForScale(this.scale()) ); - readonly zoomLabelKey = computed(() => { - switch (this.zoomLevel()) { - case 'day': - return 'EPG.TIMELINE.ZOOM_DAY'; - case 'hours': - return 'EPG.TIMELINE.ZOOM_HOURS'; - default: - return 'EPG.TIMELINE.ZOOM_DETAIL'; - } - }); + readonly zoomLabelKey = computed(() => + timelineZoomLabelKey(this.zoomLevel()) + ); /** `zoom_out` reads as "overview"; both finer bands share `zoom_in`. */ readonly zoomIcon = computed(() => this.zoomLevel() === 'day' ? 'zoom_out' : 'zoom_in' @@ -348,6 +348,7 @@ export class EpgTimelineComponent { const axis = this.axis(); const centreMs = (group.startMs + group.stopMs) / 2; const offsetMin = (centreMs - axis.startMs) / TIMELINE_MINUTE_MS; + this.ribbonWindow.centreOnMinute(offsetMin); this.scroll.scrollToOffset(offsetMin, 0.5); }