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>
This commit is contained in:
authored and GitHub committed 2026-10-07 22:07:25 +02:00
1 parent 8581bddcaf
commit 6c8f5028c7
11 files changed
+1121 -27

No files matched your search

@@ -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<string | null> {
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<void> {
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<string>();
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);
}
});
@@ -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<typeof channelItemByTitle>[0]
): Promise<Set<string>> {
const ribbon = page.locator('app-epg-timeline .epg-timeline__ribbon');
const seen = new Set<string>();
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<typeof channelItemByTitle>
) {