diff --git a/client/src/app/components/rdio-scanner/admin/admin.component.ts b/client/src/app/components/rdio-scanner/admin/admin.component.ts index 21940f8f..6d432a33 100644 --- a/client/src/app/components/rdio-scanner/admin/admin.component.ts +++ b/client/src/app/components/rdio-scanner/admin/admin.component.ts @@ -25,6 +25,8 @@ import { AdminEvent, Config, RdioScannerAdminService } from './admin.service'; import { RdioScannerAdminLogsComponent } from './logs/logs.component'; import { RdioScannerAdminConfigComponent } from './config/config.component'; import { RdioScannerAdminToolsComponent } from './tools/tools.component'; +import { TransferUserDialogComponent } from './config/users/transfer-user-dialog.component'; +import { DeleteSystemDialogComponent } from './config/systems/delete-system-dialog.component'; export interface SearchResult { label: string; diff --git a/client/src/app/components/rdio-scanner/admin/admin.module.ts b/client/src/app/components/rdio-scanner/admin/admin.module.ts index b241b844..9d7d5467 100644 --- a/client/src/app/components/rdio-scanner/admin/admin.module.ts +++ b/client/src/app/components/rdio-scanner/admin/admin.module.ts @@ -47,6 +47,7 @@ import { RdioScannerAdminUserRegistrationComponent } from './config/user-registr import { RdioScannerAdminUsersComponent } from './config/users/users.component'; import { InviteUserDialogComponent, InvitationResultsDialogComponent, CreateUserDialogComponent, ResetPasswordDialogComponent } from './config/users/invite-user-dialog.component'; import { TransferUserDialogComponent } from './config/users/transfer-user-dialog.component'; +import { DeleteSystemDialogComponent } from './config/systems/delete-system-dialog.component'; import { RequestAPIKeyDialogComponent } from './config/options/request-api-key-dialog.component'; import { RecoverAPIKeyDialogComponent } from './config/options/recover-api-key-dialog.component'; import { RelayAccountDialogComponent } from './config/options/relay-account-dialog.component'; @@ -105,6 +106,7 @@ import { TalkgroupLocationDialogComponent } from './config/systems/system/talkgr CreateUserDialogComponent, ResetPasswordDialogComponent, TransferUserDialogComponent, + DeleteSystemDialogComponent, RequestAPIKeyDialogComponent, RecoverAPIKeyDialogComponent, RelayAccountDialogComponent, diff --git a/client/src/app/components/rdio-scanner/admin/config/systems/delete-system-dialog.component.ts b/client/src/app/components/rdio-scanner/admin/config/systems/delete-system-dialog.component.ts new file mode 100644 index 00000000..c9eeef7d --- /dev/null +++ b/client/src/app/components/rdio-scanner/admin/config/systems/delete-system-dialog.component.ts @@ -0,0 +1,85 @@ +/* + * ***************************************************************************** + * Copyright (C) 2025 Thinline Dynamic Solutions + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +import { Component, Inject } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { FormBuilder, FormGroup } from '@angular/forms'; + +export interface DeleteSystemDialogData { + /** The system's display name/label the operator must type back exactly. */ + systemLabel: string; +} + +@Component({ + selector: 'rdio-scanner-delete-system-dialog', + template: ` +

Delete System

+ +

+ This permanently deletes {{ data.systemLabel }} — its talkgroups, sites, + units, tone sets, and all associated calls. This cannot be undone. +

+

+ Type {{ data.systemLabel }} below to confirm: +

+
+ + System name + + +
+
+ + + + + `, +}) +export class DeleteSystemDialogComponent { + confirmForm: FormGroup; + + constructor( + private dialogRef: MatDialogRef, + private formBuilder: FormBuilder, + @Inject(MAT_DIALOG_DATA) public data: DeleteSystemDialogData, + ) { + this.confirmForm = this.formBuilder.group({ confirmText: [''] }); + } + + /** Exact, case-sensitive match — same behavior as GitHub's "type to confirm". */ + matches(): boolean { + const typed = this.confirmForm.get('confirmText')?.value ?? ''; + return typed === this.data.systemLabel; + } + + onConfirm(): void { + if (this.matches()) { + this.dialogRef.close(true); + } + } + + onCancel(): void { + this.dialogRef.close(false); + } +} diff --git a/client/src/app/components/rdio-scanner/admin/config/systems/system/system.component.html b/client/src/app/components/rdio-scanner/admin/config/systems/system/system.component.html index 85698dc7..09ababb1 100644 --- a/client/src/app/components/rdio-scanner/admin/config/systems/system/system.component.html +++ b/client/src/app/components/rdio-scanner/admin/config/systems/system/system.component.html @@ -42,7 +42,7 @@

