diff --git a/src/app/core-ui/main-header/play-button/play-button.component.ts b/src/app/core-ui/main-header/play-button/play-button.component.ts index 5d70119447..7aa94b0ad3 100644 --- a/src/app/core-ui/main-header/play-button/play-button.component.ts +++ b/src/app/core-ui/main-header/play-button/play-button.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectorRef, Component, computed, + effect, ElementRef, inject, input, @@ -25,6 +26,7 @@ import { TaskService } from '../../../features/tasks/task.service'; import { animationFrameScheduler, Subscription } from 'rxjs'; import { distinctUntilChanged, observeOn } from 'rxjs/operators'; import { NavigateToTaskService } from '../../navigate-to-task/navigate-to-task.service'; +import { lazySetInterval } from '../../../util/lazy-set-interval'; @Component({ selector: 'play-button', @@ -51,7 +53,10 @@ import { NavigateToTaskService } from '../../navigate-to-task/navigate-to-task.s } @if (currentTaskId()) { -
+
} @if (hasTimeEstimate) { @@ -98,14 +103,16 @@ import { NavigateToTaskService } from '../../navigate-to-task/navigate-to-task.s display: contents; } - @keyframes pulse { + /* Finite pulse animation played once per trigger (not infinite). + * An infinite CSS animation forces the compositor to run at 60fps + * continuously, causing ~5-10% CPU and ~30% GPU even when idle. + * Instead, this finite animation is triggered periodically via JS + * (see effect() in the component class). */ + @keyframes pulse-once { 0% { transform: scale(0.7); } - 25% { - transform: scale(1); - } - 50% { + 40% { transform: scale(1); } 100% { @@ -127,10 +134,17 @@ import { NavigateToTaskService } from '../../navigate-to-task/navigate-to-task.s bottom: 0; border-radius: 50%; margin: auto; - transform: scale(1, 1); - animation: pulse 2s infinite; + transform: scale(0.7); background: var(--c-accent); opacity: 0.6; + + /* Promote to own GPU layer so the pulse animation does not + * trigger repaints of the button or icon above it. */ + will-change: transform; + + &.do-pulse { + animation: pulse-once 2s ease-in-out 1; + } } .circle-svg { @@ -227,6 +241,7 @@ export class PlayButtonComponent implements OnInit, OnDestroy { readonly currentTaskContext = input(); readonly hasTrackableTasks = input(true); readonly circleSvg = viewChild>('circleSvg'); + readonly pulseCircle = viewChild>('pulseCircle'); readonly isDisabled = computed( () => !this.currentTaskId() && !this.hasTrackableTasks(), @@ -238,6 +253,46 @@ export class PlayButtonComponent implements OnInit, OnDestroy { private _subs = new Subscription(); private circumference = 10 * 2 * Math.PI; // ~62.83 protected hasTimeEstimate = false; + constructor() { + // Intermittent pulse animation to indicate active time tracking. + // + // Performance note (see https://github.com/super-productivity/super-productivity/issues/6076): + // CSS `animation: infinite` forces the browser compositor to run at 60fps + // continuously, even for a simple scale transform. This caused ~5-10% CPU + // and ~30% GPU usage while tracking. + // + // Fix: use a finite CSS animation (`animation-iteration-count: 1`) triggered + // periodically via lazySetInterval. The compositor goes fully idle between + // pulses. Combined with `will-change: transform` on the pulse element (which + // promotes it to its own GPU layer), the animation does not cause repaints of + // surrounding elements (button, icon). + effect((onCleanup) => { + const el = this.pulseCircle()?.nativeElement; + const isTracking = !!this.currentTaskId(); + + if (el && isTracking) { + // Trigger an initial pulse immediately + el.classList.add('do-pulse'); + + // Remove the class when the animation finishes so it can be re-triggered + const onAnimEnd = (): void => el.classList.remove('do-pulse'); + el.addEventListener('animationend', onAnimEnd); + + // Re-trigger the pulse every 5s (animation itself is 2s, leaving 3s idle) + const stopInterval = lazySetInterval(() => { + el.classList.add('do-pulse'); + }, 5000); + + // Angular calls onCleanup automatically when the effect re-runs or + // the component is destroyed — no manual ngOnDestroy cleanup needed. + onCleanup(() => { + stopInterval(); + el.removeEventListener('animationend', onAnimEnd); + el.classList.remove('do-pulse'); + }); + } + }); + } ngOnInit(): void { // Subscribe to current task to track if it has a time estimate diff --git a/src/app/util/lazy-set-interval.ts b/src/app/util/lazy-set-interval.ts new file mode 100644 index 0000000000..a8ffb4f117 --- /dev/null +++ b/src/app/util/lazy-set-interval.ts @@ -0,0 +1,19 @@ +// Avoids the performance issues caused by normal setInterval when the user +// is not at the computer for some time. Uses chained setTimeout instead. +export const lazySetInterval = ( + func: () => void, + intervalDuration: number, +): (() => void) => { + let lastTimeoutId: ReturnType; + + const interval = (): void => { + lastTimeoutId = setTimeout(interval, intervalDuration); + func.call(null); + }; + + lastTimeoutId = setTimeout(interval, intervalDuration); + + return () => { + clearTimeout(lastTimeoutId); + }; +};