From 89575b4de2fa749e6963c91300c1a548f71ec4ca Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 1 Oct 2026 14:34:28 +1000 Subject: [PATCH 1/2] fix(control): stop stuck spinners and stale room state - Release source bindings for aliases that leave the input/output list, so a room change or join no longer shows the previous room's sources. - Only re-run the tab sync when the driver's `selected_tab` changes, so volume or mute updates no longer jump the user back to another tab. - Clear the loading state and show an error when dialling, routing a source or joining a meeting fails. Close the join confirmation after joining, and check for a cancel before setting its loading state. - Treat `Idle` and `Disconnecting` calls as ended, leave the call page when the far end hangs up, and clear the "Leaving" spinner. - Hide the lighting scenes action when the room has no scenes. - Read the system search result with `hasValue()` so a failed request shows no results instead of throwing in the template. --- apps/control/src/app/bootstrap.component.ts | 4 +- apps/control/src/app/control-state.service.ts | 6 +- .../app/tabbed-view/tab-outlet.component.ts | 4 +- .../src/app/topbar-header.component.ts | 2 +- .../app/ui/select-meeting-modal.component.ts | 18 ++++- .../src/app/ui/source-select.component.ts | 12 +++- .../video-call-dial-view.component.ts | 11 ++- .../video-call/video-call-page.component.ts | 14 ++++ .../video-call/video-call-state.service.ts | 7 +- .../src/tests/bootstrap.component.spec.ts | 19 ++++- .../src/tests/control-state.service.spec.ts | 53 +++++++++++++- .../tabbed-view/tab-outlet.component.spec.ts | 19 ++++- .../src/tests/topbar-header.component.spec.ts | 8 +++ .../ui/select-meeting-modal.component.spec.ts | 49 ++++++++----- .../tests/ui/source-select.component.spec.ts | 70 +++++++++++++++++++ .../video-call-dial-view.component.spec.ts | 8 +++ .../video-call-page.component.spec.ts | 8 +++ .../video-call-state.service.spec.ts | 14 +++- shared/assets/locale/ar.json | 3 + shared/assets/locale/en-AU.json | 3 + shared/assets/locale/en-GB.json | 3 + shared/assets/locale/en-US.json | 3 + shared/assets/locale/es.json | 3 + shared/assets/locale/fr-CA.json | 3 + shared/assets/locale/fr.json | 3 + shared/assets/locale/it.json | 3 + shared/assets/locale/ja-JP.json | 3 + shared/assets/locale/pt.json | 3 + shared/assets/locale/zh.json | 3 + 29 files changed, 323 insertions(+), 36 deletions(-) create mode 100644 apps/control/src/tests/ui/source-select.component.spec.ts diff --git a/apps/control/src/app/bootstrap.component.ts b/apps/control/src/app/bootstrap.component.ts index 0c1c80af65b..99cbc5b0c9e 100644 --- a/apps/control/src/app/bootstrap.component.ts +++ b/apps/control/src/app/bootstrap.component.ts @@ -205,7 +205,9 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { } }, }); - public readonly space_list = computed(() => this._space_list.value() ?? []); + public readonly space_list = computed(() => + this._space_list.hasValue() ? this._space_list.value() : [], + ); constructor() { super(); diff --git a/apps/control/src/app/control-state.service.ts b/apps/control/src/app/control-state.service.ts index cff3d29aee3..7173edba8e3 100644 --- a/apps/control/src/app/control-state.service.ts +++ b/apps/control/src/app/control-state.service.ts @@ -340,7 +340,9 @@ export class ControlStateService extends AsyncHandler { return list.filter((_) => _.meeting_url.startsWith(url)); }, }); - public readonly events = computed(() => this._events.value() ?? []); + public readonly events = computed(() => + this._events.hasValue() ? this._events.value() : [], + ); public get id() { return this._id(); @@ -634,6 +636,8 @@ export class ControlStateService extends AsyncHandler { const id = this._id(); if (!id) return; + // Release bindings for aliases from the previous list or room + this.unsubWith(`listen:${type}/`); if (type === 'input') this._input_data.set([]); else this._output_data.set([]); 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 31d840b7c04..5aa29e9a86b 100644 --- a/apps/control/src/app/tabbed-view/tab-outlet.component.ts +++ b/apps/control/src/app/tabbed-view/tab-outlet.component.ts @@ -284,6 +284,8 @@ export class TabOutletComponent extends AsyncHandler { }); 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); private _available_inputs = this._service.available_inputs; private _route_tab = toSignal( this._route.paramMap.pipe(map((params) => params.get('tab') || '')), @@ -308,7 +310,7 @@ export class TabOutletComponent extends AsyncHandler { if (tab) this.active_tab.set(tab); }); effect(() => { - const selected_tab = this.system()?.selected_tab; + const selected_tab = this._selected_tab(); this.timeout( 'update_tab', () => { diff --git a/apps/control/src/app/topbar-header.component.ts b/apps/control/src/app/topbar-header.component.ts index 23c3b33a99b..83e49275ba2 100644 --- a/apps/control/src/app/topbar-header.component.ts +++ b/apps/control/src/app/topbar-header.component.ts @@ -279,7 +279,7 @@ export class TopbarHeaderComponent extends AsyncHandler { actions[TOOLTIP.CAMERA].show = cams?.length > 0 && !speaker_track; actions[TOOLTIP.HELP].show = help_items?.length > 0; actions[TOOLTIP.LIGHT_LEVELS].show = light_levels != null; - actions[TOOLTIP.LIGHT_SCENES].show = l_scenes != null; + actions[TOOLTIP.LIGHT_SCENES].show = l_scenes?.length > 0; return actions; }); diff --git a/apps/control/src/app/ui/select-meeting-modal.component.ts b/apps/control/src/app/ui/select-meeting-modal.component.ts index 0f703924e87..cbaf30cf3c5 100644 --- a/apps/control/src/app/ui/select-meeting-modal.component.ts +++ b/apps/control/src/app/ui/select-meeting-modal.component.ts @@ -6,7 +6,12 @@ import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; -import { CalendarEvent, i18n, notifySuccess } from '@placeos/common'; +import { + CalendarEvent, + i18n, + notifyError, + notifySuccess, +} from '@placeos/common'; import { IconComponent, openConfirmModal, @@ -133,9 +138,16 @@ export class SelectMeetingModalComponent { }, this._dialog, ); - details.loading(i18n('APP.CONTROL.MEETING_JOIN_LOADING')); if (details.reason !== 'done') return; - await this._service.setEvent(e); + details.loading(i18n('APP.CONTROL.MEETING_JOIN_LOADING')); + try { + await this._service.setEvent(e); + } catch (error) { + notifyError(i18n('APP.CONTROL.MEETING_JOIN_ERROR', { error })); + return; + } finally { + details.close(); + } notifySuccess(i18n('APP.CONTROL.MEETING_JOIN_SUCCESS')); this._dialog_ref.close(); }; diff --git a/apps/control/src/app/ui/source-select.component.ts b/apps/control/src/app/ui/source-select.component.ts index a5e2af177eb..b2cf4fef612 100644 --- a/apps/control/src/app/ui/source-select.component.ts +++ b/apps/control/src/app/ui/source-select.component.ts @@ -8,7 +8,7 @@ import { } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { unique } from '@placeos/common'; +import { i18n, notifyError, unique } from '@placeos/common'; import { TranslatePipe } from '@placeos/components'; import { ControlStateService, RoomInput } from '../control-state.service'; @@ -131,8 +131,14 @@ export class SourceSelectComponent { public async selectSource(input: RoomInput) { this.loading.set(true); - await this._state.setRoute(input.id, this.output()); - this.loading.set(false); + try { + await this._state.setRoute(input.id, this.output()); + } catch (error) { + notifyError(i18n('APP.CONTROL.ROUTE_ERROR', { error })); + return; + } finally { + this.loading.set(false); + } this.source.emit(input); } } diff --git a/apps/control/src/app/video-call/video-call-dial-view.component.ts b/apps/control/src/app/video-call/video-call-dial-view.component.ts index 368fe765fb8..1e6bc7c3262 100644 --- a/apps/control/src/app/video-call/video-call-dial-view.component.ts +++ b/apps/control/src/app/video-call/video-call-dial-view.component.ts @@ -12,6 +12,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, Router } from '@angular/router'; +import { i18n, notifyError } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { getModule } from '@placeos/ts-client'; import { ControlStateService } from '../control-state.service'; @@ -135,8 +136,14 @@ export class VideoCallDialViewComponent { const system_id = this._control.id; const mod = getModule(system_id, 'VidConf'); this.loading.set(true); - await mod.execute('dial', [dial_number]); - this.loading.set(false); + try { + await mod.execute('dial', [dial_number]); + } catch (error) { + notifyError(i18n('APP.CONTROL.VC_DIAL_ERROR', { error })); + return; + } finally { + this.loading.set(false); + } if (this.redirect()) { this._router.navigate(['call'], { relativeTo: this._route }); } 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 8cf02d1b6d6..d82bc6b87da 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 @@ -3,9 +3,11 @@ import { Injector, OnInit, computed, + effect, inject, input, signal, + untracked, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; @@ -287,6 +289,17 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit { this._onCallEnded(); }; + constructor() { + super(); + // Leave the page when the call ends remotely (far-end hang-up or drop) + let had_call = false; + effect(() => { + const has_call = !!this.call(); + if (had_call && !has_call) untracked(() => this._onCallEnded()); + had_call = has_call; + }); + } + public async ngOnInit() { this.loading.set(i18n('APP.CONTROL.VC_LOADING')); this.timeout( @@ -309,6 +322,7 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit { } private _onCallEnded() { + this.loading.set(''); if (this.redirect()) this._router.navigate(['/panel', this._control.id]); } 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 031c2b903c3..24a4837f52b 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 @@ -43,6 +43,9 @@ export interface VideoCallDetails { Ice: string; } +/** Statuses that do not count as a live call */ +const INACTIVE_STATUSES: readonly CallStatus[] = ['Idle', 'Disconnecting']; + @Injectable({ providedIn: 'root', }) @@ -57,7 +60,9 @@ export class VideoCallStateService extends AsyncHandler { public readonly call = computed(() => { const calls = this._calls(); for (const key in calls) { - if (calls[key].Status) return calls[key]; + const status = calls[key]?.Status; + if (status && !INACTIVE_STATUSES.includes(status)) + return calls[key]; } return null; }); diff --git a/apps/control/src/tests/bootstrap.component.spec.ts b/apps/control/src/tests/bootstrap.component.spec.ts index 0f41dfdfc5a..7a8fe6afb1e 100644 --- a/apps/control/src/tests/bootstrap.component.spec.ts +++ b/apps/control/src/tests/bootstrap.component.spec.ts @@ -4,14 +4,20 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Router } from '@angular/router'; -import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest'; +import { + createRoutingFactory, + SpectatorRouting, +} from '@ngneat/spectator/vitest'; import { MockModule, MockProvider } from 'ng-mocks'; import { SettingsService } from '@placeos/common'; import { SpacesService } from '@placeos/events'; +import * as client from '@placeos/ts-client'; import { of } from 'rxjs'; import { BootstrapComponent } from '../app/bootstrap.component'; +vi.mock('@placeos/ts-client', { spy: true }); + describe('BootstrapComponent', () => { let spectator: SpectatorRouting; const createComponent = createRoutingFactory({ @@ -95,4 +101,15 @@ describe('BootstrapComponent', () => { vi.runOnlyPendingTimers(); expect(localStorage.getItem('PLACEOS.CONTROL.system')).toBeFalsy(); }); + + it('should show no results when the system search fails', async () => { + vi.mocked(client.querySystems).mockRejectedValue(new Error('401')); + spectator.component.system_id.set('Room'); + spectator.detectChanges(); + await vi.advanceTimersByTimeAsync(400); + expect(client.querySystems).toHaveBeenCalled(); + expect(() => spectator.component.space_list()).not.toThrow(); + expect(spectator.component.space_list()).toEqual([]); + expect(() => spectator.detectChanges()).not.toThrow(); + }); }); diff --git a/apps/control/src/tests/control-state.service.spec.ts b/apps/control/src/tests/control-state.service.spec.ts index 9f516381486..e7be40140f5 100644 --- a/apps/control/src/tests/control-state.service.spec.ts +++ b/apps/control/src/tests/control-state.service.spec.ts @@ -1,21 +1,57 @@ +import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; -import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest'; +import { + createServiceFactory, + SpectatorService, +} from '@ngneat/spectator/vitest'; import { CalendarService, SpacesService } from '@placeos/events'; import { of } from 'rxjs'; + +vi.mock('@placeos/ts-client', { spy: true }); + +import * as client from '@placeos/ts-client'; import { ControlStateService } from '../app/control-state.service'; describe('ControlStateService', () => { let spectator: SpectatorService; + /** Latest binding callback for each bound variable name */ + let bindings: Record void>; + /** Names of bindings that were released */ + let released: string[]; const createService = createServiceFactory({ service: ControlStateService, providers: [ { provide: MatDialog, useValue: { open: vi.fn() } }, { provide: CalendarService, useValue: { calendars: of([]) } }, - { provide: SpacesService, useValue: { loadSpaces: vi.fn() } }, + { + provide: SpacesService, + useValue: { loadSpaces: vi.fn(), loadSpace: vi.fn() }, + }, ], }); - beforeEach(() => (spectator = createService())); + beforeEach(() => { + bindings = {}; + released = []; + vi.mocked(client.getModule).mockImplementation( + () => + ({ + variable: (name: string) => ({ + bindThenSubscribe: (cb: (value: unknown) => void) => { + bindings[name] = cb; + return () => released.push(name); + }, + bind: () => () => null, + listen: () => + Object.assign(() => undefined, { + subscribe: () => () => null, + }), + }), + execute: vi.fn(async () => null), + }) as unknown as ReturnType, + ); + spectator = createService(); + }); it('should create service', () => { expect(spectator.service).toBeTruthy(); @@ -28,4 +64,15 @@ describe('ControlStateService', () => { expect(spectator.service.system().volume).toBe(55); vi.useRealTimers(); }); + + it('should release source bindings that are no longer listed', () => { + spectator.service.setID('sys-1'); + TestBed.tick(); + bindings['inputs'](['a', 'b']); + TestBed.tick(); + expect(bindings['input/a']).toBeDefined(); + bindings['inputs'](['b']); + TestBed.tick(); + expect(released).toContain('input/a'); + }); }); 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 334ade1d97e..cd51311cb77 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 @@ -9,7 +9,6 @@ import { MockPipe, MockProvider } from 'ng-mocks'; import { BindingDirective, IconComponent, - SafePipe, TranslatePipe, } from '@placeos/components'; import { ControlStateService } from '../../app/control-state.service'; @@ -39,7 +38,6 @@ describe('TabOutletComponent', () => { mockDirective(BindingDirective), MockPipe(TranslatePipe, (v) => v), MockPipe(MarkdownPipe, (v) => v), - MockPipe(SafePipe, (v) => v), ], providers: [ MockProvider(ControlStateService, { @@ -208,4 +206,21 @@ describe('TabOutletComponent', () => { spectator.detectChanges(); expect(spectator.query('[output-actions]')).toExist(); }); + + it('should not jump back to the driver tab on unrelated system changes', () => { + vi.useFakeTimers(); + const service: any = spectator.inject(ControlStateService); + const navigate = vi + .spyOn(spectator.router, 'navigate') + .mockResolvedValue(true); + service.system.set({ selected_tab: 'tab1' }); + spectator.detectChanges(); + vi.advanceTimersByTime(600); + expect(navigate).toHaveBeenCalledTimes(1); + service.system.set({ selected_tab: 'tab1', volume: 40 }); + spectator.detectChanges(); + vi.advanceTimersByTime(600); + expect(navigate).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); }); diff --git a/apps/control/src/tests/topbar-header.component.spec.ts b/apps/control/src/tests/topbar-header.component.spec.ts index 737b6ac2e0a..b14644c0b3b 100644 --- a/apps/control/src/tests/topbar-header.component.spec.ts +++ b/apps/control/src/tests/topbar-header.component.spec.ts @@ -88,4 +88,12 @@ describe('TopbarHeaderComponent', () => { spectator.inject(ControlStateService).changeRoom, ).not.toHaveBeenCalled(); }); + + it('should hide the lighting scenes action when the room has no scenes', () => { + spectator = createComponent(); + const action = spectator.component + .action_list() + .find((_) => _.id === 'lighting_scenes'); + expect(action.show).toBe(false); + }); }); diff --git a/apps/control/src/tests/ui/select-meeting-modal.component.spec.ts b/apps/control/src/tests/ui/select-meeting-modal.component.spec.ts index 32a2f434baf..e0f4027d255 100644 --- a/apps/control/src/tests/ui/select-meeting-modal.component.spec.ts +++ b/apps/control/src/tests/ui/select-meeting-modal.component.spec.ts @@ -5,7 +5,7 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { setNotifyOutlet } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { MockComponent, MockPipe } from 'ng-mocks'; -import { NEVER, of } from 'rxjs'; +import { NEVER, of, tap } from 'rxjs'; import { ControlStateService } from '../../app/control-state.service'; import { SelectMeetingModalComponent } from '../../app/ui/select-meeting-modal.component'; @@ -93,32 +93,49 @@ describe('SelectMeetingModalComponent', () => { expect(service.setCalendar).toHaveBeenCalledWith(calendars()[1]); }); + /** Confirm modal ref that resolves as confirmed */ + const confirmedRef = () => ({ + componentInstance: { + event: of({ reason: 'done' }), + loading: signal(''), + }, + afterClosed: () => NEVER, + close: vi.fn(), + }); + it('should join a meeting when the confirmation is completed', async () => { - dialog_open.mockReturnValue({ - componentInstance: { - event: of({ reason: 'done' }), - loading: signal(''), - }, - afterClosed: () => NEVER, - close: vi.fn(), - }); + const confirm_ref = confirmedRef(); + dialog_open.mockReturnValue(confirm_ref); const event = events()[0]; await spectator.component.select(event); expect(dialog_open).toHaveBeenCalled(); expect(service.setEvent).toHaveBeenCalledWith(event); expect(notify_open).toHaveBeenCalled(); + expect(confirm_ref.close).toHaveBeenCalled(); expect(dialog_ref.close).toHaveBeenCalled(); }); + it('should close the confirmation and keep the list open when joining fails', async () => { + const confirm_ref = confirmedRef(); + dialog_open.mockReturnValue(confirm_ref); + service.setEvent.mockRejectedValue(new Error('offline')); + await spectator.component.select(events()[0]); + expect(notify_open).toHaveBeenCalled(); + expect(confirm_ref.close).toHaveBeenCalled(); + expect(dialog_ref.close).not.toHaveBeenCalled(); + }); + it('should not join a meeting when the confirmation is dismissed', async () => { - dialog_open.mockReturnValue({ - componentInstance: { - event: NEVER, - loading: signal(''), - }, - afterClosed: () => of({ reason: 'close' }), + // Like MatDialogRef, the component instance is cleared once closed + const confirm_ref = { + componentInstance: { event: NEVER, loading: signal('') }, + afterClosed: () => + of(undefined).pipe( + tap(() => (confirm_ref.componentInstance = null)), + ), close: vi.fn(), - }); + }; + dialog_open.mockReturnValue(confirm_ref); await spectator.component.select(events()[0]); expect(service.setEvent).not.toHaveBeenCalled(); expect(notify_open).not.toHaveBeenCalled(); diff --git a/apps/control/src/tests/ui/source-select.component.spec.ts b/apps/control/src/tests/ui/source-select.component.spec.ts new file mode 100644 index 00000000000..7a6e9955494 --- /dev/null +++ b/apps/control/src/tests/ui/source-select.component.spec.ts @@ -0,0 +1,70 @@ +import { signal } from '@angular/core'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { setNotifyOutlet } from '@placeos/common'; +import { TranslatePipe } from '@placeos/components'; +import { MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { + ControlStateService, + RoomInput, +} from '../../app/control-state.service'; +import { SourceSelectComponent } from '../../app/ui/source-select.component'; + +describe('SourceSelectComponent', () => { + let spectator: Spectator; + const setRoute = vi.fn(); + const notify_open = vi.fn(() => ({ + onAction: () => of(), + dismiss: vi.fn(), + })); + const input = { id: 'pc', name: 'PC', type: 'pc' } as RoomInput; + const createComponent = createComponentFactory({ + component: SourceSelectComponent, + declarations: [MockPipe(TranslatePipe, (v) => v)], + providers: [ + { + provide: ControlStateService, + useValue: { + output_list: signal([]), + available_inputs: signal([input]), + setRoute, + }, + }, + ], + }); + + beforeEach(() => { + setRoute.mockReset(); + notify_open.mockClear(); + setNotifyOutlet( + { open: notify_open } as unknown as Parameters< + typeof setNotifyOutlet + >[0], + true, + ); + spectator = createComponent({ props: { output: 'display' } }); + }); + + afterEach(() => setNotifyOutlet(null, true)); + + it('should route the source and emit it', async () => { + setRoute.mockResolvedValue(null); + const emitted = vi.fn(); + spectator.output('source').subscribe(emitted); + await spectator.component.selectSource(input); + expect(setRoute).toHaveBeenCalledWith('pc', 'display'); + expect(spectator.component.loading()).toBe(false); + expect(emitted).toHaveBeenCalledWith(input); + }); + + it('should clear loading and show an error when routing fails', async () => { + setRoute.mockRejectedValue(new Error('offline')); + const emitted = vi.fn(); + spectator.output('source').subscribe(emitted); + await spectator.component.selectSource(input); + expect(spectator.component.loading()).toBe(false); + expect(notify_open).toHaveBeenCalled(); + expect(emitted).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/control/src/tests/video-call/video-call-dial-view.component.spec.ts b/apps/control/src/tests/video-call/video-call-dial-view.component.spec.ts index a801c16aaa4..a329dfc5284 100644 --- a/apps/control/src/tests/video-call/video-call-dial-view.component.spec.ts +++ b/apps/control/src/tests/video-call/video-call-dial-view.component.spec.ts @@ -130,4 +130,12 @@ describe('VideoCallDialViewComponent', () => { await spectator.component.toggleCamera(); expect(call_state.showCameraPIP).toHaveBeenLastCalledWith(false); }); + + it('should clear the joining state when dialling fails', async () => { + execute_spy.mockRejectedValue(new Error('busy')); + spectator.component.dial_number.set('1234'); + await spectator.component.joinConference(); + expect(spectator.component.loading()).toBe(false); + expect(spectator.component.dial_number()).toBe('1234'); + }); }); diff --git a/apps/control/src/tests/video-call/video-call-page.component.spec.ts b/apps/control/src/tests/video-call/video-call-page.component.spec.ts index eac0eb169a2..68e2417bebb 100644 --- a/apps/control/src/tests/video-call/video-call-page.component.spec.ts +++ b/apps/control/src/tests/video-call/video-call-page.component.spec.ts @@ -146,6 +146,14 @@ describe('VideoCallPageComponent', () => { await spectator.component.endCall(); expect(call_state.hangup).toHaveBeenCalled(); expect(router_mock.navigate).not.toHaveBeenCalled(); + expect(spectator.component.loading()).toBe(''); + }); + + it('should navigate back to the panel when the call ends remotely', () => { + spectator.detectChanges(); + call_state.call.set(null); + spectator.detectChanges(); + expect(router_mock.navigate).toHaveBeenCalledWith(['/panel', 'sys-1']); }); it('should surface errors and stop loading when hangup fails', async () => { 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 1de3c72ead0..c33982a8712 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 @@ -1,6 +1,9 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest'; +import { + createServiceFactory, + SpectatorService, +} from '@ngneat/spectator/vitest'; import { ControlStateService } from '../../app/control-state.service'; import { VideoCallStateService } from '../../app/video-call/video-call-state.service'; @@ -53,6 +56,15 @@ describe('VideoCallStateService', () => { }); }); + it('should not treat idle or disconnecting calls as active', () => { + variable_values.calls = { + a: { Status: 'Idle' }, + b: { Status: 'Disconnecting' }, + }; + TestBed.flushEffects(); + expect(spectator.service.call()).toBeNull(); + }); + it('should return null when no call has a status', () => { variable_values.calls = { a: { Status: '' } }; TestBed.flushEffects(); diff --git a/shared/assets/locale/ar.json b/shared/assets/locale/ar.json index fe0807716ff..899cf2b284f 100644 --- a/shared/assets/locale/ar.json +++ b/shared/assets/locale/ar.json @@ -1889,8 +1889,10 @@ "VC_PIP_HIDE": "إخفاء صورة داخل صورة للكاميرا", "VC_JOIN_ERROR": "تعذر العثور على مؤتمر فيديو نشط", "VC_LEAVE_ERROR": "فشل إنهاء المكالمة. خطأ: {{ error }}", + "VC_DIAL_ERROR": "تعذر إجراء المكالمة. خطأ: {{ error }}", "VC_LEAVE_LOADING": "جاري إنهاء المكالمة...", "VC_LOADING": "جاري تحميل تفاصيل المكالمة...", + "ROUTE_ERROR": "تعذر تبديل المصدر. خطأ: {{ error }}", "CONTROLS": "عناصر التحكم", "ZOOM": "تكبير/تصغير", "CAMERA_SELECT": "اختر كاميرا", @@ -1926,6 +1928,7 @@ "MEETING_JOIN_MSG": "هل أنت متأكد من رغبتك في الانضمام إلى اجتماع {{ name }} الذي يبدأ في {{ time }}؟", "MEETING_JOIN_LOADING": "جاري الانضمام إلى الاجتماع...", "MEETING_JOIN_SUCCESS": "تم الانضمام إلى الاجتماع بنجاح.", + "MEETING_JOIN_ERROR": "تعذر الانضمام إلى الاجتماع. خطأ: {{ error }}", "SOURCE_INPUT_SELECT": "اختر مصدر الإدخال لـ {{ name }}", "SOURCE_INPUTS_EMPTY": "لا توجد مصادر إدخال متاحة للإخراج المحدد ({{ name }})", "SOURCE_SWITCHING": "جاري تبديل مصدر الإدخال...", diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 4d723d8acb6..4034e673ee4 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -2981,8 +2981,10 @@ "VC_PIP_HIDE": "Hide Camera PIP", "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_LEAVE_LOADING": "Hanging up call...", "VC_LOADING": "Loading call details...", + "ROUTE_ERROR": "Could not switch the source. Error: {{ error }}", "CONTROLS": "Controls", "ZOOM": "Zoom", "CAMERA_SELECT": "Select Camera", @@ -3018,6 +3020,7 @@ "MEETING_JOIN_MSG": "Are you sure you wish to join {{ name }}'s meeting starting at {{ time }}?", "MEETING_JOIN_LOADING": "Joining meeting...", "MEETING_JOIN_SUCCESS": "Successfully joined meeting.", + "MEETING_JOIN_ERROR": "Could not join the meeting. Error: {{ error }}", "SOURCE_INPUT_SELECT": "Select input source for {{ name }}", "SOURCE_INPUTS_EMPTY": "No input sources available for the selected output({{ name }})", "SOURCE_SWITCHING": "Switching input source...", diff --git a/shared/assets/locale/en-GB.json b/shared/assets/locale/en-GB.json index 1cbf32d3f41..f41ae90328e 100644 --- a/shared/assets/locale/en-GB.json +++ b/shared/assets/locale/en-GB.json @@ -1974,8 +1974,10 @@ "VC_PIP_HIDE": "Hide Camera PIP", "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_LEAVE_LOADING": "Hanging up call...", "VC_LOADING": "Loading call details...", + "ROUTE_ERROR": "Could not switch the source. Error: {{ error }}", "CONTROLS": "Controls", "ZOOM": "Zoom", "CAMERA_SELECT": "Select Camera", @@ -2011,6 +2013,7 @@ "MEETING_JOIN_MSG": "Are you sure you wish to join {{ name }}'s meeting starting at {{ time }}?", "MEETING_JOIN_LOADING": "Joining meeting...", "MEETING_JOIN_SUCCESS": "Successfully joined meeting.", + "MEETING_JOIN_ERROR": "Could not join the meeting. Error: {{ error }}", "SOURCE_INPUT_SELECT": "Select input source for {{ name }}", "SOURCE_INPUTS_EMPTY": "No input sources available for the selected output({{ name }})", "SOURCE_SWITCHING": "Switching input source...", diff --git a/shared/assets/locale/en-US.json b/shared/assets/locale/en-US.json index afa53026e04..9bfbf7edb33 100644 --- a/shared/assets/locale/en-US.json +++ b/shared/assets/locale/en-US.json @@ -1974,8 +1974,10 @@ "VC_PIP_HIDE": "Hide Camera PIP", "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_LEAVE_LOADING": "Hanging up call...", "VC_LOADING": "Loading call details...", + "ROUTE_ERROR": "Could not switch the source. Error: {{ error }}", "CONTROLS": "Controls", "ZOOM": "Zoom", "CAMERA_SELECT": "Select Camera", @@ -2011,6 +2013,7 @@ "MEETING_JOIN_MSG": "Are you sure you wish to join {{ name }}'s meeting starting at {{ time }}?", "MEETING_JOIN_LOADING": "Joining meeting...", "MEETING_JOIN_SUCCESS": "Successfully joined meeting.", + "MEETING_JOIN_ERROR": "Could not join the meeting. Error: {{ error }}", "SOURCE_INPUT_SELECT": "Select input source for {{ name }}", "SOURCE_INPUTS_EMPTY": "No input sources available for the selected output({{ name }})", "SOURCE_SWITCHING": "Switching input source...", diff --git a/shared/assets/locale/es.json b/shared/assets/locale/es.json index 0c80d37f631..9e2123e40a8 100644 --- a/shared/assets/locale/es.json +++ b/shared/assets/locale/es.json @@ -1889,8 +1889,10 @@ "VC_PIP_HIDE": "Ocultar PIP de Cámara", "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_LEAVE_LOADING": "Colgando llamada...", "VC_LOADING": "Cargando detalles de la llamada...", + "ROUTE_ERROR": "No se pudo cambiar la fuente. Error: {{ error }}", "CONTROLS": "Controles", "ZOOM": "Zoom", "CAMERA_SELECT": "Seleccionar Cámara", @@ -1926,6 +1928,7 @@ "MEETING_JOIN_MSG": "¿Está seguro de que desea unirse a la reunión de {{ name }} que comienza a las {{ time }}?", "MEETING_JOIN_LOADING": "Uniéndose a la reunión...", "MEETING_JOIN_SUCCESS": "Se unió a la reunión con éxito.", + "MEETING_JOIN_ERROR": "No se pudo unir a la reunión. Error: {{ error }}", "SOURCE_INPUT_SELECT": "Seleccione la fuente de entrada para {{ name }}", "SOURCE_INPUTS_EMPTY": "No hay fuentes de entrada disponibles para la salida seleccionada({{ name }})", "SOURCE_SWITCHING": "Cambiando fuente de entrada...", diff --git a/shared/assets/locale/fr-CA.json b/shared/assets/locale/fr-CA.json index ee65a834de2..034409ddc31 100644 --- a/shared/assets/locale/fr-CA.json +++ b/shared/assets/locale/fr-CA.json @@ -1889,8 +1889,10 @@ "VC_PIP_HIDE": "Masquer la caméra PIP", "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_LEAVE_LOADING": "Fin de l'appel...", "VC_LOADING": "Chargement des détails de l'appel...", + "ROUTE_ERROR": "Impossible de changer la source. Erreur : {{ error }}", "CONTROLS": "Contrôles", "ZOOM": "Zoom", "CAMERA_SELECT": "Sélectionner la caméra", @@ -1926,6 +1928,7 @@ "MEETING_JOIN_MSG": "Êtes-vous sûr de vouloir rejoindre la réunion de {{ name }} commençant à {{ time }}?", "MEETING_JOIN_LOADING": "Rejoindre la réunion...", "MEETING_JOIN_SUCCESS": "Réunion rejointe avec succès.", + "MEETING_JOIN_ERROR": "Impossible de rejoindre la réunion. Erreur : {{ error }}", "SOURCE_INPUT_SELECT": "Sélectionnez la source d'entrée pour {{ name }}", "SOURCE_INPUTS_EMPTY": "Aucune source d'entrée disponible pour la sortie sélectionnée ({{ name }})", "SOURCE_SWITCHING": "Changement de source d'entrée...", diff --git a/shared/assets/locale/fr.json b/shared/assets/locale/fr.json index 175b6f940e4..a98d3dd136e 100644 --- a/shared/assets/locale/fr.json +++ b/shared/assets/locale/fr.json @@ -1889,8 +1889,10 @@ "VC_PIP_HIDE": "Masquer la caméra en incrustation", "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_LEAVE_LOADING": "Hanging up call...", "VC_LOADING": "Chargement des détails de l'appel...", + "ROUTE_ERROR": "Impossible de changer la source. Erreur : {{ error }}", "CONTROLS": "Controls", "ZOOM": "Zoom", "CAMERA_SELECT": "Sélectionner une caméra", @@ -1926,6 +1928,7 @@ "MEETING_JOIN_MSG": "Êtes-vous sûr de vouloir rejoindre la réunion de {{ name }} qui commence à {{ time }} ?", "MEETING_JOIN_LOADING": "Joining meeting...", "MEETING_JOIN_SUCCESS": "Successfully joined meeting.", + "MEETING_JOIN_ERROR": "Impossible de rejoindre la réunion. Erreur : {{ error }}", "SOURCE_INPUT_SELECT": "Sélectionner la source d'entrée pour {{ name }}", "SOURCE_INPUTS_EMPTY": "Aucune source d'entrée disponible pour la sortie sélectionnée ({{ name }})", "SOURCE_SWITCHING": "Switching input source...", diff --git a/shared/assets/locale/it.json b/shared/assets/locale/it.json index a26e1143a9e..3a6864465f2 100644 --- a/shared/assets/locale/it.json +++ b/shared/assets/locale/it.json @@ -1889,8 +1889,10 @@ "VC_PIP_HIDE": "Nascondi PIP fotocamera", "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_LEAVE_LOADING": "Chiusura chiamata in corso...", "VC_LOADING": "Caricamento dettagli chiamata...", + "ROUTE_ERROR": "Impossibile cambiare la sorgente. Errore: {{ error }}", "CONTROLS": "Controlli", "ZOOM": "Zoom", "CAMERA_SELECT": "Seleziona telecamera", @@ -1926,6 +1928,7 @@ "MEETING_JOIN_MSG": "Sei sicuro di voler partecipare alla riunione di {{ name }} che inizia alle {{ time }}?", "MEETING_JOIN_LOADING": "Connessione alla riunione in corso...", "MEETING_JOIN_SUCCESS": "Partecipato con successo alla riunione.", + "MEETING_JOIN_ERROR": "Impossibile partecipare alla riunione. Errore: {{ error }}", "SOURCE_INPUT_SELECT": "Seleziona la sorgente di ingresso per {{ name }}", "SOURCE_INPUTS_EMPTY": "Nessuna sorgente di ingresso disponibile per l'uscita selezionata ({{ name }})", "SOURCE_SWITCHING": "Cambio sorgente in corso...", diff --git a/shared/assets/locale/ja-JP.json b/shared/assets/locale/ja-JP.json index 446b890515e..b7644c1e3d8 100644 --- a/shared/assets/locale/ja-JP.json +++ b/shared/assets/locale/ja-JP.json @@ -1889,8 +1889,10 @@ "VC_PIP_HIDE": "カメラPIPを非表示", "VC_JOIN_ERROR": "アクティブなビデオ会議が見つかりません", "VC_LEAVE_ERROR": "通話の切断に失敗しました。エラー: {{ error }}", + "VC_DIAL_ERROR": "発信できませんでした。エラー: {{ error }}", "VC_LEAVE_LOADING": "通話を切断中...", "VC_LOADING": "通話詳細を読み込み中...", + "ROUTE_ERROR": "ソースを切り替えられませんでした。エラー: {{ error }}", "CONTROLS": "コントロール", "ZOOM": "ズーム", "CAMERA_SELECT": "カメラを選択", @@ -1926,6 +1928,7 @@ "MEETING_JOIN_MSG": "{{ time }}開始の{{ name }}の会議に参加してもよろしいですか?", "MEETING_JOIN_LOADING": "会議に参加中...", "MEETING_JOIN_SUCCESS": "会議への参加に成功しました。", + "MEETING_JOIN_ERROR": "会議に参加できませんでした。エラー: {{ error }}", "SOURCE_INPUT_SELECT": "{{ name }}の入力ソースを選択してください", "SOURCE_INPUTS_EMPTY": "選択された出力({{ name }})に利用可能な入力ソースはありません", "SOURCE_SWITCHING": "入力ソースを切り替え中...", diff --git a/shared/assets/locale/pt.json b/shared/assets/locale/pt.json index 9e663dfe657..ed36076837d 100644 --- a/shared/assets/locale/pt.json +++ b/shared/assets/locale/pt.json @@ -1889,8 +1889,10 @@ "VC_PIP_HIDE": "Ocultar Janela de Câmera (PIP)", "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_LEAVE_LOADING": "Encerrando a chamada...", "VC_LOADING": "Carregando detalhes da chamada...", + "ROUTE_ERROR": "Não foi possível mudar a fonte. Erro: {{ error }}", "CONTROLS": "Controles", "ZOOM": "Zoom", "CAMERA_SELECT": "Selecionar Câmera", @@ -1926,6 +1928,7 @@ "MEETING_JOIN_MSG": "Tem certeza de que deseja participar da reunião de {{ name }}, que começará às {{ time }}?", "MEETING_JOIN_LOADING": "Ingressando na reunião...", "MEETING_JOIN_SUCCESS": "Ingressou na reunião com sucesso.", + "MEETING_JOIN_ERROR": "Não foi possível entrar na reunião. Erro: {{ error }}", "SOURCE_INPUT_SELECT": "Selecione a fonte de entrada para {{ name }}", "SOURCE_INPUTS_EMPTY": "Nenhuma fonte de entrada disponível para a saída selecionada ({{ name }})", "SOURCE_SWITCHING": "Alternando fonte de entrada...", diff --git a/shared/assets/locale/zh.json b/shared/assets/locale/zh.json index 53e016d48c9..f400b9c3ec1 100644 --- a/shared/assets/locale/zh.json +++ b/shared/assets/locale/zh.json @@ -1890,8 +1890,10 @@ "VC_PIP_HIDE": "隐藏摄像头画中画", "VC_JOIN_ERROR": "无法找到活动的视频会议", "VC_LEAVE_ERROR": "挂断电话失败。错误:{{ error }}", + "VC_DIAL_ERROR": "无法拨打电话。错误:{{ error }}", "VC_LEAVE_LOADING": "正在挂断电话...", "VC_LOADING": "正在加载通话详情...", + "ROUTE_ERROR": "无法切换信号源。错误:{{ error }}", "CONTROLS": "控件", "ZOOM": "缩放", "CAMERA_SELECT": "选择摄像头", @@ -1927,6 +1929,7 @@ "MEETING_JOIN_MSG": "您确定要加入 {{ name }} 在 {{ time }} 开始的会议吗?", "MEETING_JOIN_LOADING": "正在加入会议...", "MEETING_JOIN_SUCCESS": "成功加入会议。", + "MEETING_JOIN_ERROR": "无法加入会议。错误:{{ error }}", "SOURCE_INPUT_SELECT": "为 {{ name }} 选择输入源", "SOURCE_INPUTS_EMPTY": "所选输出({{ name }})没有可用的输入源", "SOURCE_SWITCHING": "正在切换输入源...", From aba4a0c4a3534b745e8d0932a632dbe4dd043010 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 2 Oct 2026 01:17:49 +1000 Subject: [PATCH 2/2] fix(control): leave the call page once when a call ends A local hang-up and the call status clearing could both navigate away. --- .../src/app/video-call/video-call-page.component.ts | 6 ++++++ .../tests/video-call/video-call-page.component.spec.ts | 10 ++++++++++ 2 files changed, 16 insertions(+) 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 d82bc6b87da..45dab68f0dc 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 @@ -241,6 +241,9 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit { private _router = inject(Router); private _injector = inject(Injector); + /** Whether the page has already left for the ended call */ + private _left = false; + public readonly redirect = input(true); public readonly present_output = input(''); public readonly loading = signal(''); @@ -321,8 +324,11 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit { mod.execute('selected_camera', [camera]); } + /** Leave the page once. A local hang-up and the call status clearing both end up here. */ private _onCallEnded() { this.loading.set(''); + if (this._left) return; + this._left = true; if (this.redirect()) this._router.navigate(['/panel', this._control.id]); } diff --git a/apps/control/src/tests/video-call/video-call-page.component.spec.ts b/apps/control/src/tests/video-call/video-call-page.component.spec.ts index 68e2417bebb..8463fd5fa18 100644 --- a/apps/control/src/tests/video-call/video-call-page.component.spec.ts +++ b/apps/control/src/tests/video-call/video-call-page.component.spec.ts @@ -141,6 +141,16 @@ describe('VideoCallPageComponent', () => { expect(router_mock.navigate).toHaveBeenCalledWith(['/panel', 'sys-1']); }); + it('should leave once when the call clears before hang-up resolves', async () => { + spectator.detectChanges(); + call_state.hangup.mockImplementation(async () => { + call_state.call.set(null); + spectator.detectChanges(); + }); + await spectator.component.endCall(); + expect(router_mock.navigate).toHaveBeenCalledTimes(1); + }); + it('should not navigate on end call when redirect is disabled', async () => { spectator.setInput({ redirect: false }); await spectator.component.endCall();