{{ $any(form.value).label?.trim() || 'New System' }}

{{ saveSuccess ? 'check_circle' : 'save' }} {{ saving ? 'Saving…' : (saveSuccess ? 'Saved!' : 'Save System') }} - diff --git a/client/src/app/components/rdio-scanner/rdio-scanner.module.ts b/client/src/app/components/rdio-scanner/rdio-scanner.module.ts index 69ec0b39..967a94e5 100644 --- a/client/src/app/components/rdio-scanner/rdio-scanner.module.ts +++ b/client/src/app/components/rdio-scanner/rdio-scanner.module.ts @@ -61,6 +61,7 @@ import { RdioScannerWeatherWidgetComponent } from './weather/weather-widget.comp import { RdioScannerWeatherAlertTickerComponent } from './weather/weather-alert-ticker.component'; import { WeatherAlertTickerBridgeService } from './weather/weather-alert-ticker-bridge.service'; import { NwsService } from './weather/nws.service'; +import { WeatherAlertTtsService } from './weather/weather-alert-tts.service'; @NgModule({ declarations: [ @@ -116,6 +117,7 @@ import { NwsService } from './weather/nws.service'; RdioScannerAdminService, TranscriptReviewService, NwsService, + WeatherAlertTtsService, { provide: OverlayContainer, useClass: FullscreenOverlayContainer }, ], }) diff --git a/client/src/app/components/rdio-scanner/settings/settings.component.html b/client/src/app/components/rdio-scanner/settings/settings.component.html index 915264dc..f2707ff4 100644 --- a/client/src/app/components/rdio-scanner/settings/settings.component.html +++ b/client/src/app/components/rdio-scanner/settings/settings.component.html @@ -91,6 +91,27 @@

Severe Weather Alert Sound

+
+ + Read severe weather alerts aloud (text-to-speech) + +
+

+ Text-to-speech isn't supported in this browser. +

