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);
+ };
+};