diff --git a/src/app/pages/stopwatch/stopwatch.component.ts b/src/app/pages/stopwatch/stopwatch.component.ts index 40eb517..51b3b57 100644 --- a/src/app/pages/stopwatch/stopwatch.component.ts +++ b/src/app/pages/stopwatch/stopwatch.component.ts @@ -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 {MatButtonModule} from '@angular/material/button'; import {MatCardModule} from '@angular/material/card'; @@ -6,6 +6,7 @@ import {MatFormFieldModule} from '@angular/material/form-field'; import {MatIconModule} from '@angular/material/icon'; import {MatInputModule} from '@angular/material/input'; import {MatCheckboxModule} from '@angular/material/checkbox'; +import {Title} from '@angular/platform-browser'; import {TranslateModule} from '@ngx-translate/core'; type StopwatchState = 'idle' | 'running' | 'paused'; @@ -37,22 +38,33 @@ export class StopwatchComponent implements OnDestroy { readonly display = computed(() => formatElapsed(this.elapsedMs())); - private rafId: number | null = null; + private intervalId: number | null = null; private startTimestamp = 0; private accumulatedMs = 0; private previousElapsedMs = 0; private audioContext: AudioContext | 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 { if (this.state() === 'running') { return; } this.ensureAudioContext(); + this.resumeAudioContext(); this.startTimestamp = performance.now(); this.previousElapsedMs = this.accumulatedMs; this.state.set('running'); - this.tick(); + this.startTicking(); } pause(): void { @@ -86,6 +98,14 @@ export class StopwatchComponent implements OnDestroy { this.audioContext.close().catch(() => undefined); 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 => { @@ -98,17 +118,24 @@ export class StopwatchComponent implements OnDestroy { this.checkIntervalCrossing(this.previousElapsedMs, newElapsed); this.previousElapsedMs = newElapsed; this.elapsedMs.set(newElapsed); - - this.rafId = requestAnimationFrame(this.tick); }; private cancelTick(): void { - if (this.rafId !== null) { - cancelAnimationFrame(this.rafId); - this.rafId = null; + if (this.intervalId !== null) { + clearInterval(this.intervalId); + 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 { if (!this.intervalEnabled()) { 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 { if (!this.audioContext) { return; @@ -181,6 +215,19 @@ function formatElapsed(totalMs: number): string { 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 { return value.toString().padStart(length, '0'); }