Added update for tabbar
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m31s
Build, Test & Push Frontend / docker (push) Successful in 58s

This commit is contained in:
Andreas Dahm
2026-05-08 13:31:10 +02:00
parent e20aa11927
commit fb911b897a

View File

@@ -1,4 +1,4 @@
import {Component, computed, OnDestroy, signal} from '@angular/core'; import {Component, computed, effect, inject, OnDestroy, signal} from '@angular/core';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button'; import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card'; import {MatCardModule} from '@angular/material/card';
@@ -6,6 +6,7 @@ import {MatFormFieldModule} from '@angular/material/form-field';
import {MatIconModule} from '@angular/material/icon'; import {MatIconModule} from '@angular/material/icon';
import {MatInputModule} from '@angular/material/input'; import {MatInputModule} from '@angular/material/input';
import {MatCheckboxModule} from '@angular/material/checkbox'; import {MatCheckboxModule} from '@angular/material/checkbox';
import {Title} from '@angular/platform-browser';
import {TranslateModule} from '@ngx-translate/core'; import {TranslateModule} from '@ngx-translate/core';
type StopwatchState = 'idle' | 'running' | 'paused'; type StopwatchState = 'idle' | 'running' | 'paused';
@@ -37,22 +38,33 @@ export class StopwatchComponent implements OnDestroy {
readonly display = computed(() => formatElapsed(this.elapsedMs())); readonly display = computed(() => formatElapsed(this.elapsedMs()));
private rafId: number | null = null; private intervalId: number | null = null;
private startTimestamp = 0; private startTimestamp = 0;
private accumulatedMs = 0; private accumulatedMs = 0;
private previousElapsedMs = 0; private previousElapsedMs = 0;
private audioContext: AudioContext | null = null; private audioContext: AudioContext | null = null;
private flashTimeoutId: number | null = null; private flashTimeoutId: number | null = null;
private readonly titleService = inject(Title);
private readonly originalTitle = this.titleService.getTitle();
constructor() {
effect(() => {
const state = this.state();
const elapsed = this.elapsedMs();
this.updateDocumentTitle(state, elapsed);
});
}
start(): void { start(): void {
if (this.state() === 'running') { if (this.state() === 'running') {
return; return;
} }
this.ensureAudioContext(); this.ensureAudioContext();
this.resumeAudioContext();
this.startTimestamp = performance.now(); this.startTimestamp = performance.now();
this.previousElapsedMs = this.accumulatedMs; this.previousElapsedMs = this.accumulatedMs;
this.state.set('running'); this.state.set('running');
this.tick(); this.startTicking();
} }
pause(): void { pause(): void {
@@ -86,6 +98,14 @@ export class StopwatchComponent implements OnDestroy {
this.audioContext.close().catch(() => undefined); this.audioContext.close().catch(() => undefined);
this.audioContext = null; this.audioContext = null;
} }
this.titleService.setTitle(this.originalTitle);
}
private startTicking(): void {
this.cancelTick();
this.tick();
// setInterval keeps firing in background tabs (throttled to ~1s) so beeps still play when unfocused.
this.intervalId = window.setInterval(this.tick, 50);
} }
private tick = (): void => { private tick = (): void => {
@@ -98,17 +118,24 @@ export class StopwatchComponent implements OnDestroy {
this.checkIntervalCrossing(this.previousElapsedMs, newElapsed); this.checkIntervalCrossing(this.previousElapsedMs, newElapsed);
this.previousElapsedMs = newElapsed; this.previousElapsedMs = newElapsed;
this.elapsedMs.set(newElapsed); this.elapsedMs.set(newElapsed);
this.rafId = requestAnimationFrame(this.tick);
}; };
private cancelTick(): void { private cancelTick(): void {
if (this.rafId !== null) { if (this.intervalId !== null) {
cancelAnimationFrame(this.rafId); clearInterval(this.intervalId);
this.rafId = null; this.intervalId = null;
} }
} }
private updateDocumentTitle(state: StopwatchState, elapsed: number): void {
if (state === 'idle') {
this.titleService.setTitle(this.originalTitle);
return;
}
const prefix = state === 'running' ? '▶' : '⏸';
this.titleService.setTitle(`${prefix} ${formatElapsedShort(elapsed)} · ${this.originalTitle}`);
}
private checkIntervalCrossing(prevMs: number, currMs: number): void { private checkIntervalCrossing(prevMs: number, currMs: number): void {
if (!this.intervalEnabled()) { if (!this.intervalEnabled()) {
return; return;
@@ -146,6 +173,13 @@ export class StopwatchComponent implements OnDestroy {
} }
} }
private resumeAudioContext(): void {
// Browsers suspend AudioContext until a user gesture; resume on start so beeps can play in background tabs.
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume().catch(() => undefined);
}
}
private beep(): void { private beep(): void {
if (!this.audioContext) { if (!this.audioContext) {
return; return;
@@ -181,6 +215,19 @@ function formatElapsed(totalMs: number): string {
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(ms, 3)}`; return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(ms, 3)}`;
} }
function formatElapsedShort(totalMs: number): string {
const totalSeconds = Math.floor(totalMs / 1000);
const seconds = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
if (hours > 0) {
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}`;
}
return `${pad(minutes, 2)}:${pad(seconds, 2)}`;
}
function pad(value: number, length: number): string { function pad(value: number, length: number): string {
return value.toString().padStart(length, '0'); return value.toString().padStart(length, '0');
} }