+
+ +
+
Weather Alert Sound diff --git a/client/src/app/components/rdio-scanner/settings/settings.component.ts b/client/src/app/components/rdio-scanner/settings/settings.component.ts index 5907efcb..d2e57606 100644 --- a/client/src/app/components/rdio-scanner/settings/settings.component.ts +++ b/client/src/app/components/rdio-scanner/settings/settings.component.ts @@ -27,6 +27,7 @@ import { SettingsService } from './settings.service'; import { TagColorService, TagColorConfig } from '../tag-color.service'; import { AlertSoundService, AlertSound } from '../alert-sound.service'; import { WeatherAlertTickerBridgeService } from '../weather/weather-alert-ticker-bridge.service'; +import { WeatherAlertTtsService } from '../weather/weather-alert-tts.service'; import { AlertsService } from '../alerts/alerts.service'; import { RdioScannerAlertPreference } from '../rdio-scanner'; import { APP_FONTS } from '../app-font.util'; @@ -50,6 +51,8 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit { alertSound: string = 'alert'; weatherAlertSoundEnabled = false; weatherAlertSound: string = 'alert'; + weatherAlertTtsEnabled = false; + ttsSupported = true; availableAlertSounds: AlertSound[] = []; // Font selection @@ -107,7 +110,9 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit { private fb: FormBuilder, private snackBar: MatSnackBar, private weatherAlertTickerBridge: WeatherAlertTickerBridgeService, + private weatherAlertTtsService: WeatherAlertTtsService, ) { + this.ttsSupported = this.weatherAlertTtsService.isSupported(); this.emailForm = this.fb.group({ newEmail: ['', [Validators.required, Validators.email]], password: ['', [Validators.required]], @@ -421,6 +426,7 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit { this.alertSound = this.settings.alertSound || 'alert'; this.weatherAlertSoundEnabled = !!this.settings.weatherAlertSoundEnabled; this.weatherAlertSound = this.settings.weatherAlertSound || 'alert'; + this.weatherAlertTtsEnabled = !!this.settings.weatherAlertTtsEnabled; // Load font setting this.appFont = this.settings.appFont || 'Roboto'; this.appFontService.apply(this.appFont); @@ -433,6 +439,7 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit { this.alertSound = 'alert'; this.weatherAlertSoundEnabled = false; this.weatherAlertSound = 'alert'; + this.weatherAlertTtsEnabled = false; this.appFont = 'Roboto'; }, }); @@ -502,6 +509,7 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit { this.settings.alertSound = this.alertSound; this.settings.weatherAlertSoundEnabled = this.weatherAlertSoundEnabled; this.settings.weatherAlertSound = this.weatherAlertSound; + this.settings.weatherAlertTtsEnabled = this.weatherAlertTtsEnabled; this.settings.appFont = this.appFont; this.settingsService.saveSettings(this.settings).subscribe({ next: () => { @@ -538,14 +546,25 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit { this.saveSettings(); } + onWeatherAlertTtsChange(): void { + this.saveSettings(); + } + previewAlertSound(soundName: string): void { this.alertSoundService.previewSound(soundName); } + previewWeatherAlertTts(): void { + this.weatherAlertTtsService.speak( + 'Severe Thunderstorm Warning for your area. This is a sample of the weather alert reader.', + ); + } + testWeatherAlertTicker(): void { const played = this.weatherAlertTickerBridge.triggerTest( this.weatherAlertSoundEnabled, this.weatherAlertSound, + this.weatherAlertTtsEnabled, ); if (!played) { this.snackBar.open('Could not reach the header ticker. Try refreshing the page.', 'Close', { diff --git a/client/src/app/components/rdio-scanner/weather/weather-alert-ticker-bridge.service.ts b/client/src/app/components/rdio-scanner/weather/weather-alert-ticker-bridge.service.ts index 76c72268..375a904a 100644 --- a/client/src/app/components/rdio-scanner/weather/weather-alert-ticker-bridge.service.ts +++ b/client/src/app/components/rdio-scanner/weather/weather-alert-ticker-bridge.service.ts @@ -20,11 +20,11 @@ export class WeatherAlertTickerBridgeService { } } - triggerTest(playSound: boolean, soundName?: string): boolean { + triggerTest(playSound: boolean, soundName?: string, speakTts?: boolean): boolean { if (!this.ticker) { return false; } - this.ticker.showTestAlert(playSound, soundName); + this.ticker.showTestAlert(playSound, soundName, speakTts); return true; } } diff --git a/client/src/app/components/rdio-scanner/weather/weather-alert-ticker.component.ts b/client/src/app/components/rdio-scanner/weather/weather-alert-ticker.component.ts index 89f0dd3b..3d16f091 100644 --- a/client/src/app/components/rdio-scanner/weather/weather-alert-ticker.component.ts +++ b/client/src/app/components/rdio-scanner/weather/weather-alert-ticker.component.ts @@ -14,6 +14,7 @@ import { AlertSoundService } from '../alert-sound.service'; import { SettingsService } from '../settings/settings.service'; import { NwsSevereAlert, NwsService } from './nws.service'; import { WeatherAlertTickerBridgeService } from './weather-alert-ticker-bridge.service'; +import { WeatherAlertTtsService } from './weather-alert-tts.service'; const TEST_ALERT_DURATION_MS = 25_000; @@ -41,6 +42,7 @@ export class RdioScannerWeatherAlertTickerComponent implements OnInit, OnDestroy private settingsService: SettingsService, private alertSoundService: AlertSoundService, private tickerBridge: WeatherAlertTickerBridgeService, + private weatherAlertTtsService: WeatherAlertTtsService, private cdr: ChangeDetectorRef, ) {} @@ -82,7 +84,7 @@ export class RdioScannerWeatherAlertTickerComponent implements OnInit, OnDestroy } } - showTestAlert(playSound: boolean, soundOverride?: string): void { + showTestAlert(playSound: boolean, soundOverride?: string, speakTts?: boolean): void { if (this.testClearTimer) { clearTimeout(this.testClearTimer); } @@ -103,6 +105,9 @@ export class RdioScannerWeatherAlertTickerComponent implements OnInit, OnDestroy if (playSound) { this.playConfiguredSound(soundOverride); } + if (speakTts) { + this.weatherAlertTtsService.speakAlerts(testAlerts); + } this.testClearTimer = setTimeout(() => this.clearTestAlert(), TEST_ALERT_DURATION_MS); } @@ -145,8 +150,10 @@ export class RdioScannerWeatherAlertTickerComponent implements OnInit, OnDestroy } const newIds = alerts.map((a) => a.id).filter(Boolean); const hasNew = newIds.some((id) => !this.knownAlertIds.has(id)); + const newAlerts = alerts.filter((a) => a.id && !this.knownAlertIds.has(a.id)); if (hasNew && this.knownAlertIds.size > 0) { this.maybePlaySound(); + this.maybeSpeakAlerts(newAlerts); } if (hasNew && alerts.length > 0) { this.flashing = true; @@ -179,6 +186,14 @@ export class RdioScannerWeatherAlertTickerComponent implements OnInit, OnDestroy this.playConfiguredSound(); } + private maybeSpeakAlerts(alerts: NwsSevereAlert[]): void { + const enabled = !!this.settingsSnapshot['weatherAlertTtsEnabled']; + if (!enabled || !alerts.length) { + return; + } + this.weatherAlertTtsService.speakAlerts(alerts); + } + private playConfiguredSound(soundOverride?: string): void { const sound = soundOverride || (typeof this.settingsSnapshot['weatherAlertSound'] === 'string' diff --git a/client/src/app/components/rdio-scanner/weather/weather-alert-tts.service.ts b/client/src/app/components/rdio-scanner/weather/weather-alert-tts.service.ts new file mode 100644 index 00000000..1b3d018e --- /dev/null +++ b/client/src/app/components/rdio-scanner/weather/weather-alert-tts.service.ts @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2025 Thinline Dynamic Solutions + */ + +import { Injectable } from '@angular/core'; +import { NwsSevereAlert } from './nws.service'; + +/** + * Reads severe weather alerts aloud using the browser's built-in + * text-to-speech engine (Web Speech API). No server/API key required. + */ +@Injectable({ + providedIn: 'root', +}) +export class WeatherAlertTtsService { + private queue: string[] = []; + private speaking = false; + + /** Whether the current browser supports speech synthesis at all. */ + isSupported(): boolean { + return typeof window !== 'undefined' && 'speechSynthesis' in window; + } + + /** Speak one or more severe alerts, queuing them so they don't overlap. */ + speakAlerts(alerts: NwsSevereAlert[]): void { + if (!alerts.length) { + return; + } + for (const alert of alerts) { + this.speak(this.buildSpeechText(alert)); + } + } + + /** Speak a single line of text (used for the "Test TTS" preview button). */ + speak(text: string): void { + if (!this.isSupported() || !text) { + return; + } + this.queue.push(text); + this.pumpQueue(); + } + + /** Stop anything currently being read and clear the queue. */ + stop(): void { + this.queue = []; + this.speaking = false; + if (this.isSupported()) { + window.speechSynthesis.cancel(); + } + } + + private buildSpeechText(alert: NwsSevereAlert): string { + const parts = [alert.event || 'Weather Alert']; + if (alert.area) { + parts.push(`for ${alert.area}`); + } + if (alert.headline) { + parts.push(alert.headline); + } + return parts.join('. '); + } + + private pumpQueue(): void { + if (this.speaking || !this.queue.length || !this.isSupported()) { + return; + } + const text = this.queue.shift(); + if (!text) { + return; + } + this.speaking = true; + const utterance = new SpeechSynthesisUtterance(text); + utterance.rate = 1; + utterance.pitch = 1; + utterance.volume = 1; + const finish = () => { + this.speaking = false; + this.pumpQueue(); + }; + utterance.onend = finish; + utterance.onerror = finish; + window.speechSynthesis.speak(utterance); + } +}