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>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-04 11:30:31 +02:00
1 parent e8b181fcea
commit 67e0133f12
8 files changed
+702 -20

No files matched your search

@@ -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,33 @@ 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 the last programme once scrolled to the end.
const allTitles = [...fixture.fullEpg]
.sort((a, b) => a.startTimestamp - b.startTimestamp)
.map((listing) => listing.title);
await expect
.poll(async () => {
const titles = await timelineBlockTitles(app.mainWindow);
return (
titles.includes(currentProgram.title) &&
isContiguousRun(titles, allTitles)
);
})
.toBe(true);
await app.mainWindow
.locator('app-epg-timeline .epg-timeline__ribbon')
.evaluate((ribbon) => {
ribbon.scrollLeft = ribbon.scrollWidth;
});
await expect
.poll(() => timelineBlockTitles(app.mainWindow))
.toEqual(allTitles);
.toContain(allTitles[allTitles.length - 1]);
await app.mainWindow
.locator('app-epg-timeline .epg-timeline__jump')
.click();
// The current programme is highlighted as the "now" block.
await expect(
@@ -866,6 +886,19 @@ async function timelineBlockTitles(
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
}
/** 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>
) {