mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
perf(dashboard): fill the hero rotation dot on the compositor (#1758)
* perf(dashboard): fill the hero rotation dot on the compositor The active hero dot animated `width` 0 → 18px for every 8 s rotation, so an idle dashboard with two or more slides ran style, layout and paint on every frame. The fill is now a full-width bar that slides in with `transform` under the pill's rounded clip. The `animationend` advance, the pause and reduced-motion behaviour are unchanged. Measured on the E2E build (visible, four slides, 120 s): layouts 10,405-10,677 -> 366-369, renderer process CPU 14.2-16.4 s -> 3.7-4.1 s, GPU process CPU 14.8-18.8 s -> 14.0-14.8 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): drive the real hero rotation animation The unit specs dispatch `animationend` on the dot span by hand, so a fill whose real event no longer reached the handler would still pass. The new Electron spec shortens `--hero-rotation-ms` and checks that the running `::before` fill advances the slide, that pause holds it and that Play resumes it. 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:
6 files changed
+141
-8
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: perf
|
||||
area: dashboard
|
||||
---
|
||||
|
||||
The dashboard's rotating banner uses much less CPU while you leave it on
|
||||
screen: the progress dot under it no longer makes the page re-lay itself out
|
||||
on every frame.
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
/** Index of the dot marked `aria-current`, i.e. the slide on screen. */
|
||||
async function activeDotIndex(page: Page): Promise<number> {
|
||||
return page
|
||||
.getByTestId('dashboard-hero-dot')
|
||||
.evaluateAll((dots) =>
|
||||
dots.findIndex((dot) => dot.getAttribute('aria-current') === 'true')
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('Dashboard hero rotation', () => {
|
||||
// The unit tests dispatch `animationend` by hand. This drives the real
|
||||
// CSS fill animation, whose `animationend` comes from the fill's
|
||||
// `::before` and must still reach the span's handler.
|
||||
test('advances when the dot fill animation ends and holds while paused', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
|
||||
try {
|
||||
// Xtream "recently added" titles alone give the hero several slides.
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await goToDashboard(page);
|
||||
|
||||
const hero = page.getByTestId('dashboard-hero');
|
||||
await expect(hero).toBeVisible();
|
||||
await expect
|
||||
.poll(() => page.getByTestId('dashboard-hero-dot').count())
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
// Keep the pointer off the hero: hovering it pauses the rotation.
|
||||
await page.mouse.move(1, 1);
|
||||
// A short interval so the test does not wait out 8 s per slide.
|
||||
await hero.evaluate((element) =>
|
||||
(element as HTMLElement).style.setProperty(
|
||||
'--hero-rotation-ms',
|
||||
'600ms'
|
||||
)
|
||||
);
|
||||
|
||||
const first = await activeDotIndex(page);
|
||||
expect(first).toBeGreaterThanOrEqual(0);
|
||||
await expect
|
||||
.poll(() => activeDotIndex(page), { timeout: 10_000 })
|
||||
.not.toBe(first);
|
||||
|
||||
const pause = page.getByTestId('dashboard-hero-pause');
|
||||
await pause.click();
|
||||
await expect(pause).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.mouse.move(1, 1);
|
||||
const paused = await activeDotIndex(page);
|
||||
// Several intervals: a running fill would have advanced by now.
|
||||
await page.waitForTimeout(2_000);
|
||||
expect(await activeDotIndex(page)).toBe(paused);
|
||||
|
||||
await pause.click();
|
||||
await expect(pause).toHaveAttribute('aria-pressed', 'false');
|
||||
await page.mouse.move(1, 1);
|
||||
await expect
|
||||
.poll(() => activeDotIndex(page), { timeout: 10_000 })
|
||||
.not.toBe(paused);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -183,11 +183,11 @@ code. Costs are for the dev build unless noted.
|
||||
### Added after the measurement: rotating hero
|
||||
|
||||
Not in the measured counts above; found by reading the code on master after
|
||||
#1725.
|
||||
#1725. The rotation row was measured later, when its follow-up landed.
|
||||
|
||||
| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| [dashboard-hero.component.scss:558](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss) `hero-dot-fill` and the `(animationend)="onRotationTick()"` at [dashboard-hero.component.html:233](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html) | The active rotation dot animates `width` 0 → 18 px over `HERO_ROTATION_MS` (8 s); its `animationend` advances the slide, which starts the next fill and the backdrop's 8 s `transform` transition | Continuous while the dashboard is visible with two or more hero slides, unless the rotation is paused or reduced motion is on | A layout pass on every animation frame, because `width` is a layout property (not measured) | **Partly.** The rotation is a feature. Animating a layout property for it is not, and it runs on an otherwise idle page. | Static | **Own thread.** Animate the fill with a compositor-only `transform: scaleX()`, keep the `animationend` advance, and decide whether rotation should pause on an idle or hidden dashboard. |
|
||||
| [dashboard-hero.component.scss:579](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss) `hero-dot-fill` and the `(animationend)="onRotationTick()"` at [dashboard-hero.component.html:233](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html) | The active rotation dot animated `width` 0 → 18 px over `HERO_ROTATION_MS` (8 s); its `animationend` advances the slide, which starts the next fill and the backdrop's 8 s `transform` transition | Continuous while the dashboard is visible with two or more hero slides, unless the rotation is paused or reduced motion is on | Measured afterwards (visible, four slides, 120 Hz display): about 10,500 layouts, 16 s renderer and 17 s GPU process CPU per 120 s; about 0.3 s each while paused | **Partly.** The rotation is a feature. Animating a layout property for it is not, and it runs on an otherwise idle page. | Measured after the fact | **Done.** The fill now animates `transform` only: about 370 layouts (the slide switches), 3.7 s renderer and 14 s GPU process CPU per 120 s. The GPU figure is the compositor drawing every vsync for any running animation, so going lower means animating less, not animating differently; whether rotation should stop on an idle dashboard is open. |
|
||||
| [dashboard-live-epg.presenter.ts:121](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts) `heroLiveCandidates` (lookup) and :191 (pinned portal keys); limits in [dashboard-hero-slides.utils.ts:14–15](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts) | Up to five hero live candidates (three favourites, two recent) join the 30 s XMLTV "now on air" lookup, and are pinned for the portal live-EPG queue even when their rails are hidden or scrolled away | 30 s XMLTV heartbeat while the hero is enabled and any candidate exists; portal sync on the same tick | More lookup keys per `GET_CURRENT_PROGRAMS_BATCH` IPC and SQL, and portal `get_short_epg`-style requests for pinned Xtream/Stalker candidates when stale (not measured) | **Yes** for the programme shown on the hero; the 30 s re-ask of unchanged programmes is not | Static | Covered by #1722: the clock re-asks only when a programme ended, a key has no answer, the answer is five minutes old, or the guide changed, and it pauses while hidden. |
|
||||
|
||||
### Checked and not periodic at idle
|
||||
|
||||
@@ -127,13 +127,19 @@ extras (backdrop, rating, genres, overview, year) come from
|
||||
`DashboardHeroTmdbService` per featured title and vanish when TMDB is off.
|
||||
|
||||
Rotation is the active dot's CSS fill animation (8 s); its `animationend`
|
||||
advances. Hover, focus inside the hero and the pause button pause it; an
|
||||
advances. The fill animates `transform` only (a bar sliding in under the
|
||||
pill's rounded clip), so it runs on the compositor; animating `width` there
|
||||
cost a style, layout and paint pass on every frame of an idle dashboard.
|
||||
Hover, focus inside the hero and the pause button pause it; an
|
||||
explicit Play clears the hover/focus pause until they re-arm; under
|
||||
`prefers-reduced-motion` nothing auto-advances. The active slide is tracked
|
||||
by id, so a late live slide never moves the user off the current one. Test
|
||||
hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
|
||||
`dashboard-hero-dot`, `dashboard-hero-pause`,
|
||||
`dashboard-hero-primary-action`, `dashboard-hero-secondary-action`.
|
||||
`dashboard-hero-rotation.e2e.ts` drives the real fill animation (with a
|
||||
shortened `--hero-rotation-ms`) to prove its `animationend` still advances
|
||||
and that pause holds the slide.
|
||||
|
||||
## Rail Contract
|
||||
|
||||
|
||||
@@ -552,28 +552,41 @@
|
||||
width: 0;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--hero-heading);
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
// The fill is a full-width bar that slides in from the left under the
|
||||
// pill's rounded clip. Only `transform` animates, so the compositor runs
|
||||
// it: animating `width` meant style, layout and paint on every frame for
|
||||
// as long as an idle dashboard stayed on screen. Its `animationend`
|
||||
// reaches the span (the originating element) and advances the slide.
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--hero-heading);
|
||||
}
|
||||
|
||||
.hero__dot--active & {
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.hero--rotating .hero__dot--active & {
|
||||
.hero--rotating .hero__dot--active &::before {
|
||||
animation: hero-dot-fill var(--hero-rotation-ms, 8000ms) linear both;
|
||||
}
|
||||
|
||||
.hero--paused .hero__dot--active & {
|
||||
.hero--paused .hero__dot--active &::before {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hero-dot-fill {
|
||||
from {
|
||||
width: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
width: 18px;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
@@ -276,3 +278,27 @@ describe('DashboardHeroComponent', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardHeroComponent rotation animation', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(__dirname, 'dashboard-hero.component.scss'),
|
||||
'utf8'
|
||||
);
|
||||
const keyframes = (name: string) =>
|
||||
new RegExp(`@keyframes ${name}\\s*\\{([\\s\\S]*?)\\n\\}`).exec(
|
||||
styles
|
||||
)?.[1] ?? '';
|
||||
|
||||
// The fill runs for the whole time an idle dashboard is on screen: a
|
||||
// layout property here means style, layout and paint on every frame.
|
||||
it('fills the active dot with a compositor-only animation', () => {
|
||||
const frames = keyframes('hero-dot-fill');
|
||||
const properties = Array.from(
|
||||
frames.matchAll(/^\s*([a-z-]+)\s*:/gm),
|
||||
(match) => match[1]
|
||||
);
|
||||
|
||||
expect(properties.length).toBeGreaterThan(0);
|
||||
expect(new Set(properties)).toEqual(new Set(['transform']));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user