diff --git a/.changes/dashboard-live-progress-compositor.md b/.changes/dashboard-live-progress-compositor.md
new file mode 100644
index 000000000..d6d7d6ae1
--- /dev/null
+++ b/.changes/dashboard-live-progress-compositor.md
@@ -0,0 +1,8 @@
+---
+type: perf
+area: dashboard
+---
+
+Live programme progress bars on the dashboard now move without animating or
+redrawing the whole page, so a dashboard left open with recent live channels
+uses less CPU and GPU.
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html
index 49f632790..3336ca5f3 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html
@@ -141,7 +141,7 @@
aria-valuemax="100"
[attr.aria-valuenow]="slide.progress"
>
-
+
}
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss
index 408d15802..42055bebd 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss
@@ -339,10 +339,15 @@
i {
display: block;
+ width: 100%;
height: 100%;
border-radius: inherit;
background: var(--app-selection-color, #78adff);
- transition: width 0.4s ease;
+ // A full-width fill slid into place, neither a width nor animated: a
+ // live-EPG tick moves it by under one percent, so an animation was
+ // invisible yet cost a layout pass (width) or a burst of compositor
+ // frames (transform transition) on every tick.
+ transform: translateX(calc(var(--hero-progress, 0) * 1% - 100%));
}
&--live i {
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts
index c05b4250f..f149b7fa0 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts
@@ -254,6 +254,19 @@ describe('DashboardHeroComponent', () => {
).toBe('/workspace/a');
});
+ it('positions the progress fill through a custom property, never its width', () => {
+ // Animating width re-lays out the page on every live-EPG tick; the
+ // stylesheet slides the fill with a transform driven by this property.
+ slides.set([
+ { ...slide('a', 'Live'), contentType: 'live', progress: 42 },
+ ]);
+ render();
+
+ const fill = host().querySelector('.hero__progress i');
+ expect(fill?.style.getPropertyValue('--hero-progress')).toBe('42');
+ expect(fill?.style.width).toBe('');
+ });
+
it('renders nothing when there is nothing to feature', () => {
slides.set([]);
render();
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html
index 2dff0ea0d..649460d5b 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html
@@ -126,7 +126,7 @@
"
>
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss
index 485e34ced..0816357bb 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss
@@ -727,10 +727,15 @@
i {
display: block;
+ width: 100%;
height: 100%;
background: var(--app-live-color, #ff8f8f);
border-radius: 2px;
- transition: width 0.4s ease;
+ // A full-width fill slid into place, neither a width nor animated: a
+ // live-EPG tick moves it by under one percent, so an animation was
+ // invisible yet cost a layout pass (width) or a burst of compositor
+ // frames (transform transition) on every tick.
+ transform: translateX(calc(var(--live-progress, 0) * 1% - 100%));
}
&--idle i {
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts
index f5f748fdb..5d84cb49c 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts
@@ -335,6 +335,34 @@ describe('DashboardRailComponent', () => {
expect(visible.at(-1)).toEqual(['a', 'b']);
});
+ it('positions the live progress fill through a custom property, never its width', async () => {
+ // Animating width re-lays out the page for every frame of the
+ // transition on each EPG tick; the stylesheet slides the fill with
+ // a compositor transform driven by this property instead.
+ installObservers(false);
+ const { fixture } = await render([
+ card({
+ id: 'known',
+ contentType: 'live',
+ nowPlayingProgress: 37.5,
+ }),
+ card({ id: 'unknown', contentType: 'live' }),
+ ]);
+ const fills = Array.from(
+ (
+ fixture.nativeElement as HTMLElement
+ ).querySelectorAll('.rail__channel-progress i')
+ );
+ expect(fills).toHaveLength(2);
+ expect(fills[0].style.getPropertyValue('--live-progress')).toBe(
+ '37.5'
+ );
+ expect(fills[1].style.getPropertyValue('--live-progress')).toBe(
+ '0'
+ );
+ expect(fills.map((fill) => fill.style.width)).toEqual(['', '']);
+ });
+
it('shows the placeholder only while a live card is pending its first answer', async () => {
installObservers(false);
const { fixture } = await render([