Merge branch 'fix/issue-6076'

* fix/issue-6076:
  fix(play-button): replace infinite CSS animation with periodic finite pulses
  fix(build): fix tag resolution and add error handling in bump-android-version
This commit is contained in:
Johannes Millan 2026-03-06 15:44:49 +01:00
commit 0a3444ad49
2 changed files with 82 additions and 8 deletions

View file

@ -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
</div>
}
@if (currentTaskId()) {
<div class="pulse-circle"></div>
<div
#pulseCircle
class="pulse-circle"
></div>
}
@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<WorkContext | null>();
readonly hasTrackableTasks = input<boolean>(true);
readonly circleSvg = viewChild<ElementRef<SVGCircleElement>>('circleSvg');
readonly pulseCircle = viewChild<ElementRef<HTMLElement>>('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

View file

@ -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<typeof setTimeout>;
const interval = (): void => {
lastTimeoutId = setTimeout(interval, intervalDuration);
func.call(null);
};
lastTimeoutId = setTimeout(interval, intervalDuration);
return () => {
clearTimeout(lastTimeoutId);
};
};