Added timer component
This commit is contained in:
1 parent
b47bce16b5
commit
39b90a0b81
7 files changed
+296
No files matched your search
@@ -0,0 +1,75 @@
|
||||
<mat-card class="w-full max-w-[1920px] p-5">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'STOPWATCH.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="flex flex-col items-center gap-6 mt-4">
|
||||
<div
|
||||
class="font-mono tabular-nums tracking-wider text-[clamp(2.5rem,8vw,5rem)] font-semibold leading-none rounded-2xl px-6 py-3 ring-2 ring-transparent transition-all duration-300 ease-out"
|
||||
[class]="flashing()
|
||||
? 'scale-110 !text-[var(--mat-sys-primary)] !ring-[var(--mat-sys-primary)] shadow-[0_0_40px_var(--mat-sys-primary)]'
|
||||
: ''"
|
||||
>
|
||||
{{ display() }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 justify-center">
|
||||
<button mat-flat-button color="primary" (click)="start()" [disabled]="state() === 'running'">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'STOPWATCH.START' | translate }}
|
||||
</button>
|
||||
<button mat-flat-button color="accent" (click)="pause()" [disabled]="state() !== 'running'">
|
||||
<mat-icon>pause</mat-icon>
|
||||
{{ 'STOPWATCH.PAUSE' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button (click)="stop()" [disabled]="state() === 'idle' && elapsedMs() === 0">
|
||||
<mat-icon>stop</mat-icon>
|
||||
{{ 'STOPWATCH.STOP' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button (click)="reset()" [disabled]="elapsedMs() === 0 && state() === 'idle'">
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
{{ 'STOPWATCH.RESET' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 items-center w-full max-w-[520px]">
|
||||
<mat-checkbox [checked]="intervalEnabled()" (change)="intervalEnabled.set($event.checked)">
|
||||
{{ 'STOPWATCH.INTERVAL_ENABLED' | translate }}
|
||||
</mat-checkbox>
|
||||
|
||||
<div class="flex flex-wrap gap-3 items-center justify-center">
|
||||
<mat-form-field appearance="outline" class="w-[160px]">
|
||||
<mat-label>{{ 'STOPWATCH.INTERVAL_MINUTES' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
[disabled]="!intervalEnabled()"
|
||||
[ngModel]="intervalMinutes()"
|
||||
(ngModelChange)="intervalMinutes.set($event)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-[160px]">
|
||||
<mat-label>{{ 'STOPWATCH.INTERVAL_SECONDS' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
step="1"
|
||||
[disabled]="!intervalEnabled()"
|
||||
[ngModel]="intervalSeconds()"
|
||||
(ngModelChange)="intervalSeconds.set($event)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<p class="m-0 opacity-70 text-sm text-center">
|
||||
{{ 'STOPWATCH.INTERVAL_HINT' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@@ -0,0 +1,186 @@
|
||||
import {Component, computed, OnDestroy, signal} 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 {TranslateModule} from '@ngx-translate/core';
|
||||
|
||||
type StopwatchState = 'idle' | 'running' | 'paused';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stopwatch',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatCheckboxModule,
|
||||
TranslateModule
|
||||
],
|
||||
templateUrl: './stopwatch.component.html'
|
||||
})
|
||||
export class StopwatchComponent implements OnDestroy {
|
||||
readonly state = signal<StopwatchState>('idle');
|
||||
readonly elapsedMs = signal(0);
|
||||
|
||||
readonly intervalEnabled = signal(false);
|
||||
readonly intervalMinutes = signal(2);
|
||||
readonly intervalSeconds = signal(0);
|
||||
|
||||
readonly flashing = signal(false);
|
||||
|
||||
readonly display = computed(() => formatElapsed(this.elapsedMs()));
|
||||
|
||||
private rafId: number | null = null;
|
||||
private startTimestamp = 0;
|
||||
private accumulatedMs = 0;
|
||||
private previousElapsedMs = 0;
|
||||
private audioContext: AudioContext | null = null;
|
||||
private flashTimeoutId: number | null = null;
|
||||
|
||||
start(): void {
|
||||
if (this.state() === 'running') {
|
||||
return;
|
||||
}
|
||||
this.ensureAudioContext();
|
||||
this.startTimestamp = performance.now();
|
||||
this.previousElapsedMs = this.accumulatedMs;
|
||||
this.state.set('running');
|
||||
this.tick();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
this.rafId = requestAnimationFrame(this.tick);
|
||||
};
|
||||
|
||||
private cancelTick(): void {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private checkIntervalCrossing(prevMs: number, currMs: number): void {
|
||||
if (!this.intervalEnabled()) {
|
||||
return;
|
||||
}
|
||||
const intervalMs = this.intervalMinutes() * 60_000 + this.intervalSeconds() * 1_000;
|
||||
if (intervalMs <= 0) {
|
||||
return;
|
||||
}
|
||||
const prevTick = Math.floor(prevMs / intervalMs);
|
||||
const currTick = Math.floor(currMs / intervalMs);
|
||||
if (currTick > prevTick) {
|
||||
this.beep();
|
||||
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 beep(): void {
|
||||
if (!this.audioContext) {
|
||||
return;
|
||||
}
|
||||
const ctx = this.audioContext;
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.value = 880;
|
||||
|
||||
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 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 pad(value: number, length: number): string {
|
||||
return value.toString().padStart(length, '0');
|
||||
}
|
||||
Reference in new issue
Block a user