import {Component, computed, effect, inject, OnDestroy, signal, WritableSignal} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {MatButtonModule} from '@angular/material/button'; import {MatCardModule} from '@angular/material/card'; 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 {TranslatePipe} from '@ngx-translate/core'; type StopwatchState = 'idle' | 'running' | 'paused'; /** Which of the two configured intervals a beep belongs to. */ type IntervalSlot = 'first' | 'second'; const FIRST_INTERVAL_FREQUENCY_HZ = 880; const SECOND_INTERVAL_FREQUENCY_HZ = 587; @Component({ selector: 'app-stopwatch', standalone: true, imports: [ FormsModule, MatButtonModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatCheckboxModule, TranslatePipe ], templateUrl: './stopwatch.component.html' }) export class StopwatchComponent implements OnDestroy { readonly state = signal('idle'); readonly elapsedMs = signal(0); readonly intervalEnabled = signal(false); readonly firstIntervalMinutes = signal(1); readonly firstIntervalSeconds = signal(0); readonly secondIntervalEnabled = signal(false); readonly secondIntervalMinutes = signal(2); readonly secondIntervalSeconds = signal(0); readonly flashing = signal(false); readonly display = computed(() => formatElapsed(this.elapsedMs())); readonly firstIntervalMs = computed( () => toMilliseconds(this.firstIntervalMinutes(), this.firstIntervalSeconds()) ); readonly secondIntervalMs = computed(() => { if (!this.secondIntervalEnabled()) { return 0; } return toMilliseconds(this.secondIntervalMinutes(), this.secondIntervalSeconds()); }); /** Absolute times of the upcoming beeps, used as a preview of the alternating schedule. */ readonly schedulePreview = computed(() => { const beepTimes = collectBeepTimes(this.firstIntervalMs(), this.secondIntervalMs(), 4); return beepTimes.map(formatElapsedShort).join(' · '); }); 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.startTicking(); } pause(): void { if (this.state() !== 'running') { return; } this.accumulatedMs = this.elapsedMs(); this.cancelTick(); this.state.set('paused'); } stop(): void { this.cancelTick(); this.accumulatedMs = 0; this.previousElapsedMs = 0; this.elapsedMs.set(0); this.state.set('idle'); } reset(): void { this.stop(); } /** Number inputs can emit empty strings or negative values, so sanitize before storing. */ updateIntervalValue(target: WritableSignal, value: unknown): void { target.set(toNonNegativeInteger(value)); } ngOnDestroy(): void { this.cancelTick(); if (this.flashTimeoutId !== null) { clearTimeout(this.flashTimeoutId); this.flashTimeoutId = null; } if (this.audioContext) { 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 => { if (this.state() !== 'running') { return; } const now = performance.now(); const newElapsed = this.accumulatedMs + (now - this.startTimestamp); this.checkIntervalCrossing(this.previousElapsedMs, newElapsed); this.previousElapsedMs = newElapsed; this.elapsedMs.set(newElapsed); }; private cancelTick(): void { 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; } const firstMs = this.firstIntervalMs(); const secondMs = this.secondIntervalMs(); const previousBeeps = countBeeps(prevMs, firstMs, secondMs); const currentBeeps = countBeeps(currMs, firstMs, secondMs); if (currentBeeps <= previousBeeps) { return; } const slot = slotOfBeep(currentBeeps, secondMs); this.beep(slot === 'first' ? FIRST_INTERVAL_FREQUENCY_HZ : SECOND_INTERVAL_FREQUENCY_HZ); this.triggerFlash(); } private triggerFlash(): void { if (this.flashTimeoutId !== null) { clearTimeout(this.flashTimeoutId); } this.flashing.set(true); this.flashTimeoutId = window.setTimeout(() => { this.flashing.set(false); this.flashTimeoutId = null; }, 450); } private ensureAudioContext(): void { if (this.audioContext) { return; } const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (Ctor) { this.audioContext = new Ctor(); } } 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(frequencyHz: number): void { if (!this.audioContext) { return; } const ctx = this.audioContext; const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); oscillator.type = 'sine'; oscillator.frequency.value = frequencyHz; const now = ctx.currentTime; const duration = 0.18; gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(0.25, now + 0.01); gain.gain.linearRampToValueAtTime(0, now + duration); oscillator.connect(gain); gain.connect(ctx.destination); oscillator.start(now); oscillator.stop(now + duration); } } function toMilliseconds(minutes: number, seconds: number): number { return minutes * 60_000 + seconds * 1_000; } function toNonNegativeInteger(value: unknown): number { const parsed = Math.floor(Number(value)); if (!Number.isFinite(parsed) || parsed < 0) { return 0; } return parsed; } /** * The two intervals alternate: the first beep lands after `firstMs`, the next one * `secondMs` later, then `firstMs` again. With first = 1min and second = 2min the * beeps therefore fall on 1, 3, 4, 6, 7 ... minutes. */ export function countBeeps(elapsedMs: number, firstMs: number, secondMs: number): number { if (firstMs <= 0) { return 0; } if (secondMs <= 0) { return Math.floor(elapsedMs / firstMs); } const cycleMs = firstMs + secondMs; const completedCycles = Math.floor(elapsedMs / cycleMs); const remainderMs = elapsedMs % cycleMs; const firstBeepOfCycleReached = remainderMs >= firstMs ? 1 : 0; return completedCycles * 2 + firstBeepOfCycleReached; } /** Beeps alternate, so odd beep numbers end the first interval and even ones the second. */ export function slotOfBeep(beepNumber: number, secondMs: number): IntervalSlot { if (secondMs <= 0) { return 'first'; } return beepNumber % 2 === 1 ? 'first' : 'second'; } export function collectBeepTimes(firstMs: number, secondMs: number, count: number): number[] { if (firstMs <= 0) { return []; } const times: number[] = []; let current = 0; for (let beepNumber = 1; beepNumber <= count; beepNumber++) { const isFirstSlot = slotOfBeep(beepNumber, secondMs) === 'first'; current += isFirstSlot ? firstMs : secondMs; times.push(current); } return times; } function formatElapsed(totalMs: number): string { const ms = Math.floor(totalMs % 1000); 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); 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'); }