From 64bb44c38b808fb2234c6f55e724bde3ddeed114 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 1 Oct 2026 14:41:09 +1000 Subject: [PATCH 1/2] fix(control): tab defaults, voice lifecycle and recording controls - Default the active tab to the driver's selected tab, then the first tab, so the panel no longer opens with no tab selected. - Read the system id as a signal in the tab outlet so links and tab sync follow system changes. - Use one route matcher for `tabbed/:system` with an optional tab, so a tab change reuses the view. Clear `?join=true` after the meeting list opens, so it does not open again. - Voice assistant: flag browsers without speech recognition, abort recognition and detach handlers on teardown, disable it when the view is destroyed, and close the previous room's chat on a room change. - Fix advanced view paging: no empty page when outputs fill the last page, and move to the last page when outputs shrink. - Add a centre dead zone to the joystick so a tap does not pan right. - Send recording commands to the bound capture module. - Show the selected source and lighting state with the selected style. - Show an error when a video call command fails, and type the call bindings so the page no longer needs casts. --- .../src/app/advanced-view.component.ts | 26 ++++--- apps/control/src/app/status-bar.component.ts | 4 +- .../app/tabbed-view/tab-outlet.component.ts | 43 ++++++++---- .../src/app/tabbed-view/tabbed-route.ts | 19 +++++ .../app/tabbed-view/tabbed-view.component.ts | 13 +++- apps/control/src/app/ui/joystick.component.ts | 36 ++++++---- .../src/app/ui/lighting-tooltip.component.ts | 2 +- .../src/app/ui/source-select.component.ts | 2 +- .../src/app/ui/voice-assistant.component.ts | 4 ++ .../src/app/ui/voice-assistant.service.ts | 30 +++++++- .../video-call/video-call-page.component.ts | 19 ++--- .../video-call/video-call-state.service.ts | 70 ++++++++----------- apps/control/src/main.ts | 20 +++--- .../src/tests/advanced-view.component.spec.ts | 22 ++++++ .../src/tests/status-bar.component.spec.ts | 11 ++- .../tabbed-view/tab-outlet.component.spec.ts | 40 ++++++++++- .../tests/tabbed-view/tabbed-route.spec.ts | 25 +++++++ .../tabbed-view/tabbed-view.component.spec.ts | 11 +++ .../src/tests/ui/joystick.component.spec.ts | 11 +++ .../ui/lighting-tooltip.component.spec.ts | 7 +- .../tests/ui/source-select.component.spec.ts | 6 +- .../ui/voice-assistant.component.spec.ts | 6 ++ .../tests/ui/voice-assistant.service.spec.ts | 23 +++++- .../video-call-state.service.spec.ts | 7 ++ shared/assets/locale/ar.json | 1 + shared/assets/locale/en-AU.json | 1 + shared/assets/locale/en-GB.json | 1 + shared/assets/locale/en-US.json | 1 + shared/assets/locale/es.json | 1 + shared/assets/locale/fr-CA.json | 1 + shared/assets/locale/fr.json | 1 + shared/assets/locale/it.json | 1 + shared/assets/locale/ja-JP.json | 1 + shared/assets/locale/pt.json | 1 + shared/assets/locale/zh.json | 1 + 35 files changed, 355 insertions(+), 113 deletions(-) create mode 100644 apps/control/src/app/tabbed-view/tabbed-route.ts create mode 100644 apps/control/src/tests/tabbed-view/tabbed-route.spec.ts diff --git a/apps/control/src/app/advanced-view.component.ts b/apps/control/src/app/advanced-view.component.ts index 4dedb5a886a..7c8fd1bef21 100644 --- a/apps/control/src/app/advanced-view.component.ts +++ b/apps/control/src/app/advanced-view.component.ts @@ -1,9 +1,11 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, computed, inject, linkedSignal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { TranslatePipe } from '@placeos/components'; import { ControlStateService } from './control-state.service'; import { OutputDisplayComponent } from './ui/output-display.component'; +const PAGE_SIZE = 6; + @Component({ selector: 'control-advanced-view', template: ` @@ -63,18 +65,24 @@ import { OutputDisplayComponent } from './ui/output-display.component'; export class ControlAdvancedViewComponent { private _state = inject(ControlStateService); - public readonly page = signal(0); - public readonly outputs = this._state.output_list; + private readonly _page_total = computed(() => + Math.max(1, Math.ceil((this.outputs()?.length || 0) / PAGE_SIZE)), + ); + /** Selected page. Moves to the last page when the output list shrinks. */ + public readonly page = linkedSignal({ + source: this._page_total, + computation: (total, previous) => + Math.min(previous?.value ?? 0, total - 1), + }); + public readonly paged_outputs = computed(() => { - const all = this.outputs(); const p = this.page(); - return all.slice(p * 6, (p + 1) * 6); + return this.outputs().slice(p * PAGE_SIZE, (p + 1) * PAGE_SIZE); }); - public readonly page_count = computed(() => { - const len = this.outputs()?.length || 0; - return new Array(Math.floor(len / 6) + 1).fill(0); - }); + public readonly page_count = computed(() => + new Array(this._page_total()).fill(0), + ); } diff --git a/apps/control/src/app/status-bar.component.ts b/apps/control/src/app/status-bar.component.ts index 065981525f5..947e00b896e 100644 --- a/apps/control/src/app/status-bar.component.ts +++ b/apps/control/src/app/status-bar.component.ts @@ -62,7 +62,7 @@ import { DurationPipe } from './ui/duration.pipe'; [disabled]="!rec_status || rec_status === 'stopped'" binding [sys]="id" - mod="Capture" + [mod]="capture_mod()?.mod" onEvent="click" exec="stop" > @@ -79,7 +79,7 @@ import { DurationPipe } from './ui/duration.pipe'; class="rounded-none" binding [sys]="id" - mod="Capture" + [mod]="capture_mod()?.mod" onEvent="click" [exec]="rec_status === 'playing' ? 'pause' : 'start'" > diff --git a/apps/control/src/app/tabbed-view/tab-outlet.component.ts b/apps/control/src/app/tabbed-view/tab-outlet.component.ts index 5aa29e9a86b..9f14bae9b02 100644 --- a/apps/control/src/app/tabbed-view/tab-outlet.component.ts +++ b/apps/control/src/app/tabbed-view/tab-outlet.component.ts @@ -24,14 +24,14 @@ import { TVControlsComponent } from './tv-controls.component'; template: ` @@ -60,7 +70,7 @@ import { TVControlsComponent } from './tv-controls.component'; }
@if (join_code) { @@ -255,8 +265,7 @@ export class TabOutletComponent extends AsyncHandler { private _router = inject(Router); public hearing_tloop = false; - public readonly id = this._service.id; - public readonly active_tab = signal(''); + public readonly id = this._service.system_id; public readonly hide_present_all = this._service.hide_present_all; public readonly outputs = this._service.output_list; /** Whether any visible output has a source routed to it */ @@ -283,6 +292,17 @@ export class TabOutletComponent extends AsyncHandler { ); }); + /** Route tab, then the driver's selected tab, then the first tab */ + public readonly active_tab = computed(() => { + const first = this.tabs()[0]; + return ( + this._route_tab() || + this._selected_tab() || + first?.id || + first?.name || + '' + ); + }); private _user_action = signal(false); /** Driver's selected tab. A separate computed so other system changes do not re-run the tab sync. */ private _selected_tab = computed(() => this.system()?.selected_tab); @@ -305,19 +325,14 @@ export class TabOutletComponent extends AsyncHandler { constructor() { super(); - effect(() => { - const tab = this._route_tab(); - if (tab) this.active_tab.set(tab); - }); effect(() => { const selected_tab = this._selected_tab(); this.timeout( 'update_tab', () => { if (selected_tab) { - this.active_tab.set(selected_tab); this._router.navigate( - ['/tabbed', this.id, selected_tab], + ['/tabbed', this.id(), selected_tab], { queryParamsHandling: 'merge', }, @@ -331,7 +346,7 @@ export class TabOutletComponent extends AsyncHandler { const available_inputs = this._available_inputs(); const tabs = this.tabs(); const selected_input = this.system()?.selected_input; - const active_tab = this._route_tab(); + const active_tab = this.active_tab(); const user_action = this._user_action(); this.timeout( 'inputs', diff --git a/apps/control/src/app/tabbed-view/tabbed-route.ts b/apps/control/src/app/tabbed-view/tabbed-route.ts new file mode 100644 index 00000000000..60506cb0b25 --- /dev/null +++ b/apps/control/src/app/tabbed-view/tabbed-route.ts @@ -0,0 +1,19 @@ +import { UrlMatchResult, UrlSegment } from '@angular/router'; + +/** + * Matches `tabbed/:system` with an optional `/:tab`. + * One route config for both forms lets the router reuse the tabbed view + * when the tab changes, instead of building it again. + */ +export function tabbedRouteMatcher( + segments: UrlSegment[], +): UrlMatchResult | null { + const [root, system, tab] = segments; + if (root?.path !== 'tabbed' || !system || segments.length > 3) { + return null; + } + return { + consumed: segments, + posParams: tab ? { system, tab } : { system }, + }; +} diff --git a/apps/control/src/app/tabbed-view/tabbed-view.component.ts b/apps/control/src/app/tabbed-view/tabbed-view.component.ts index d1f5571899f..f5a0bc24756 100644 --- a/apps/control/src/app/tabbed-view/tabbed-view.component.ts +++ b/apps/control/src/app/tabbed-view/tabbed-view.component.ts @@ -1,6 +1,6 @@ import { Component, computed, effect, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { SettingsService, VERSION } from '@placeos/common'; import { @@ -128,6 +128,7 @@ import { TabOutletComponent } from './tab-outlet.component'; }) export class ControlTabbedViewComponent { private _route = inject(ActivatedRoute); + private _router = inject(Router); private _state = inject(ControlStateService); private _changelog = inject(ChangelogService); private _settings = inject(SettingsService); @@ -166,7 +167,15 @@ export class ControlTabbedViewComponent { effect(() => { const params = this._query_param_map(); - if (params.get('join') === 'true') this._state.selectMeeting(); + if (params.get('join') !== 'true') return; + this._state.selectMeeting(); + // Open the meeting list once. Tab links merge query params. + this._router.navigate([], { + relativeTo: this._route, + queryParams: { join: null }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); }); } } diff --git a/apps/control/src/app/ui/joystick.component.ts b/apps/control/src/app/ui/joystick.component.ts index 6b822224b72..046dc6ccdf0 100644 --- a/apps/control/src/app/ui/joystick.component.ts +++ b/apps/control/src/app/ui/joystick.component.ts @@ -10,6 +10,9 @@ import { } from '@angular/core'; import { IconComponent } from '@placeos/components'; +/** Distance from the centre, as a fraction of the radius, that does not move the camera */ +const DEAD_ZONE = 0.25; + export enum JoystickTilt { Down = 'down', Up = 'up', @@ -120,17 +123,24 @@ export class JoystickComponent implements OnDestroy { public handlePan(event: PointerEvent) { if (!this._box) return; - const point = { x: event.clientX, y: event.clientY }; - const box_point = { - y: this._box.top + this._box.height / 2, - x: this._box.left + this._box.width / 2, - }; - const angle = - (Math.atan2(point.y - box_point.y, point.x - box_point.x) * 180) / - Math.PI; - const { tilt: tiltInput, pan: panInput } = this; - const tilt = tiltInput(); - const pan = panInput(); + const dx = event.clientX - (this._box.left + this._box.width / 2); + const dy = event.clientY - (this._box.top + this._box.height / 2); + const tilt = this.tilt(); + const pan = this.pan(); + if (Math.hypot(dx, dy) < (this._box.width / 2) * DEAD_ZONE) { + this.tilt.set(JoystickTilt.Stop); + this.pan.set(JoystickPan.Stop); + } else { + this._setDirection((Math.atan2(dy, dx) * 180) / Math.PI); + } + const tiltValue = this.tilt(); + if (tilt !== tiltValue) this.tiltChange.emit(tiltValue); + const panValue = this.pan(); + if (pan !== panValue) this.panChange.emit(panValue); + } + + /** Set pan and tilt from an angle in degrees, where 0 is right and 90 is down */ + private _setDirection(angle: number) { this.tilt.set( angle >= 150 || angle <= -150 || (angle > -30 && angle < 30) ? JoystickTilt.Stop @@ -145,10 +155,6 @@ export class JoystickComponent implements OnDestroy { ? JoystickPan.Left : JoystickPan.Right, ); - const tiltValue = this.tilt(); - if (tilt !== tiltValue) this.tiltChange.emit(tiltValue); - const panValue = this.pan(); - if (pan !== panValue) this.panChange.emit(panValue); } /** Never leave a camera moving when the joystick is removed mid-gesture */ diff --git a/apps/control/src/app/ui/lighting-tooltip.component.ts b/apps/control/src/app/ui/lighting-tooltip.component.ts index 0652efa4557..f2e29741233 100644 --- a/apps/control/src/app/ui/lighting-tooltip.component.ts +++ b/apps/control/src/app/ui/lighting-tooltip.component.ts @@ -38,7 +38,7 @@ import { btn matRipple class="w-64" - [class.inverse]="state === light.state" + [class.inverse]="state !== light.state" binding onEvent="click" [sys]="id" diff --git a/apps/control/src/app/ui/source-select.component.ts b/apps/control/src/app/ui/source-select.component.ts index b2cf4fef612..10e75085666 100644 --- a/apps/control/src/app/ui/source-select.component.ts +++ b/apps/control/src/app/ui/source-select.component.ts @@ -49,7 +49,7 @@ import { ControlStateService, RoomInput } from '../control-state.service'; source class="w-48" [class.inverse]=" - input.id === details()?.source + input.id !== details()?.source " (click)="selectSource(input)" > diff --git a/apps/control/src/app/ui/voice-assistant.component.ts b/apps/control/src/app/ui/voice-assistant.component.ts index 569e6902fca..d9f3edde3ad 100644 --- a/apps/control/src/app/ui/voice-assistant.component.ts +++ b/apps/control/src/app/ui/voice-assistant.component.ts @@ -1,5 +1,6 @@ import { Component, + DestroyRef, ElementRef, computed, effect, @@ -147,6 +148,9 @@ export class VoiceAssistantComponent { if (typeof enabled === 'boolean') this._service.setEnabled(enabled); }); + // Stop listening when the panel leaves the room view + inject(DestroyRef).onDestroy(() => this._service.setEnabled(false)); + // Write bar heights directly each frame to skip change detection effect((onCleanup) => { const bar_els = this._bar_els(); diff --git a/apps/control/src/app/ui/voice-assistant.service.ts b/apps/control/src/app/ui/voice-assistant.service.ts index 428009a5ded..5bc87b776e8 100644 --- a/apps/control/src/app/ui/voice-assistant.service.ts +++ b/apps/control/src/app/ui/voice-assistant.service.ts @@ -49,6 +49,7 @@ interface SpeechRecognitionInstance { onend: (() => void) | null; start(): void; stop(): void; + abort(): void; } type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance; type SpeechWindow = Window & { @@ -104,9 +105,14 @@ export class VoiceAssistantService extends AsyncHandler { constructor() { super(); + let bound_id = ''; effect(() => { const id = this._system_id(); - if (id) this._chat_service.setBinding(id); + if (!id || id === bound_id) return; + // Drop the chat for the previous room so commands go to this one + if (bound_id) this._chat_service.close(); + bound_id = id; + this._chat_service.setBinding(id); }); effect(() => { const user_id = currentUser()?.id; @@ -135,6 +141,7 @@ export class VoiceAssistantService extends AsyncHandler { } protected override destroy() { + this._teardownVoiceRecognition(); this._mic_levels.close(); super.destroy(); } @@ -186,7 +193,20 @@ export class VoiceAssistantService extends AsyncHandler { const SpeechRecognition = speech_window.SpeechRecognition || speech_window.webkitSpeechRecognition; - if (!SpeechRecognition || this._user_speech) return; + if (this._user_speech) return; + if (!SpeechRecognition) { + log( + 'VOICE', + 'Speech recognition is unavailable.', + undefined, + 'warn', + ); + this._error.update((error) => ({ + ...error, + speech_recognition: true, + })); + return; + } log('VOICE', 'Initialising speech recognition.'); // Load the voice list early. Some browsers load it asynchronously. window.speechSynthesis?.getVoices(); @@ -261,8 +281,12 @@ export class VoiceAssistantService extends AsyncHandler { this._setIdle(); const speech = this._user_speech; if (!speech) return; + // Detach handlers first. `abort` drops any pending result, so no + // command is sent after voice control is turned off. + speech.onresult = null; + speech.onerror = null; speech.onend = null; - speech.stop(); + speech.abort(); this._user_speech = undefined; } diff --git a/apps/control/src/app/video-call/video-call-page.component.ts b/apps/control/src/app/video-call/video-call-page.component.ts index 45dab68f0dc..50ab5e5eb82 100644 --- a/apps/control/src/app/video-call/video-call-page.component.ts +++ b/apps/control/src/app/video-call/video-call-page.component.ts @@ -25,7 +25,11 @@ import { IconComponent, TranslatePipe } from '@placeos/components'; import { getModule } from '@placeos/ts-client'; import { ControlStateService } from '../control-state.service'; import { DialpadComponent } from '../ui/dialpad.component'; -import { VideoCallStateService, VideoLayout } from './video-call-state.service'; +import { + PresentationMode, + VideoCallStateService, + VideoLayout, +} from './video-call-state.service'; @Component({ selector: '[video-call-page]', @@ -252,12 +256,8 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit { public readonly show_camera_pip = computed(() => !!this._show_camera_pip()); private readonly _mic_mute = this._state.mic_mute; public readonly mic_mute = computed(() => !!this._mic_mute()); - public readonly video_layout = computed( - () => this._state.video_layout() as never, - ); - public readonly presentation_mode = computed< - 'None' | 'Local' | 'Remote' | null - >(() => this._state.presentation_mode() as never); + public readonly video_layout = this._state.video_layout; + public readonly presentation_mode = this._state.presentation_mode; public readonly presentables = this._control.presentables; /** List of available cameras to select from */ public readonly camera_list = this._control.camera_list; @@ -274,9 +274,10 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit { public readonly sentDTMF = (d) => this._state.sendDTMF(d); public readonly setPresentationSource = (i) => this._control.setRoute(i.id, this.present_output(), false); - public readonly setPresentationMode = (d) => + public readonly setPresentationMode = (d: PresentationMode) => this._state.setPresentationMode(d); - public readonly setVideoLayout = (d) => this._state.setVideoLayout(d); + public readonly setVideoLayout = (d: VideoLayout) => + this._state.setVideoLayout(d); public readonly toggleCamera = async () => this._state.showCameraPIP(!this.show_camera_pip()); public readonly toggleMute = async () => diff --git a/apps/control/src/app/video-call/video-call-state.service.ts b/apps/control/src/app/video-call/video-call-state.service.ts index 24a4837f52b..27e303c2c6f 100644 --- a/apps/control/src/app/video-call/video-call-state.service.ts +++ b/apps/control/src/app/video-call/video-call-state.service.ts @@ -6,11 +6,12 @@ import { signal, Signal, } from '@angular/core'; -import { AsyncHandler } from '@placeos/common'; +import { AsyncHandler, i18n, notifyError } from '@placeos/common'; import { getModule } from '@placeos/ts-client'; import { ControlStateService } from '../control-state.service'; export type VideoLayout = 'Auto' | 'Equal' | 'Overlay' | 'Prominent' | 'Single'; +export type PresentationMode = 'None' | 'Local' | 'Remote'; export type CallStatus = | 'Idle' | 'Dialling' @@ -29,7 +30,7 @@ export interface VideoCallDetails { CallbackNumber: string; DeviceType: string; Direction: string; - DisplayName: number; + DisplayName: string; Duration: number; 'Encryption/Type': string; FacilityServiceId: number; @@ -66,18 +67,11 @@ export class VideoCallStateService extends AsyncHandler { } return null; }); - public readonly mic_mute = this._bindTo( - 'mic_mute', - ); - public readonly presentation_mode = this._bindTo( - 'presentation_mode', - ); - public readonly video_layout = this._bindTo( - 'video_layout', - ); - public readonly show_camera_pip = this._bindTo( - 'selfview', - ); + public readonly mic_mute = this._bindTo('mic_mute'); + public readonly presentation_mode = + this._bindTo('presentation_mode'); + public readonly video_layout = this._bindTo('video_layout'); + public readonly show_camera_pip = this._bindTo('selfview'); private readonly _speaker_track = this._bindTo>('speaker_track'); public readonly speaker_track = computed( @@ -87,28 +81,20 @@ export class VideoCallStateService extends AsyncHandler { ], ); - public async showCameraPIP(state: boolean) { - const id = this._control.id; - if (!id) return; - return getModule(id, 'VidConf').execute('show_camera_pip', [state]); + public showCameraPIP(state: boolean) { + return this._exec('show_camera_pip', [state]); } - public async muteMicrophone(state: boolean) { - const id = this._control.id; - if (!id) return; - return getModule(id, 'VidConf').execute('mic_mute', [state]); + public muteMicrophone(state: boolean) { + return this._exec('mic_mute', [state]); } - public async setVideoLayout(layout: VideoLayout) { - const id = this._control.id; - if (!id) return; - return getModule(id, 'VidConf').execute('video_layout', [layout]); + public setVideoLayout(layout: VideoLayout) { + return this._exec('video_layout', [layout]); } - public async setPresentationMode(mod: 'None' | 'Local' | 'Remote') { - const id = this._control.id; - if (!id) return; - return getModule(id, 'VidConf').execute('presentation_mode', [mod]); + public setPresentationMode(mode: PresentationMode) { + return this._exec('presentation_mode', [mode]); } public async hangup() { @@ -117,23 +103,29 @@ export class VideoCallStateService extends AsyncHandler { return getModule(id, 'VidConf').execute('hangup', []); } - public async sendDTMF(digit: string) { - const id = this._control.id; - if (!id) return; - return getModule(id, 'VidConf').execute('dtmf_send', [digit]); + public sendDTMF(digit: string) { + return this._exec('dtmf_send', [digit]); } - public async toggleCallOnHold() { - const id = this._control.id; - if (!id) return; + public toggleCallOnHold() { const call = this.call(); if (!call) return; - return getModule(id, 'VidConf').execute( + return this._exec( call.Status === 'OnHold' ? 'call_resume' : 'call_place_on_hold', - [], ); } + /** Run a VidConf method. Shows an error and resolves when it fails. */ + private async _exec(method: string, args: unknown[] = []) { + const id = this._control.id; + if (!id) return; + try { + return await getModule(id, 'VidConf').execute(method, args); + } catch (error) { + notifyError(i18n('APP.CONTROL.VC_COMMAND_ERROR', { error })); + } + } + /** * Create an Angular signal that mirrors a video conferencing status * variable binding, rebinding whenever the active system changes. diff --git a/apps/control/src/main.ts b/apps/control/src/main.ts index abf620712d7..04c8630e516 100644 --- a/apps/control/src/main.ts +++ b/apps/control/src/main.ts @@ -5,7 +5,12 @@ import { provideZonelessChangeDetection, } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; -import { provideRouter, Router, withHashLocation } from '@angular/router'; +import { + provideRouter, + Router, + Routes, + withHashLocation, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; import { @@ -16,13 +21,14 @@ import * as Sentry from '@sentry/angular'; import { AppComponent } from './app/app.component'; import { BootstrapComponent } from './app/bootstrap.component'; +import { tabbedRouteMatcher } from './app/tabbed-view/tabbed-route'; import { environment } from './environments/environment'; if (environment.production) { enableProdMode(); } -const routes = [ +const routes: Routes = [ { path: 'unauthorised', component: UnauthorisedComponent }, { path: 'bootstrap', @@ -38,15 +44,7 @@ const routes = [ canActivate: [AuthorisedUserGuard], }, { - path: 'tabbed/:system', - loadComponent: () => - import('./app/tabbed-view/tabbed-view.component').then( - (m) => m.ControlTabbedViewComponent, - ), - canActivate: [AuthorisedUserGuard], - }, - { - path: 'tabbed/:system/:tab', + matcher: tabbedRouteMatcher, loadComponent: () => import('./app/tabbed-view/tabbed-view.component').then( (m) => m.ControlTabbedViewComponent, diff --git a/apps/control/src/tests/advanced-view.component.spec.ts b/apps/control/src/tests/advanced-view.component.spec.ts index 2d3706f7ea9..79cf7c308c2 100644 --- a/apps/control/src/tests/advanced-view.component.spec.ts +++ b/apps/control/src/tests/advanced-view.component.spec.ts @@ -55,4 +55,26 @@ describe('ControlAdvancedViewComponent', () => { expect('output-display').toHaveLength(6); expect('button').toHaveLength(2); }); + + /** Output list with the given number of items */ + const outputList = (count: number) => + Array.from({ length: count }, (_, i) => ({ id: `${i + 1}` })); + + it('should not add an empty page when outputs fill the last page', () => { + const service: any = spectator.inject(ControlStateService); + service.output_list.set(outputList(12)); + spectator.detectChanges(); + expect('button').toHaveLength(2); + }); + + it('should move to the last page when the output list shrinks', () => { + const service: any = spectator.inject(ControlStateService); + service.output_list.set(outputList(8)); + spectator.detectChanges(); + spectator.component.page.set(1); + service.output_list.set(outputList(4)); + spectator.detectChanges(); + expect(spectator.component.page()).toBe(0); + expect('output-display').toHaveLength(4); + }); }); diff --git a/apps/control/src/tests/status-bar.component.spec.ts b/apps/control/src/tests/status-bar.component.spec.ts index 258195fe3d8..abb06f1f273 100644 --- a/apps/control/src/tests/status-bar.component.spec.ts +++ b/apps/control/src/tests/status-bar.component.spec.ts @@ -3,7 +3,7 @@ import { FormsModule } from '@angular/forms'; import { MatSliderModule } from '@angular/material/slider'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { mockComponent, mockDirective } from '@placeos/common/tests'; -import { MockModule, MockPipe, MockProvider } from 'ng-mocks'; +import { MockModule, MockPipe, MockProvider, ngMocks } from 'ng-mocks'; import { ControlStateService } from '../app/control-state.service'; import { @@ -78,4 +78,13 @@ describe('ControlStatusBarComponent', () => { expect(service.setMute).toHaveBeenCalledWith(false); expect(service.setVolume).toHaveBeenCalledWith(30); }); + + it('should send recording commands to the bound capture module', () => { + const service = spectator.inject(ControlStateService); + (service as any).capture_list.set([{ mod: 'Recorder_1' }]); + spectator.detectChanges(); + expect(ngMocks.input('[place-action="start"]', 'mod')).toBe( + 'Recorder_1', + ); + }); }); diff --git a/apps/control/src/tests/tabbed-view/tab-outlet.component.spec.ts b/apps/control/src/tests/tabbed-view/tab-outlet.component.spec.ts index cd51311cb77..ad980c9093d 100644 --- a/apps/control/src/tests/tabbed-view/tab-outlet.component.spec.ts +++ b/apps/control/src/tests/tabbed-view/tab-outlet.component.spec.ts @@ -41,7 +41,7 @@ describe('TabOutletComponent', () => { ], providers: [ MockProvider(ControlStateService, { - id: 'sys1', + system_id: signal('sys1'), hide_present_all: signal(false), output_list: signal([]), system: signal({}), @@ -223,4 +223,42 @@ describe('TabOutletComponent', () => { expect(navigate).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); + + it('should fall back to the first tab when no tab is selected', () => { + spectator.setRouteParam('tab', ''); + const service: any = spectator.inject(ControlStateService); + service.system.set({}); + service.tabs.set([ + { id: 'a', name: 'A', icon: 'tv' }, + { id: 'b', name: 'B', icon: 'tv' }, + ]); + spectator.detectChanges(); + expect(spectator.component.active_tab()).toBe('a'); + expect(spectator.query('a[aria-current="page"]')).toContainText('A'); + }); + + it('should prefer the driver selected tab over the first tab', () => { + spectator.setRouteParam('tab', ''); + const service: any = spectator.inject(ControlStateService); + service.tabs.set([{ id: 'a' }, { id: 'b' }]); + service.system.set({ selected_tab: 'b' }); + expect(spectator.component.active_tab()).toBe('b'); + }); + + it('should follow the driver tab on the current system', () => { + vi.useFakeTimers(); + const service: any = spectator.inject(ControlStateService); + const navigate = vi + .spyOn(spectator.router, 'navigate') + .mockResolvedValue(true); + service.system_id.set('sys2'); + service.system.set({ selected_tab: 'tab2' }); + spectator.detectChanges(); + vi.advanceTimersByTime(600); + expect(navigate).toHaveBeenCalledWith( + ['/tabbed', 'sys2', 'tab2'], + expect.anything(), + ); + vi.useRealTimers(); + }); }); diff --git a/apps/control/src/tests/tabbed-view/tabbed-route.spec.ts b/apps/control/src/tests/tabbed-view/tabbed-route.spec.ts new file mode 100644 index 00000000000..be102f1c3a8 --- /dev/null +++ b/apps/control/src/tests/tabbed-view/tabbed-route.spec.ts @@ -0,0 +1,25 @@ +import { UrlSegment } from '@angular/router'; + +import { tabbedRouteMatcher } from '../../app/tabbed-view/tabbed-route'; + +/** URL segments for the given path */ +const segments = (path: string) => + path.split('/').map((p) => new UrlSegment(p, {})); + +describe('tabbedRouteMatcher', () => { + it('should match a system with and without a tab', () => { + expect( + tabbedRouteMatcher(segments('tabbed/sys-1'))?.posParams?.['system'] + .path, + ).toBe('sys-1'); + const match = tabbedRouteMatcher(segments('tabbed/sys-1/vc')); + expect(match?.posParams?.['system'].path).toBe('sys-1'); + expect(match?.posParams?.['tab'].path).toBe('vc'); + }); + + it('should not match other paths', () => { + expect(tabbedRouteMatcher(segments('tabbed'))).toBeNull(); + expect(tabbedRouteMatcher(segments('panel/sys-1'))).toBeNull(); + expect(tabbedRouteMatcher(segments('tabbed/a/b/c'))).toBeNull(); + }); +}); diff --git a/apps/control/src/tests/tabbed-view/tabbed-view.component.spec.ts b/apps/control/src/tests/tabbed-view/tabbed-view.component.spec.ts index 3d5ba99a261..ea26ab037cc 100644 --- a/apps/control/src/tests/tabbed-view/tabbed-view.component.spec.ts +++ b/apps/control/src/tests/tabbed-view/tabbed-view.component.spec.ts @@ -128,4 +128,15 @@ describe('ControlTabbedViewComponent', () => { spectator.click('[name="splash"] button'); expect(service.powerOn).not.toHaveBeenCalled(); }); + + it('should open the meeting list once and clear the join parameter', () => { + const service: any = spectator.inject(ControlStateService); + spectator.setRouteQueryParam('join', 'true'); + spectator.detectChanges(); + expect(service.selectMeeting).toHaveBeenCalledTimes(1); + expect(spectator.router.navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ queryParams: { join: null } }), + ); + }); }); diff --git a/apps/control/src/tests/ui/joystick.component.spec.ts b/apps/control/src/tests/ui/joystick.component.spec.ts index 648ac91d5ff..93bf1d83a1b 100644 --- a/apps/control/src/tests/ui/joystick.component.spec.ts +++ b/apps/control/src/tests/ui/joystick.component.spec.ts @@ -121,4 +121,15 @@ describe('JoystickComponent', () => { spectator.fixture.destroy(); expect(tilt).toHaveBeenLastCalledWith(JoystickTilt.Stop); }); + + it('should not move when pressed near the centre', () => { + const pan = vi.fn(); + spectator.output('panChange').subscribe(pan); + pointer('pointerdown', 100, 100); + expect(spectator.component.pan()).toBe(JoystickPan.Stop); + expect(spectator.component.tilt()).toBe(JoystickTilt.Stop); + expect(pan).not.toHaveBeenCalled(); + pointer('pointermove', 0, 96); + expect(spectator.component.pan()).toBe(JoystickPan.Left); + }); }); diff --git a/apps/control/src/tests/ui/lighting-tooltip.component.spec.ts b/apps/control/src/tests/ui/lighting-tooltip.component.spec.ts index 49a4a552724..24321e42adc 100644 --- a/apps/control/src/tests/ui/lighting-tooltip.component.spec.ts +++ b/apps/control/src/tests/ui/lighting-tooltip.component.spec.ts @@ -37,10 +37,11 @@ describe('LightingTooltipComponent', () => { spectator.detectChanges(); expect('p').not.toExist(); expect('button[state]').toExist(); - expect('button[state].inverse').toContainText('One'); + // Unselected states use the inverse (outlined) style + expect('button[state]:not(.inverse)').toContainText('One'); spectator.component.light.state = 'Two'; spectator.detectChanges(); - expect('button[state].inverse').not.toContainText('One'); - expect('button[state].inverse').toContainText('Two'); + expect('button[state]:not(.inverse)').not.toContainText('One'); + expect('button[state]:not(.inverse)').toContainText('Two'); }); }); diff --git a/apps/control/src/tests/ui/source-select.component.spec.ts b/apps/control/src/tests/ui/source-select.component.spec.ts index 7a6e9955494..82c47e75d6f 100644 --- a/apps/control/src/tests/ui/source-select.component.spec.ts +++ b/apps/control/src/tests/ui/source-select.component.spec.ts @@ -26,7 +26,7 @@ describe('SourceSelectComponent', () => { { provide: ControlStateService, useValue: { - output_list: signal([]), + output_list: signal([{ id: 'display', source: 'pc' }]), available_inputs: signal([input]), setRoute, }, @@ -67,4 +67,8 @@ describe('SourceSelectComponent', () => { expect(notify_open).toHaveBeenCalled(); expect(emitted).not.toHaveBeenCalled(); }); + + it('should highlight the routed source as selected', () => { + expect(spectator.query('button[source]')).not.toHaveClass('inverse'); + }); }); diff --git a/apps/control/src/tests/ui/voice-assistant.component.spec.ts b/apps/control/src/tests/ui/voice-assistant.component.spec.ts index c4247f6a458..6e625570449 100644 --- a/apps/control/src/tests/ui/voice-assistant.component.spec.ts +++ b/apps/control/src/tests/ui/voice-assistant.component.spec.ts @@ -111,4 +111,10 @@ describe('VoiceAssistantComponent', () => { spectator.setInput({ enabled: true }); expect(service.setEnabled).toHaveBeenCalledWith(true); }); + + it('should disable voice control when destroyed', () => { + service.setEnabled.mockClear(); + spectator.fixture.destroy(); + expect(service.setEnabled).toHaveBeenCalledWith(false); + }); }); diff --git a/apps/control/src/tests/ui/voice-assistant.service.spec.ts b/apps/control/src/tests/ui/voice-assistant.service.spec.ts index 14273383a8e..19c2f93f47e 100644 --- a/apps/control/src/tests/ui/voice-assistant.service.spec.ts +++ b/apps/control/src/tests/ui/voice-assistant.service.spec.ts @@ -19,6 +19,7 @@ class MockSpeechRecognition { public onend: any = null; public start = vi.fn(); public stop = vi.fn(); + public abort = vi.fn(); } describe('VoiceAssistantService', () => { @@ -51,6 +52,7 @@ describe('VoiceAssistantService', () => { setBinding: vi.fn(), startChat: vi.fn(), sendMessage: vi.fn(), + close: vi.fn(), }; spectator = createService({ providers: [{ provide: ChatService, useValue: chat }], @@ -99,7 +101,26 @@ describe('VoiceAssistantService', () => { vi.advanceTimersByTime(300); TestBed.flushEffects(); expect(spectator.service.enabled()).toBe(false); - expect(instance.stop).toHaveBeenCalled(); + expect(instance.abort).toHaveBeenCalled(); + expect(instance.onresult).toBeNull(); + }); + + it('should report speech recognition as unavailable when the browser lacks it', () => { + delete (window as any).SpeechRecognition; + spectator.service.setEnabled(true); + vi.advanceTimersByTime(300); + TestBed.flushEffects(); + expect(spectator.service.error().speech_recognition).toBe(true); + }); + + it('should drop the previous room chat when the system changes', () => { + spectator.service.setBinding('sys-1'); + TestBed.flushEffects(); + expect(chat.close).not.toHaveBeenCalled(); + spectator.service.setBinding('sys-2'); + TestBed.flushEffects(); + expect(chat.close).toHaveBeenCalledTimes(1); + expect(chat.setBinding).toHaveBeenLastCalledWith('sys-2'); }); function enable() { diff --git a/apps/control/src/tests/video-call/video-call-state.service.spec.ts b/apps/control/src/tests/video-call/video-call-state.service.spec.ts index c33982a8712..a4b72a4c9cd 100644 --- a/apps/control/src/tests/video-call/video-call-state.service.spec.ts +++ b/apps/control/src/tests/video-call/video-call-state.service.spec.ts @@ -140,4 +140,11 @@ describe('VideoCallStateService', () => { await spectator.service.sendDTMF('1'); expect(execute_spy).not.toHaveBeenCalled(); }); + + it('should resolve instead of throwing when a command fails', async () => { + execute_spy.mockRejectedValue(new Error('offline')); + await expect( + spectator.service.muteMicrophone(true), + ).resolves.toBeUndefined(); + }); }); diff --git a/shared/assets/locale/ar.json b/shared/assets/locale/ar.json index 899cf2b284f..786d8fcebe7 100644 --- a/shared/assets/locale/ar.json +++ b/shared/assets/locale/ar.json @@ -1890,6 +1890,7 @@ "VC_JOIN_ERROR": "تعذر العثور على مؤتمر فيديو نشط", "VC_LEAVE_ERROR": "فشل إنهاء المكالمة. خطأ: {{ error }}", "VC_DIAL_ERROR": "تعذر إجراء المكالمة. خطأ: {{ error }}", + "VC_COMMAND_ERROR": "فشل أمر مؤتمر الفيديو. خطأ: {{ error }}", "VC_LEAVE_LOADING": "جاري إنهاء المكالمة...", "VC_LOADING": "جاري تحميل تفاصيل المكالمة...", "ROUTE_ERROR": "تعذر تبديل المصدر. خطأ: {{ error }}", diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 4034e673ee4..44b47243a78 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -2982,6 +2982,7 @@ "VC_JOIN_ERROR": "Unable to find active video conference", "VC_LEAVE_ERROR": "Failed to hung up call. Error: {{ error }}", "VC_DIAL_ERROR": "Could not dial the call. Error: {{ error }}", + "VC_COMMAND_ERROR": "Video conference command failed. Error: {{ error }}", "VC_LEAVE_LOADING": "Hanging up call...", "VC_LOADING": "Loading call details...", "ROUTE_ERROR": "Could not switch the source. Error: {{ error }}", diff --git a/shared/assets/locale/en-GB.json b/shared/assets/locale/en-GB.json index f41ae90328e..99a1c24fa2e 100644 --- a/shared/assets/locale/en-GB.json +++ b/shared/assets/locale/en-GB.json @@ -1975,6 +1975,7 @@ "VC_JOIN_ERROR": "Unable to find active video conference", "VC_LEAVE_ERROR": "Failed to hung up call. Error: {{ error }}", "VC_DIAL_ERROR": "Could not dial the call. Error: {{ error }}", + "VC_COMMAND_ERROR": "Video conference command failed. Error: {{ error }}", "VC_LEAVE_LOADING": "Hanging up call...", "VC_LOADING": "Loading call details...", "ROUTE_ERROR": "Could not switch the source. Error: {{ error }}", diff --git a/shared/assets/locale/en-US.json b/shared/assets/locale/en-US.json index 9bfbf7edb33..f93afb32278 100644 --- a/shared/assets/locale/en-US.json +++ b/shared/assets/locale/en-US.json @@ -1975,6 +1975,7 @@ "VC_JOIN_ERROR": "Unable to find active video conference", "VC_LEAVE_ERROR": "Failed to hung up call. Error: {{ error }}", "VC_DIAL_ERROR": "Could not dial the call. Error: {{ error }}", + "VC_COMMAND_ERROR": "Video conference command failed. Error: {{ error }}", "VC_LEAVE_LOADING": "Hanging up call...", "VC_LOADING": "Loading call details...", "ROUTE_ERROR": "Could not switch the source. Error: {{ error }}", diff --git a/shared/assets/locale/es.json b/shared/assets/locale/es.json index 9e2123e40a8..56b45c7d660 100644 --- a/shared/assets/locale/es.json +++ b/shared/assets/locale/es.json @@ -1890,6 +1890,7 @@ "VC_JOIN_ERROR": "No se puede encontrar una videoconferencia activa", "VC_LEAVE_ERROR": "No se pudo colgar la llamada. Error: {{ error }}", "VC_DIAL_ERROR": "No se pudo realizar la llamada. Error: {{ error }}", + "VC_COMMAND_ERROR": "Falló el comando de la videoconferencia. Error: {{ error }}", "VC_LEAVE_LOADING": "Colgando llamada...", "VC_LOADING": "Cargando detalles de la llamada...", "ROUTE_ERROR": "No se pudo cambiar la fuente. Error: {{ error }}", diff --git a/shared/assets/locale/fr-CA.json b/shared/assets/locale/fr-CA.json index 034409ddc31..61188af61b7 100644 --- a/shared/assets/locale/fr-CA.json +++ b/shared/assets/locale/fr-CA.json @@ -1890,6 +1890,7 @@ "VC_JOIN_ERROR": "Impossible de trouver une vidéoconférence active", "VC_LEAVE_ERROR": "Échec de la fin de l'appel. Erreur : {{ error }}", "VC_DIAL_ERROR": "Impossible de passer l'appel. Erreur : {{ error }}", + "VC_COMMAND_ERROR": "La commande de visioconférence a échoué. Erreur : {{ error }}", "VC_LEAVE_LOADING": "Fin de l'appel...", "VC_LOADING": "Chargement des détails de l'appel...", "ROUTE_ERROR": "Impossible de changer la source. Erreur : {{ error }}", diff --git a/shared/assets/locale/fr.json b/shared/assets/locale/fr.json index a98d3dd136e..baa61da2bbb 100644 --- a/shared/assets/locale/fr.json +++ b/shared/assets/locale/fr.json @@ -1890,6 +1890,7 @@ "VC_JOIN_ERROR": "Impossible de trouver une visioconférence active", "VC_LEAVE_ERROR": "Failed to hung up call. Error: {{ error }}", "VC_DIAL_ERROR": "Impossible de passer l'appel. Erreur : {{ error }}", + "VC_COMMAND_ERROR": "La commande de visioconférence a échoué. Erreur : {{ error }}", "VC_LEAVE_LOADING": "Hanging up call...", "VC_LOADING": "Chargement des détails de l'appel...", "ROUTE_ERROR": "Impossible de changer la source. Erreur : {{ error }}", diff --git a/shared/assets/locale/it.json b/shared/assets/locale/it.json index 3a6864465f2..729e2aef16a 100644 --- a/shared/assets/locale/it.json +++ b/shared/assets/locale/it.json @@ -1890,6 +1890,7 @@ "VC_JOIN_ERROR": "Impossibile trovare una videoconferenza attiva", "VC_LEAVE_ERROR": "Impossibile chiudere la chiamata. Errore: {{ error }}", "VC_DIAL_ERROR": "Impossibile effettuare la chiamata. Errore: {{ error }}", + "VC_COMMAND_ERROR": "Comando della videoconferenza non riuscito. Errore: {{ error }}", "VC_LEAVE_LOADING": "Chiusura chiamata in corso...", "VC_LOADING": "Caricamento dettagli chiamata...", "ROUTE_ERROR": "Impossibile cambiare la sorgente. Errore: {{ error }}", diff --git a/shared/assets/locale/ja-JP.json b/shared/assets/locale/ja-JP.json index b7644c1e3d8..ebe0a75c349 100644 --- a/shared/assets/locale/ja-JP.json +++ b/shared/assets/locale/ja-JP.json @@ -1890,6 +1890,7 @@ "VC_JOIN_ERROR": "アクティブなビデオ会議が見つかりません", "VC_LEAVE_ERROR": "通話の切断に失敗しました。エラー: {{ error }}", "VC_DIAL_ERROR": "発信できませんでした。エラー: {{ error }}", + "VC_COMMAND_ERROR": "ビデオ会議のコマンドに失敗しました。エラー: {{ error }}", "VC_LEAVE_LOADING": "通話を切断中...", "VC_LOADING": "通話詳細を読み込み中...", "ROUTE_ERROR": "ソースを切り替えられませんでした。エラー: {{ error }}", diff --git a/shared/assets/locale/pt.json b/shared/assets/locale/pt.json index ed36076837d..7d5f0789cec 100644 --- a/shared/assets/locale/pt.json +++ b/shared/assets/locale/pt.json @@ -1890,6 +1890,7 @@ "VC_JOIN_ERROR": "Não foi possível encontrar uma videoconferência ativa", "VC_LEAVE_ERROR": "Falha ao encerrar a chamada. Erro: {{ error }}", "VC_DIAL_ERROR": "Não foi possível fazer a chamada. Erro: {{ error }}", + "VC_COMMAND_ERROR": "Falha no comando da videoconferência. Erro: {{ error }}", "VC_LEAVE_LOADING": "Encerrando a chamada...", "VC_LOADING": "Carregando detalhes da chamada...", "ROUTE_ERROR": "Não foi possível mudar a fonte. Erro: {{ error }}", diff --git a/shared/assets/locale/zh.json b/shared/assets/locale/zh.json index f400b9c3ec1..6fca8080cba 100644 --- a/shared/assets/locale/zh.json +++ b/shared/assets/locale/zh.json @@ -1891,6 +1891,7 @@ "VC_JOIN_ERROR": "无法找到活动的视频会议", "VC_LEAVE_ERROR": "挂断电话失败。错误:{{ error }}", "VC_DIAL_ERROR": "无法拨打电话。错误:{{ error }}", + "VC_COMMAND_ERROR": "视频会议命令失败。错误:{{ error }}", "VC_LEAVE_LOADING": "正在挂断电话...", "VC_LOADING": "正在加载通话详情...", "ROUTE_ERROR": "无法切换信号源。错误:{{ error }}", From 6085fcb0bde0c6f3f7b4931883bf47b85d25e1c0 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 2 Oct 2026 01:18:47 +1000 Subject: [PATCH 2/2] fix(control): stop voice at once and drop pending commands on room change - Turning voice control off stops recognition immediately. Only turning it on is debounced, so nothing is heard after the view goes away. - A room change cancels a command still waiting for the chat to connect, so it cannot reach the new room. --- .../src/app/ui/voice-assistant.service.ts | 26 ++++++++++++++++--- .../tests/ui/voice-assistant.service.spec.ts | 17 ++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/control/src/app/ui/voice-assistant.service.ts b/apps/control/src/app/ui/voice-assistant.service.ts index 5bc87b776e8..2e11be8791e 100644 --- a/apps/control/src/app/ui/voice-assistant.service.ts +++ b/apps/control/src/app/ui/voice-assistant.service.ts @@ -1,4 +1,11 @@ -import { Injectable, computed, effect, inject, signal } from '@angular/core'; +import { + Injectable, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; import { AsyncHandler, currentUser, log, randomInt } from '@placeos/common'; import { ChatService } from '@placeos/components'; @@ -109,8 +116,12 @@ export class VoiceAssistantService extends AsyncHandler { effect(() => { const id = this._system_id(); if (!id || id === bound_id) return; - // Drop the chat for the previous room so commands go to this one - if (bound_id) this._chat_service.close(); + // Drop the chat and any pending command for the previous room, + // so commands only go to this one + if (bound_id) { + untracked(() => this._setIdle()); + this._chat_service.close(); + } bound_id = id; this._chat_service.setBinding(id); }); @@ -151,8 +162,15 @@ export class VoiceAssistantService extends AsyncHandler { return this._mic_levels.read(); } + /** Turning on is debounced. Turning off is immediate, so nothing is heard after the view goes away. */ public setEnabled(is_enabled: boolean) { - this.timeout('set_enabled', () => this._enabled.set(is_enabled)); + if (is_enabled) { + this.timeout('set_enabled', () => this._enabled.set(true)); + return; + } + this.clearTimeout('set_enabled'); + this._enabled.set(false); + this._teardownVoiceRecognition(); } public setBinding(system_id: string) { diff --git a/apps/control/src/tests/ui/voice-assistant.service.spec.ts b/apps/control/src/tests/ui/voice-assistant.service.spec.ts index 19c2f93f47e..00b808b93a6 100644 --- a/apps/control/src/tests/ui/voice-assistant.service.spec.ts +++ b/apps/control/src/tests/ui/voice-assistant.service.spec.ts @@ -98,6 +98,9 @@ describe('VoiceAssistantService', () => { TestBed.flushEffects(); const instance = recognition_instances[0]; spectator.service.setEnabled(false); + // Stops at once, without waiting for the debounce + expect(spectator.service.enabled()).toBe(false); + expect(instance.abort).toHaveBeenCalled(); vi.advanceTimersByTime(300); TestBed.flushEffects(); expect(spectator.service.enabled()).toBe(false); @@ -170,6 +173,20 @@ describe('VoiceAssistantService', () => { ); }); + it('should not send a pending command to a new room', () => { + spectator.service.setBinding('sys-1'); + TestBed.flushEffects(); + const recognition = enable(); + chat.connected = false; + hear(recognition, 'Hey place turn on the projector'); + spectator.service.setBinding('sys-2'); + TestBed.flushEffects(); + chat.connected = true; + vi.advanceTimersByTime(1000); + expect(chat.sendMessage).not.toHaveBeenCalled(); + expect(spectator.service.state()).toBe('idle'); + }); + it('should ignore speech without the wake phrase', () => { const recognition = enable(); hear(recognition, 'turn on the projector', false);