From 5d4c9af648623e253a902a41d41fa4d04b3ee8ab Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 1 Oct 2026 14:55:07 +1000 Subject: [PATCH 1/3] refactor(control): remove dead code and merge duplicates - Share one splash screen between the main, tabbed and video call views, and use the shared connecting screen in the video call view. - Share one system binding helper between the control and video call state services. - Share camera move, zoom and select commands between the camera controls, camera tooltip and call page. Highlight the last recalled preset in the camera tooltip. - Show the meeting list spinner while today's events load. - Remove unused code: the page view, the app's copy of the help tooltip, unused service state (volume, mute, blinds, screens, space), the dial view redirect, unused output list item handlers, the `simple` source select mode, and other unused fields. - Give the lighting tooltips and video call view unique selectors. - Drop tests for removed code and commented-out tests. --- apps/control/src/app/bootstrap.component.ts | 40 +++----- apps/control/src/app/control-state.service.ts | 89 ++--------------- apps/control/src/app/main-view.component.ts | 65 ++----------- apps/control/src/app/page-view.component.ts | 33 ------- apps/control/src/app/system-binding.ts | 37 +++++++ .../tabbed-view/output-list-item.component.ts | 17 +--- .../app/tabbed-view/tab-outlet.component.ts | 50 +++------- .../app/tabbed-view/tabbed-view.component.ts | 52 ++-------- .../app/tabbed-view/tv-controls.component.ts | 3 +- .../src/app/topbar-header.component.ts | 1 - apps/control/src/app/ui/camera-commands.ts | 50 ++++++++++ .../src/app/ui/camera-controls.component.ts | 96 ++++--------------- .../src/app/ui/camera-tooltip.component.ts | 76 +++++---------- .../src/app/ui/help-tooltip.component.ts | 54 ----------- .../ui/lighting-levels-tooltip.component.ts | 2 +- .../ui/lighting-scene-tooltip.component.ts | 2 +- .../ui/phone-dialling-tooltip.component.ts | 7 +- .../src/app/ui/power-tooltip.component.ts | 2 - .../app/ui/select-meeting-modal.component.ts | 4 +- .../app/ui/source-select-modal.component.ts | 20 +--- .../src/app/ui/source-select.component.ts | 14 +-- apps/control/src/app/ui/splash.component.ts | 60 ++++++++++++ .../src/app/ui/voice-assistant.service.ts | 16 +--- .../video-call-dial-view.component.ts | 33 +------ .../video-call/video-call-page.component.ts | 6 +- .../video-call/video-call-state.service.ts | 53 ++++------ .../video-call/video-call-view.component.ts | 42 ++------ .../src/tests/bootstrap.component.spec.ts | 12 +-- .../src/tests/control-state.service.spec.ts | 5 +- .../src/tests/main-view.component.spec.ts | 6 +- .../src/tests/page-view.component.spec.ts | 33 ------- .../src/tests/status-bar.component.spec.ts | 1 - .../output-list-item.component.spec.ts | 12 --- .../tabbed-view/tab-outlet.component.spec.ts | 7 +- .../tabbed-view/tabbed-view.component.spec.ts | 5 +- .../ui/camera-controls.component.spec.ts | 23 +---- .../tests/ui/camera-tooltip.component.spec.ts | 57 +++-------- .../phone-dialling-tooltip.component.spec.ts | 4 - .../ui/select-meeting-modal.component.spec.ts | 5 +- .../tests/ui/voice-assistant.service.spec.ts | 8 -- .../video-call-dial-view.component.spec.ts | 61 +++--------- .../video-call-page.component.spec.ts | 6 -- .../video-call-view.component.spec.ts | 18 +++- 43 files changed, 352 insertions(+), 835 deletions(-) delete mode 100644 apps/control/src/app/page-view.component.ts create mode 100644 apps/control/src/app/system-binding.ts create mode 100644 apps/control/src/app/ui/camera-commands.ts delete mode 100644 apps/control/src/app/ui/help-tooltip.component.ts create mode 100644 apps/control/src/app/ui/splash.component.ts delete mode 100644 apps/control/src/tests/page-view.component.spec.ts diff --git a/apps/control/src/app/bootstrap.component.ts b/apps/control/src/app/bootstrap.component.ts index 99cbc5b0c9e..bacf9eaae4a 100644 --- a/apps/control/src/app/bootstrap.component.ts +++ b/apps/control/src/app/bootstrap.component.ts @@ -181,10 +181,6 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { public loading = signal(''); /** ID of the system to bootstrap */ public system_id = signal(''); - /** Selected system to bootstrap */ - public selected_system: Space = null; - /** Whether input field is focused */ - public input_focus = signal(false); private readonly _debounced_search = debounced(this.system_id, 300); private readonly _space_list = resource({ @@ -243,20 +239,18 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { // takes precedence over previously stored bootstrap settings. await syncNativeManagedConfig(); if (this.system_id()) return; - if (localStorage) { - const system_id = localStorage.getItem(CONTROL_STORE_KEY); - // A system pushed via MDM managed config overrides the stored one - const mdm_system_id = getNativeSystemId(); - if (mdm_system_id && mdm_system_id !== system_id) { - this.system_id.set(mdm_system_id); - return this.configure(mdm_system_id); - } - if (system_id) { - this._router.navigate(['/tabbed', system_id], { - queryParamsHandling: 'preserve', - }); - return; - } + const system_id = localStorage.getItem(CONTROL_STORE_KEY); + // A system pushed via MDM managed config overrides the stored one + const mdm_system_id = getNativeSystemId(); + if (mdm_system_id && mdm_system_id !== system_id) { + this.system_id.set(mdm_system_id); + return this.configure(mdm_system_id); + } + if (system_id) { + this._router.navigate(['/tabbed', system_id], { + queryParamsHandling: 'preserve', + }); + return; } this.loading.set(''); } @@ -266,16 +260,12 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { * @param system_id System to bootstrap */ private configure(system_id: string): void { - this.loading.set('Setup'); - if (localStorage) { - localStorage.setItem(CONTROL_STORE_KEY, system_id); - localStorage.setItem('trust', 'true'); - localStorage.setItem('fixed_device', 'true'); - } + localStorage.setItem(CONTROL_STORE_KEY, system_id); + localStorage.setItem('trust', 'true'); + localStorage.setItem('fixed_device', 'true'); this._router.navigate(['/tabbed', system_id], { queryParamsHandling: 'preserve', }); - this.loading.set(''); } /** diff --git a/apps/control/src/app/control-state.service.ts b/apps/control/src/app/control-state.service.ts index 7173edba8e3..8894db83abb 100644 --- a/apps/control/src/app/control-state.service.ts +++ b/apps/control/src/app/control-state.service.ts @@ -1,6 +1,5 @@ import { computed, - debounced, effect, inject, Injectable, @@ -11,12 +10,7 @@ import { untracked, } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { - getModule, - isFixedDevice, - PlaceSystem, - showSystem, -} from '@placeos/ts-client'; +import { getModule, isFixedDevice } from '@placeos/ts-client'; import { Router } from '@angular/router'; import { @@ -27,12 +21,11 @@ import { firstValueWhere, HashMap, i18n, - log, - Space, } from '@placeos/common'; import { openConfirmModal } from '@placeos/components'; import { CalendarService, queryEvents, SpacesService } from '@placeos/events'; import { endOfDay, getUnixTime } from 'date-fns'; +import { systemBinding } from './system-binding'; import { HelpModalComponent } from './ui/help-modal.component'; import { SelectMeetingModalComponent } from './ui/select-meeting-modal.component'; import { SourceSelectModalComponent } from './ui/source-select-modal.component'; @@ -146,13 +139,9 @@ export class ControlStateService extends AsyncHandler { private readonly _inputs = signal([]); private readonly _available_inputs = signal([]); private readonly _outputs = signal([]); - private readonly _volume = signal(0); - private readonly _mute = signal(false); private readonly _input_data = signal([]); private readonly _output_data = signal([]); private readonly _lights = signal([]); - private readonly _blinds = signal([]); - private readonly _screens = signal([]); private readonly _url = signal(''); private readonly _active_output = signal(''); private readonly _calendar = signal(null); @@ -165,40 +154,8 @@ export class ControlStateService extends AsyncHandler { public readonly calendar = this._calendar.asReadonly(); /** List of available light sources */ public readonly lights = this._lights.asReadonly(); - /** List of available blind sources */ - public readonly blinds = this._blinds.asReadonly(); - public readonly screens = this._screens.asReadonly(); - public readonly volume = this._volume.asReadonly(); - public readonly mute = this._mute.asReadonly(); public readonly active_output = this._active_output.asReadonly(); - private readonly _debounced_id = debounced(this._id, 1000, { - injector: this._injector, - }); - /** Active system details loaded from the API */ - private readonly _space = resource({ - params: () => this._debounced_id.value(), - loader: async ({ params: id }) => { - if (!id) return new Space(new PlaceSystem() as any); - log('Panel', `Loading system "${id}"...`); - try { - const system = await showSystem(id); - return new Space(system as any); - } catch (error: any) { - const { status, message } = error || {}; - log( - 'Control', - 'Error loading system details:', - [status, message], - 'error', - ); - if (status === 404) this._router.navigate(['/bootstrap']); - return new Space(new PlaceSystem() as any); - } - }, - }); - public readonly space = computed(() => this._space.value()); - /** List of available input sources */ public readonly input_list = computed(() => this._input_data().filter((_) => !_.hidden), @@ -343,6 +300,8 @@ export class ControlStateService extends AsyncHandler { public readonly events = computed(() => this._events.hasValue() ? this._events.value() : [], ); + /** Whether today's events for the active calendar are loading */ + public readonly events_loading = this._events.isLoading; public get id() { return this._id(); @@ -372,7 +331,8 @@ export class ControlStateService extends AsyncHandler { public setID(id: string) { if (id !== this._id()) { this._id.set(id); - this._spaces.loadSpace(id); + // Caches the space details for the space pipe + this._spaces.loadSpace(id).catch(() => null); } } @@ -467,10 +427,7 @@ export class ControlStateService extends AsyncHandler { public setMute(state = true, source = '') { const outputs = this._output_data(); - if (!source) { - this._mute.set(state); - source = outputs[0]?.id || ''; - } + if (!source) source = outputs[0]?.id || ''; if (source) { const data = outputs.find((_) => _.id === source); if (data) { @@ -490,7 +447,6 @@ export class ControlStateService extends AsyncHandler { value = Math.floor(value); const outputs = this._output_data(); if (!source) { - this._volume.set(value); // Status echoes are ignored briefly below, so set the // master volume locally to keep the UI in sync. this._system.update((s) => ({ ...s, volume: value })); @@ -613,8 +569,6 @@ export class ControlStateService extends AsyncHandler { this._outputs.set(l), ); this.bindTo(id, 'lights', undefined, (l) => this._lights.set(l)); - this.bindTo(id, 'blinds', undefined, (l) => this._blinds.set(l)); - this.bindTo(id, 'screen', undefined, (l) => this._screens.set(l)); this.bindTo(id, 'qsc_dial_number', undefined, (v) => this.updateProperty('phone', v), ); @@ -663,10 +617,6 @@ export class ControlStateService extends AsyncHandler { } else { list.push({ id, ...data }); } - if (type === 'output') { - this._volume.set(list[0].volume || 0); - this._mute.set(!!list[0].mute); - } list_signal.set(list); } @@ -690,33 +640,12 @@ export class ControlStateService extends AsyncHandler { this._system.update((item) => ({ ...item, [name]: value })); } - /** - * Create an Angular signal that mirrors a status variable binding on the - * active system, rebinding whenever the active system changes. - */ + /** Signal that mirrors a status variable on the active system */ private _systemBinding( name: string, mod = 'System', initial: T = undefined as T, ): Signal { - const value = signal(initial); - effect((onCleanup) => { - const id = this._id(); - if (!id) { - value.set(initial); - return; - } - const binding = getModule(id, mod).variable(name); - const unbind = binding.bind(); - const listener = binding.listen(); - const update = () => value.set((listener() ?? initial) as T); - update(); - const unsubscribe = listener.subscribe(() => update()); - onCleanup(() => { - unsubscribe(); - unbind(); - }); - }); - return value.asReadonly(); + return systemBinding(this._id, mod, name, initial); } } diff --git a/apps/control/src/app/main-view.component.ts b/apps/control/src/app/main-view.component.ts index 5ed40eccc4f..0ad7908d97a 100644 --- a/apps/control/src/app/main-view.component.ts +++ b/apps/control/src/app/main-view.component.ts @@ -2,16 +2,13 @@ import { Component, effect, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; -import { VERSION } from '@placeos/common'; -import { ChangelogService, TranslatePipe } from '@placeos/components'; - -import { DatePipe } from '@angular/common'; +import { ControlAdvancedViewComponent } from './advanced-view.component'; import { ControlStateService } from './control-state.service'; -import { ControlPageViewComponent } from './page-view.component'; import { ControlStatusBarComponent } from './status-bar.component'; import { TopbarHeaderComponent } from './topbar-header.component'; import { ControlConnectingComponent } from './ui/connecting.component'; import { NextMeetingComponent } from './ui/next-meeting.component'; +import { SplashComponent } from './ui/splash.component'; @Component({ selector: 'app-control-main-view', @@ -20,41 +17,15 @@ import { NextMeetingComponent } from './ui/next-meeting.component'; @if (system()?.active) {
-
+
} @else { -
-

- {{ 'APP.CONTROL.TOUCH_TO_START' | translate }} -

-

{{ system()?.name }}

+ -
-
- {{ 'COMMON.CONTROLS_VERSION' | translate }}: - - -
-
- {{ version.time | date: 'longDate' }} - ({{ version.time | date: 'shortTime' }}) -
-
-
+ } } @else { @@ -72,43 +43,25 @@ import { NextMeetingComponent } from './ui/next-meeting.component'; :host > div { color: #fff; } - - [name='splash'] { - animation: crossfade 10s linear; - animation-iteration-count: infinite; - } `, ], imports: [ TopbarHeaderComponent, - ControlPageViewComponent, + ControlAdvancedViewComponent, + SplashComponent, ControlStatusBarComponent, ControlConnectingComponent, NextMeetingComponent, - TranslatePipe, - DatePipe, ], }) export class ControlMainViewComponent { private _route = inject(ActivatedRoute); private _state = inject(ControlStateService); - private _changelog = inject(ChangelogService); private readonly _param_map = toSignal(this._route.paramMap); private readonly _query_param_map = toSignal(this._route.queryParamMap); public readonly system = this._state.system; - public readonly changelog_available = this._changelog.available; - public readonly viewChangelog = () => this._changelog.view(); - - public readonly powerOn = () => this._state.powerOn(); - public get id() { - return this._state.id; - } - - public get version() { - return VERSION; - } constructor() { effect(() => { diff --git a/apps/control/src/app/page-view.component.ts b/apps/control/src/app/page-view.component.ts deleted file mode 100644 index 006a9178514..00000000000 --- a/apps/control/src/app/page-view.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ControlAdvancedViewComponent } from './advanced-view.component'; -import { SourceSelectComponent } from './ui/source-select.component'; - -@Component({ - selector: '[control-page-view]', - template: ` - @switch (view()) { - @case ('basic') { - - } - @default { - - } - } - `, - styles: [ - ` - :host { - width: 100%; - flex: 1; - height: 50%; - overflow: hidden; - background: #f0f0f0; - color: rgba(0, 0, 0, 0.85); - } - `, - ], - imports: [ControlAdvancedViewComponent, SourceSelectComponent], -}) -export class ControlPageViewComponent { - public readonly view = signal<'basic' | 'advanced'>('advanced'); -} diff --git a/apps/control/src/app/system-binding.ts b/apps/control/src/app/system-binding.ts new file mode 100644 index 00000000000..61712b54880 --- /dev/null +++ b/apps/control/src/app/system-binding.ts @@ -0,0 +1,37 @@ +import { effect, Signal, signal } from '@angular/core'; +import { getModule } from '@placeos/ts-client'; + +/** + * Create a signal that mirrors a status variable binding on a system module. + * Rebinds whenever `system_id` changes. Call from an injection context. + * @param system_id ID of the system to bind to + * @param mod Module name, for example `System` or `VidConf` + * @param name Status variable name + * @param initial Value used when there is no system or no value + */ +export function systemBinding( + system_id: Signal, + mod: string, + name: string, + initial: T, +): Signal { + const value = signal(initial); + effect((onCleanup) => { + const id = system_id(); + if (!id) { + value.set(initial); + return; + } + const binding = getModule(id, mod).variable(name); + const unbind = binding.bind(); + const listener = binding.listen(); + const update = () => value.set((listener() ?? initial) as T); + update(); + const unsubscribe = listener.subscribe(() => update()); + onCleanup(() => { + unsubscribe(); + unbind(); + }); + }); + return value.asReadonly(); +} diff --git a/apps/control/src/app/tabbed-view/output-list-item.component.ts b/apps/control/src/app/tabbed-view/output-list-item.component.ts index 9557aabf09a..61b3234e7fe 100644 --- a/apps/control/src/app/tabbed-view/output-list-item.component.ts +++ b/apps/control/src/app/tabbed-view/output-list-item.component.ts @@ -1,12 +1,9 @@ import { Component, computed, inject, input } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; -import { AsyncHandler } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { ControlStateService, RoomOutput } from '../control-state.service'; import { ICON_MAP } from '../ui/output-display.component'; -const STATUS = {}; - @Component({ selector: 'device-output-list-item', template: ` @@ -77,17 +74,11 @@ const STATUS = {}; ], imports: [TranslatePipe, MatRippleModule, IconComponent], }) -export class DeviceOutputListItemComponent extends AsyncHandler { +export class DeviceOutputListItemComponent { private _state = inject(ControlStateService); public readonly item = input(undefined); public readonly active = input(undefined); - /** Current volume level for output */ - public volume: number; - /** Current mute state of the output */ - public mute: boolean; - public last_input: string; - public readonly icons = ICON_MAP; private readonly _available_inputs = this._state.available_inputs; private readonly _system = this._state.system; @@ -99,12 +90,6 @@ export class DeviceOutputListItemComponent extends AsyncHandler { ); }); - public readonly setVolume = (v) => - this.timeout('volume', () => this._state.setVolume(v, this.item()?.id)); - public readonly setMute = (i, s) => { - this._state.setRoute(s ? 'mute' : this.last_input, this.item()?.id); - this.last_input = i; - }; public readonly setActiveOutput = () => { const { selected_input } = this._system() || {}; const input = this.input(); 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 9f14bae9b02..7e181b81277 100644 --- a/apps/control/src/app/tabbed-view/tab-outlet.component.ts +++ b/apps/control/src/app/tabbed-view/tab-outlet.component.ts @@ -9,7 +9,7 @@ import { TranslatePipe, } from '@placeos/components'; import { map } from 'rxjs/operators'; -import { ControlStateService } from '../control-state.service'; +import { ControlStateService, RoomInput } from '../control-state.service'; import { CameraControlsComponent } from '../ui/camera-controls.component'; import { MarkdownPipe } from '../ui/markdown.pipe'; import { VoiceAssistantComponent } from '../ui/voice-assistant.component'; @@ -89,7 +89,7 @@ import { TVControlsComponent } from './tv-controls.component';

- {{ join_code || '=CODE=' }} + {{ join_code }}

@@ -120,16 +120,6 @@ import { TVControlsComponent } from './tv-controls.component'; {{ input?.name }} } - @if (!inputs().length) { -
- {{ - 'APP.CONTROL.INPUT_CATEGORY_EMPTY' - | translate - }} -
- } }
} @@ -277,18 +266,17 @@ export class TabOutletComponent extends AsyncHandler { public readonly call = this._vc_state.call; public readonly speaker_track = this._vc_state.speaker_track; public readonly tab = computed(() => - this.tabs().find((t: any) => (t.id || t.name) === this.active_tab()), + this.tabs().find((t) => (t.id || t.name) === this.active_tab()), ); + /** Inputs for the active tab: its listed inputs, or else inputs of its type */ public readonly inputs = computed(() => { - const id = this.active_tab(); - const tab = this.tabs().find((_: any) => (_.id || _.name) === id); - const inputs = this._available_inputs(); + const tab = this.tab(); if (!tab) return []; - return inputs.filter( - (_) => - (!tab.inputs && (!tab.type || _.type === tab.type)) || - (tab.inputs && tab.inputs.includes(_.id)), + return this._available_inputs().filter((_) => + tab.inputs + ? tab.inputs.includes(_.id) + : !tab.type || _.type === tab.type, ); }); @@ -320,7 +308,8 @@ export class TabOutletComponent extends AsyncHandler { public join_code = ''; - public setInput = (s) => this._service.setOutputSource(s.id); + public setInput = (input: RoomInput) => + this._service.setOutputSource(input.id); public viewHelp = () => this._service.viewHelp(this.tab()?.help); constructor() { @@ -342,26 +331,15 @@ export class TabOutletComponent extends AsyncHandler { 500, ); }); + // Select the first input of the tab when the user changes tab and + // the selected input is not on it effect(() => { - const available_inputs = this._available_inputs(); - const tabs = this.tabs(); + const input_list = this.inputs(); const selected_input = this.system()?.selected_input; - const active_tab = this.active_tab(); const user_action = this._user_action(); this.timeout( 'inputs', () => { - const tab = tabs.find( - (_: any) => (_.id || _.name) === active_tab, - ); - const input_list = !tab - ? [] - : available_inputs.filter( - (_) => - (!tab.inputs && - (!tab.type || _.type === tab.type)) || - (tab.inputs && tab.inputs.includes(_.id)), - ); const has_selected = input_list.find( (i) => (i.id || i.name) === selected_input, ); 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 f5a0bc24756..c3a894c3ed0 100644 --- a/apps/control/src/app/tabbed-view/tabbed-view.component.ts +++ b/apps/control/src/app/tabbed-view/tabbed-view.component.ts @@ -2,21 +2,20 @@ import { Component, computed, effect, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { SettingsService, VERSION } from '@placeos/common'; +import { SettingsService } from '@placeos/common'; import { AuthenticatedImageDirective, - ChangelogService, IconComponent, TranslatePipe, } from '@placeos/components'; -import { DatePipe } from '@angular/common'; import { OrganisationService } from '@placeos/common'; import { ControlStateService } from '../control-state.service'; import { ControlStatusBarComponent } from '../status-bar.component'; import { TopbarHeaderComponent } from '../topbar-header.component'; import { ControlConnectingComponent } from '../ui/connecting.component'; import { NextMeetingComponent } from '../ui/next-meeting.component'; +import { SplashComponent } from '../ui/splash.component'; import { VoiceAssistantComponent } from '../ui/voice-assistant.component'; import { TabOutletComponent } from './tab-outlet.component'; @@ -33,41 +32,18 @@ import { TabOutletComponent } from './tab-outlet.component'; } @else { -
-

- {{ 'APP.CONTROL.TOUCH_TO_START' | translate }} -

-

{{ system()?.name }}

+ -
-
- Version: - -
-
- {{ version.time | date: 'longDate' }} - ({{ version.time | date: 'shortTime' }}) -
-
-
+
-
+
} @if (!join_status()[0] && join_status()[1]) {
div { color: #fff; } - - [name='splash'] { - animation: crossfade 10s linear; - animation-iteration-count: infinite; - } `, ], imports: [ @@ -123,14 +94,13 @@ import { TabOutletComponent } from './tab-outlet.component'; IconComponent, AuthenticatedImageDirective, VoiceAssistantComponent, - DatePipe, + SplashComponent, ], }) export class ControlTabbedViewComponent { private _route = inject(ActivatedRoute); private _router = inject(Router); private _state = inject(ControlStateService); - private _changelog = inject(ChangelogService); private _settings = inject(SettingsService); private _org = inject(OrganisationService); @@ -144,11 +114,7 @@ export class ControlTabbedViewComponent { public readonly system = this._state.system; public readonly join_status = this._state.join_status; - public readonly powerOn = () => this._state.powerOn(); public readonly id = this._state.system_id; - public readonly version = VERSION; - public readonly changelog_available = this._changelog.available; - public readonly viewChangelog = () => this._changelog.view(); public readonly logo = computed(() => { this._org.active_building(); diff --git a/apps/control/src/app/tabbed-view/tv-controls.component.ts b/apps/control/src/app/tabbed-view/tv-controls.component.ts index 323f6c0ed36..36a327fe77e 100644 --- a/apps/control/src/app/tabbed-view/tv-controls.component.ts +++ b/apps/control/src/app/tabbed-view/tv-controls.component.ts @@ -1,6 +1,5 @@ import { Component, inject, input } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; -import { AsyncHandler } from '@placeos/common'; import { AuthenticatedImageDirective, BindingDirective, @@ -53,7 +52,7 @@ import { ControlStateService } from '../control-state.service'; `, imports: [BindingDirective, MatRippleModule, AuthenticatedImageDirective], }) -export class TVControlsComponent extends AsyncHandler { +export class TVControlsComponent { private _state = inject(ControlStateService); public readonly mod = input(''); diff --git a/apps/control/src/app/topbar-header.component.ts b/apps/control/src/app/topbar-header.component.ts index 83e49275ba2..9e2063cdf2a 100644 --- a/apps/control/src/app/topbar-header.component.ts +++ b/apps/control/src/app/topbar-header.component.ts @@ -285,7 +285,6 @@ export class TopbarHeaderComponent extends AsyncHandler { }); public readonly viewHelp = () => this._state.viewHelp(); - public readonly powerOff = () => this._state.powerOff(); public readonly logo = computed(() => { this._org.active_building(); diff --git a/apps/control/src/app/ui/camera-commands.ts b/apps/control/src/app/ui/camera-commands.ts new file mode 100644 index 00000000000..0fbecbbee21 --- /dev/null +++ b/apps/control/src/app/ui/camera-commands.ts @@ -0,0 +1,50 @@ +import { getModule } from '@placeos/ts-client'; +import { RoomInput } from '../control-state.service'; +import { JoystickPan, JoystickTilt } from './joystick.component'; + +export enum ZoomDirection { + In = 'in', + Out = 'out', + Stop = 'stop', +} + +/** Command arguments, plus the camera index when the module has more than one camera */ +function withIndex(camera: RoomInput, args: unknown[]) { + return camera.index ? [...args, camera.index] : args; +} + +/** Select the camera that the room controls */ +export function selectCamera(system_id: string, camera_id: string) { + return getModule(system_id, 'System').execute('selected_camera', [ + camera_id, + ]); +} + +/** Move a camera. Stops first so an axis that returned to Stop does not keep moving. */ +export async function moveCamera( + system_id: string, + camera: RoomInput, + pan: JoystickPan, + tilt: JoystickTilt, +) { + const mod = getModule(system_id, camera.mod); + await mod.execute('stop', withIndex(camera, [])); + if (tilt !== JoystickTilt.Stop) { + await mod.execute('tilt', withIndex(camera, [tilt])); + } + if (pan !== JoystickPan.Stop) { + await mod.execute('pan', withIndex(camera, [pan])); + } +} + +/** Start or stop zooming a camera */ +export function zoomCamera( + system_id: string, + camera: RoomInput, + zoom: ZoomDirection, +) { + return getModule(system_id, camera.mod).execute( + 'zoom', + withIndex(camera, [zoom]), + ); +} diff --git a/apps/control/src/app/ui/camera-controls.component.ts b/apps/control/src/app/ui/camera-controls.component.ts index ca8638acf87..cd11567e343 100644 --- a/apps/control/src/app/ui/camera-controls.component.ts +++ b/apps/control/src/app/ui/camera-controls.component.ts @@ -1,12 +1,4 @@ -import { - Component, - DestroyRef, - effect, - inject, - OnInit, - signal, -} from '@angular/core'; -import { getModule } from '@placeos/ts-client'; +import { Component, DestroyRef, effect, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; @@ -14,18 +6,18 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { ControlStateService, RoomInput } from '../control-state.service'; +import { + moveCamera, + selectCamera, + zoomCamera, + ZoomDirection, +} from './camera-commands'; import { JoystickComponent, JoystickPan, JoystickTilt, } from './joystick.component'; -export enum ZoomDirection { - In = 'in', - Out = 'out', - Stop = 'stop', -} - @Component({ selector: 'camera-controls', template: ` @@ -119,15 +111,11 @@ export enum ZoomDirection { FormsModule, ], }) -export class CameraControlsComponent implements OnInit { +export class CameraControlsComponent { private _state = inject(ControlStateService); /** Currently active camera */ public readonly active_camera = signal(undefined); - /** List of available presets for the active camera */ - public readonly presets = signal([]); - /** Currently active preset */ - public readonly preset = signal(''); /** Current zoom value for camera */ public readonly zoom = signal(ZoomDirection.Stop); /** Current panning value for camera */ @@ -139,8 +127,8 @@ export class CameraControlsComponent implements OnInit { private readonly _selected_camera = this._state.selected_camera; - private _move_timeout: any; - private _zoom_timeout: any; + private _move_timeout?: ReturnType; + private _zoom_timeout?: ReturnType; public get id(): string { return this._state.id; @@ -155,61 +143,19 @@ export class CameraControlsComponent implements OnInit { }); } - public ngOnInit() { - // Effect handles camera selection - } - public selectCamera(camera: RoomInput) { this.active_camera.set(camera); - const mod = getModule(this.id, 'System'); - if (!mod) return; - mod.execute('selected_camera', [camera.id]); - } - - public recallPreset(preset: string) { - const cam = this.active_camera(); - if (!cam) return; - const mod = getModule(this.id, cam.mod); - if (!mod) return; - mod.execute('recall', [preset]); - } - - public addPreset(preset: string) { - const cam = this.active_camera(); - if (!cam) return; - const mod = getModule(this.id, 'System'); - if (!mod) return; - mod.execute('add_preset', [preset, cam.id]); - } - - public removePreset(preset: string) { - const cam = this.active_camera(); - if (!cam) return; - const mod = getModule(this.id, 'System'); - if (!mod) return; - mod.execute('remove_preset', [preset, cam.id]); + selectCamera(this.id, camera.id); } public moveCamera() { const cam = this.active_camera(); if (!cam) return; clearTimeout(this._move_timeout); - this._move_timeout = setTimeout(async () => { - const { index } = cam; - const mod = getModule(this.id, cam.mod); - if (!mod) return; - await mod.execute('stop', index ? [index] : []); - if (this.tilt() !== JoystickTilt.Stop) - await mod.execute( - 'tilt', - index ? [this.tilt(), index] : [this.tilt()], - ); - if (this.pan() !== JoystickPan.Stop) - await mod.execute( - 'pan', - index ? [this.pan(), index] : [this.pan()], - ); - }, 50); + this._move_timeout = setTimeout( + () => moveCamera(this.id, cam, this.pan(), this.tilt()), + 50, + ); } /** Start zooming. Pointer capture makes sure the button receives the release. */ @@ -217,13 +163,8 @@ export class CameraControlsComponent implements OnInit { (e.currentTarget as Element | null)?.setPointerCapture?.(e.pointerId); const cam = this.active_camera(); if (!cam) return; - const mod = getModule(this.id, cam.mod); - if (!mod) return; this.zoom.set(dir === 'in' ? ZoomDirection.In : ZoomDirection.Out); - const { index } = cam; - await mod - .execute('zoom', index ? [this.zoom(), index] : [this.zoom()]) - .catch(); + await zoomCamera(this.id, cam, this.zoom()).catch(() => null); } public stopZoom() { @@ -232,11 +173,8 @@ export class CameraControlsComponent implements OnInit { if (this.zoom() === ZoomDirection.Stop) return; const cam = this.active_camera(); if (!cam) return; - const mod = getModule(this.id, cam.mod); - if (!mod) return; - const { index } = cam; this.zoom.set(ZoomDirection.Stop); - mod.execute('zoom', index ? [this.zoom(), index] : [this.zoom()]); + zoomCamera(this.id, cam, ZoomDirection.Stop); }, 50); } } diff --git a/apps/control/src/app/ui/camera-tooltip.component.ts b/apps/control/src/app/ui/camera-tooltip.component.ts index 7e45f536feb..43d3bf366bd 100644 --- a/apps/control/src/app/ui/camera-tooltip.component.ts +++ b/apps/control/src/app/ui/camera-tooltip.component.ts @@ -14,17 +14,18 @@ import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; import { ControlStateService, RoomInput } from '../control-state.service'; +import { + moveCamera, + selectCamera, + zoomCamera, + ZoomDirection, +} from './camera-commands'; import { JoystickComponent, JoystickPan, JoystickTilt, } from './joystick.component'; -export enum ZoomDirection { - In = 'in', - Out = 'out', - Stop = 'stop', -} @Component({ selector: 'camera-tooltip', template: ` @@ -60,7 +61,7 @@ export enum ZoomDirection { btn matRipple class="w-48" - [class.inverse]="preset !== name" + [class.inverse]="preset() !== name" (click)="recallPreset(name)" > {{ name }} @@ -238,15 +239,15 @@ export class CameraTooltipComponent { private _state = inject(ControlStateService); private _tooltip = inject(CustomTooltipData); - private _move_timeout: any; - private _stop_zoom_timeout: any; + private _move_timeout?: ReturnType; + private _stop_zoom_timeout?: ReturnType; /** Currently active camera */ public readonly active_camera = signal(undefined); /** List of available presets for the active camera */ public readonly presets = signal([]); - /** Currently active preset */ - public preset = ''; + /** Last preset recalled from this panel */ + public readonly preset = signal(''); /** Current zoom value for camera */ public zoom: ZoomDirection = ZoomDirection.Stop; /** Current panning value for camera */ @@ -276,57 +277,40 @@ export class CameraTooltipComponent { public selectCamera(camera: RoomInput) { this.active_camera.set(camera); - const mod = getModule(this.id, 'System'); - if (!mod) return; - mod.execute('selected_camera', [camera.id]); + this.preset.set(''); + selectCamera(this.id, camera.id); } public recallPreset(preset: string) { const camera = this.active_camera(); if (!camera?.mod) return; - const mod = getModule(this.id, camera.mod); - if (!mod) return; - mod.execute('recall', [preset]); + this.preset.set(preset); + getModule(this.id, camera.mod).execute('recall', [preset]); } public addPreset(preset: string) { const camera = this.active_camera(); if (!camera) return; - const mod = getModule(this.id, 'System'); - if (!mod) return; - mod.execute('add_preset', [preset, camera.id]); + getModule(this.id, 'System').execute('add_preset', [preset, camera.id]); } public removePreset(preset: string) { const camera = this.active_camera(); if (!camera) return; - const mod = getModule(this.id, 'System'); - if (!mod) return; - mod.execute('remove_preset', [preset, camera.id]); + getModule(this.id, 'System').execute('remove_preset', [ + preset, + camera.id, + ]); } public moveCamera() { const camera = this.active_camera(); if (!camera) return; clearTimeout(this._move_timeout); - this._move_timeout = setTimeout(async () => { - const { index } = camera; - const mod = getModule(this.id, camera.mod); - // Stop first so an axis that returned to Stop does not keep moving - await mod.execute('stop', index ? [index] : []); - if (this.tilt !== JoystickTilt.Stop) { - await mod.execute( - 'tilt', - index ? [this.tilt, index] : [this.tilt], - ); - } - if (this.pan !== JoystickPan.Stop) { - await mod.execute( - 'pan', - index ? [this.pan, index] : [this.pan], - ); - } - }, 50); + this._move_timeout = setTimeout( + () => moveCamera(this.id, camera, this.pan, this.tilt), + 50, + ); } /** Start zooming. Pointer capture makes sure the button receives the release. */ @@ -334,13 +318,8 @@ export class CameraTooltipComponent { (e.currentTarget as Element | null)?.setPointerCapture?.(e.pointerId); const camera = this.active_camera(); if (!camera?.mod) return; - const mod = getModule(this.id, camera.mod); - if (!mod) return; this.zoom = dir === 'in' ? ZoomDirection.In : ZoomDirection.Out; - const { index } = camera; - await mod - .execute('zoom', index ? [this.zoom, index] : [this.zoom]) - .catch(); + await zoomCamera(this.id, camera, this.zoom).catch(() => null); } public stopZoom() { @@ -349,11 +328,8 @@ export class CameraTooltipComponent { if (this.zoom === ZoomDirection.Stop) return; const camera = this.active_camera(); if (!camera?.mod) return; - const mod = getModule(this.id, camera.mod); - if (!mod) return; - const { index } = camera; this.zoom = ZoomDirection.Stop; - mod.execute('zoom', index ? [this.zoom, index] : [this.zoom]); + zoomCamera(this.id, camera, ZoomDirection.Stop); }, 50); } } diff --git a/apps/control/src/app/ui/help-tooltip.component.ts b/apps/control/src/app/ui/help-tooltip.component.ts deleted file mode 100644 index 12580281c94..00000000000 --- a/apps/control/src/app/ui/help-tooltip.component.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Component, computed, inject } from '@angular/core'; -import { SettingsService } from '@placeos/common'; -import { - CustomTooltipData, - SanitizePipe, - TranslatePipe, -} from '@placeos/components'; - -@Component({ - selector: 'help-tooltip', - template: ` -
-

- {{ 'APP.CONTROL.HELP_CONTACT' | translate }} -

-

-
- `, - styles: [ - ` - a { - text-decoration-line: underline; - } - `, - ], - imports: [TranslatePipe, SanitizePipe], -}) -export class HelpTooltipComponent { - private _settings = inject(SettingsService); - private _tooltip = inject(CustomTooltipData); - - public readonly support_details = computed(() => ({ - contact: - this._settings.get('app.support_contact') || ' your administrator', - email: this._settings.get('app.support_email') || 'support@place.tech', - phone: this._settings.get('app.support_phone') || '0412345678', - })); - - /** Close the tooltip */ - public readonly close = () => this._tooltip.close(); -} diff --git a/apps/control/src/app/ui/lighting-levels-tooltip.component.ts b/apps/control/src/app/ui/lighting-levels-tooltip.component.ts index be62000625e..0cbc72b27f9 100644 --- a/apps/control/src/app/ui/lighting-levels-tooltip.component.ts +++ b/apps/control/src/app/ui/lighting-levels-tooltip.component.ts @@ -11,7 +11,7 @@ import { getModule } from '@placeos/ts-client'; import { ControlStateService } from '../control-state.service'; @Component({ - selector: 'lighting-tooltip', + selector: 'lighting-levels-tooltip', template: `
this._state.powerOff(t); /** Close the tooltip */ public readonly close = () => this._tooltip.close(); - - public readonly joined = this._state.joined; } 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 cbaf30cf3c5..2fc76dbcf21 100644 --- a/apps/control/src/app/ui/select-meeting-modal.component.ts +++ b/apps/control/src/app/ui/select-meeting-modal.component.ts @@ -1,5 +1,5 @@ import { DatePipe } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; @@ -120,7 +120,7 @@ export class SelectMeetingModalComponent { public readonly calendars = this._service.calendars; public readonly events = this._service.events; - public readonly loading = signal(false); + public readonly loading = this._service.events_loading; public readonly calendar = this._service.calendar; diff --git a/apps/control/src/app/ui/source-select-modal.component.ts b/apps/control/src/app/ui/source-select-modal.component.ts index b648977328f..d6fd2cb67e7 100644 --- a/apps/control/src/app/ui/source-select-modal.component.ts +++ b/apps/control/src/app/ui/source-select-modal.component.ts @@ -16,27 +16,14 @@ export class SourceSelectModalData { selector: 'source-select-modal', template: `
- + @@ -54,7 +41,6 @@ export class SourceSelectModalComponent { private _dialog_ref = inject>(MatDialogRef); - public simple = false; public readonly output = this._data.output; public close() { diff --git a/apps/control/src/app/ui/source-select.component.ts b/apps/control/src/app/ui/source-select.component.ts index 10e75085666..20cef62e35e 100644 --- a/apps/control/src/app/ui/source-select.component.ts +++ b/apps/control/src/app/ui/source-select.component.ts @@ -15,11 +15,7 @@ import { ControlStateService, RoomInput } from '../control-state.service'; @Component({ selector: 'source-select', template: ` -
+
@let source = details();

{{ @@ -29,11 +25,7 @@ import { ControlStateService, RoomInput } from '../control-state.service';

@if (!loading()) { @if (input_types().length) { -
+
@for (type of input_types(); track type) {

@@ -89,8 +81,6 @@ import { ControlStateService, RoomInput } from '../control-state.service'; export class SourceSelectComponent { private _state = inject(ControlStateService); - // Whether to use the simple display - public readonly simple = input(false); // ID of the selected output public readonly output = input(undefined); /** Emitter for changes to the selected input source */ diff --git a/apps/control/src/app/ui/splash.component.ts b/apps/control/src/app/ui/splash.component.ts new file mode 100644 index 00000000000..36ffa376782 --- /dev/null +++ b/apps/control/src/app/ui/splash.component.ts @@ -0,0 +1,60 @@ +import { DatePipe } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { VERSION } from '@placeos/common'; +import { ChangelogService, TranslatePipe } from '@placeos/components'; +import { ControlStateService } from '../control-state.service'; + +/** + * Full screen shown while the room is powered off. A tap powers on the room. + * Projects extra content, such as the next meeting, under the room name. + */ +@Component({ + selector: 'control-splash', + template: ` +

+ {{ 'APP.CONTROL.TOUCH_TO_START' | translate }} +

+

{{ system()?.name }}

+ +
+
+ {{ 'COMMON.CONTROLS_VERSION' | translate }}: + +
+
+ {{ version.time | date: 'longDate' }} + ({{ version.time | date: 'shortTime' }}) +
+
+ `, + styles: [ + ` + :host { + animation: crossfade 10s linear; + animation-iteration-count: infinite; + } + `, + ], + host: { + name: 'splash', + class: 'absolute inset-0 flex flex-col items-center justify-center text-white', + '(click)': 'powerOn()', + }, + imports: [TranslatePipe, DatePipe], +}) +export class SplashComponent { + private _state = inject(ControlStateService); + private _changelog = inject(ChangelogService); + + public readonly system = this._state.system; + public readonly version = VERSION; + public readonly changelog_available = this._changelog.available; + public readonly viewChangelog = () => this._changelog.view(); + public readonly powerOn = () => this._state.powerOn(); +} diff --git a/apps/control/src/app/ui/voice-assistant.service.ts b/apps/control/src/app/ui/voice-assistant.service.ts index 2e11be8791e..4e09f6b9aef 100644 --- a/apps/control/src/app/ui/voice-assistant.service.ts +++ b/apps/control/src/app/ui/voice-assistant.service.ts @@ -1,11 +1,4 @@ -import { - Injectable, - computed, - effect, - inject, - signal, - untracked, -} from '@angular/core'; +import { Injectable, effect, inject, signal, untracked } from '@angular/core'; import { AsyncHandler, currentUser, log, randomInt } from '@placeos/common'; import { ChatService } from '@placeos/components'; @@ -93,13 +86,6 @@ export class VoiceAssistantService extends AsyncHandler { public readonly error = this._error.asReadonly(); public readonly state = this._state.asReadonly(); public readonly progress = this._chat_service.progress; - public readonly waiting = computed(() => { - const list = this._chat_service.messages(); - return ( - list.length !== 0 && - list[list.length - 1]?.user_id === currentUser()?.id - ); - }); private _mic_levels = new MicLevels(); /** True when `readLevels()` returns live microphone levels */ 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 1e6bc7c3262..54e83ec2bba 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 @@ -1,21 +1,11 @@ -import { - Component, - computed, - inject, - input, - output, - signal, -} from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; 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'; import { DialpadComponent } from '../ui/dialpad.component'; import { VideoCallStateService } from './video-call-state.service'; @@ -26,7 +16,7 @@ import { VideoCallStateService } from './video-call-state.service'; @if (!loading()) { -
+

{{ 'APP.CONTROL.VC_ENTER_CODE' | translate }}

@@ -103,27 +93,16 @@ import { VideoCallStateService } from './video-call-state.service'; ], }) export class VideoCallDialViewComponent { - private _control = inject(ControlStateService); private _call = inject(VideoCallStateService); - private _router = inject(Router); - private _route = inject(ActivatedRoute); - - public readonly redirect = input(true); - public readonly close = output(); public readonly dial_number = signal(''); public readonly loading = signal(false); - public readonly call = this._call.call; private readonly _show_camera_pip = this._call.show_camera_pip; public readonly show_camera_pip = computed(() => !!this._show_camera_pip()); public readonly toggleCamera = async () => this._call.showCameraPIP(!this.show_camera_pip()); - public get id() { - return this._control.id; - } - public addDigit(digit: string) { digit && digit !== '\b' ? this.dial_number.update((v) => v + digit) @@ -133,21 +112,15 @@ export class VideoCallDialViewComponent { public async joinConference() { const dial_number = this.dial_number(); if (!dial_number) return; - const system_id = this._control.id; - const mod = getModule(system_id, 'VidConf'); this.loading.set(true); try { - await mod.execute('dial', [dial_number]); + await this._call.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 }); - } - this.close.emit(); this.dial_number.set(''); } } 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 50ab5e5eb82..bc668c5224d 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 @@ -22,8 +22,8 @@ import { notifyError, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; -import { getModule } from '@placeos/ts-client'; import { ControlStateService } from '../control-state.service'; +import { selectCamera } from '../ui/camera-commands'; import { DialpadComponent } from '../ui/dialpad.component'; import { PresentationMode, @@ -320,9 +320,7 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit { } public selectCamera(camera: string) { - const mod = getModule(this._control.id, 'System'); - if (!mod) return; - mod.execute('selected_camera', [camera]); + selectCamera(this._control.id, camera); } /** Leave the page once. A local hang-up and the call status clearing both end up here. */ 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 27e303c2c6f..d2e16868302 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 @@ -1,14 +1,8 @@ -import { - computed, - effect, - inject, - Injectable, - signal, - Signal, -} from '@angular/core'; -import { AsyncHandler, i18n, notifyError } from '@placeos/common'; +import { computed, inject, Injectable, Signal } from '@angular/core'; +import { i18n, notifyError } from '@placeos/common'; import { getModule } from '@placeos/ts-client'; import { ControlStateService } from '../control-state.service'; +import { systemBinding } from '../system-binding'; export type VideoLayout = 'Auto' | 'Equal' | 'Overlay' | 'Prominent' | 'Single'; export type PresentationMode = 'None' | 'Local' | 'Remote'; @@ -50,7 +44,7 @@ const INACTIVE_STATUSES: readonly CallStatus[] = ['Idle', 'Disconnecting']; @Injectable({ providedIn: 'root', }) -export class VideoCallStateService extends AsyncHandler { +export class VideoCallStateService { private _control = inject(ControlStateService); public readonly connected = this._bindTo( @@ -97,6 +91,13 @@ export class VideoCallStateService extends AsyncHandler { return this._exec('presentation_mode', [mode]); } + /** Dial a number. Rejects when the codec fails to dial. */ + public async dial(number: string) { + const id = this._control.id; + if (!id) return; + return getModule(id, 'VidConf').execute('dial', [number]); + } + public async hangup() { const id = this._control.id; if (!id) return; @@ -126,29 +127,13 @@ export class VideoCallStateService extends AsyncHandler { } } - /** - * Create an Angular signal that mirrors a video conferencing status - * variable binding, rebinding whenever the active system changes. - */ - private _bindTo(name: string, mod_name = 'VidConf'): Signal { - const value = signal(null); - effect((onCleanup) => { - const id = this._control.system_id(); - if (!id) { - value.set(null); - return; - } - const binding = getModule(id, mod_name).variable(name); - const unbind = binding.bind(); - const listener = binding.listen(); - const update = () => value.set(listener() ?? null); - update(); - const unsubscribe = listener.subscribe(() => update()); - onCleanup(() => { - unsubscribe(); - unbind(); - }); - }); - return value.asReadonly(); + /** Signal that mirrors a VidConf status variable on the active system */ + private _bindTo(name: string): Signal { + return systemBinding( + this._control.system_id, + 'VidConf', + name, + null, + ); } } diff --git a/apps/control/src/app/video-call/video-call-view.component.ts b/apps/control/src/app/video-call/video-call-view.component.ts index 267f6988f07..c6ea0352541 100644 --- a/apps/control/src/app/video-call/video-call-view.component.ts +++ b/apps/control/src/app/video-call/video-call-view.component.ts @@ -2,15 +2,15 @@ import { Component, effect, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { TranslatePipe } from '@placeos/components'; import { ControlStateService } from '../control-state.service'; import { ControlStatusBarComponent } from '../status-bar.component'; import { TopbarHeaderComponent } from '../topbar-header.component'; +import { ControlConnectingComponent } from '../ui/connecting.component'; +import { SplashComponent } from '../ui/splash.component'; import { VideoCallPageComponent } from './video-call-page.component'; @Component({ - selector: 'app-control-main-view', + selector: 'app-control-video-call-view', template: ` @if (system()?.connected) { @if (system()?.active) { @@ -25,28 +25,10 @@ import { VideoCallPageComponent } from './video-call-page.component';
} @else { -
-

- {{ 'APP.CONTROL.TOUCH_TO_START' | translate }} -

-

{{ system()?.name }}

-
+ } } @else { -
- -
- {{ 'APP.CONTROL.CONNECTING' | translate: { id: id() } }} -
-
-
+ } `, styles: [ @@ -62,23 +44,14 @@ import { VideoCallPageComponent } from './video-call-page.component'; background-color: var(--primary); color: #fff; } - - [name='loader'] { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 304 304' width='304' height='304'%3E%3Cpath fill='%23000' fill-opacity='0.05' d='M44.1 224a5 5 0 1 1 0 2H0v-2h44.1zm160 48a5 5 0 1 1 0 2H82v-2h122.1zm57.8-46a5 5 0 1 1 0-2H304v2h-42.1zm0 16a5 5 0 1 1 0-2H304v2h-42.1zm6.2-114a5 5 0 1 1 0 2h-86.2a5 5 0 1 1 0-2h86.2zm-256-48a5 5 0 1 1 0 2H0v-2h12.1zm185.8 34a5 5 0 1 1 0-2h86.2a5 5 0 1 1 0 2h-86.2zM258 12.1a5 5 0 1 1-2 0V0h2v12.1zm-64 208a5 5 0 1 1-2 0v-54.2a5 5 0 1 1 2 0v54.2zm48-198.2V80h62v2h-64V21.9a5 5 0 1 1 2 0zm16 16V64h46v2h-48V37.9a5 5 0 1 1 2 0zm-128 96V208h16v12.1a5 5 0 1 1-2 0V210h-16v-76.1a5 5 0 1 1 2 0zm-5.9-21.9a5 5 0 1 1 0 2H114v48H85.9a5 5 0 1 1 0-2H112v-48h12.1zm-6.2 130a5 5 0 1 1 0-2H176v-74.1a5 5 0 1 1 2 0V242h-60.1zm-16-64a5 5 0 1 1 0-2H114v48h10.1a5 5 0 1 1 0 2H112v-48h-10.1zM66 284.1a5 5 0 1 1-2 0V274H50v30h-2v-32h18v12.1zM236.1 176a5 5 0 1 1 0 2H226v94h48v32h-2v-30h-48v-98h12.1zm25.8-30a5 5 0 1 1 0-2H274v44.1a5 5 0 1 1-2 0V146h-10.1zm-64 96a5 5 0 1 1 0-2H208v-80h16v-14h-42.1a5 5 0 1 1 0-2H226v18h-16v80h-12.1zm86.2-210a5 5 0 1 1 0 2H272V0h2v32h10.1zM98 101.9V146H53.9a5 5 0 1 1 0-2H96v-42.1a5 5 0 1 1 2 0zM53.9 34a5 5 0 1 1 0-2H80V0h2v34H53.9zm60.1 3.9V66H82v64H69.9a5 5 0 1 1 0-2H80V64h32V37.9a5 5 0 1 1 2 0zM101.9 82a5 5 0 1 1 0-2H128V37.9a5 5 0 1 1 2 0V82h-28.1zm16-64a5 5 0 1 1 0-2H146v44.1a5 5 0 1 1-2 0V18h-26.1zm102.2 270a5 5 0 1 1 0 2H98v14h-2v-16h124.1zM242 149.9V160h16v34h-16v62h48v48h-2v-46h-48v-66h16v-30h-16v-12.1a5 5 0 1 1 2 0zM53.9 18a5 5 0 1 1 0-2H64V2H48V0h18v18H53.9zm112 32a5 5 0 1 1 0-2H192V0h50v2h-48v48h-28.1zm-48-48a5 5 0 0 1-9.8-2h2.07a3 3 0 1 0 5.66 0H178v34h-18V21.9a5 5 0 1 1 2 0V32h14V2h-58.1zm0 96a5 5 0 1 1 0-2H137l32-32h39V21.9a5 5 0 1 1 2 0V66h-40.17l-32 32H117.9zm28.1 90.1a5 5 0 1 1-2 0v-76.51L175.59 80H224V21.9a5 5 0 1 1 2 0V82h-49.59L146 112.41v75.69zm16 32a5 5 0 1 1-2 0v-99.51L184.59 96H300.1a5 5 0 0 1 3.9-3.9v2.07a3 3 0 0 0 0 5.66v2.07a5 5 0 0 1-3.9-3.9H185.41L162 121.41v98.69zm-144-64a5 5 0 1 1-2 0v-3.51l48-48V48h32V0h2v50H66v55.41l-48 48v2.69zM50 53.9v43.51l-48 48V208h26.1a5 5 0 1 1 0 2H0v-65.41l48-48V53.9a5 5 0 1 1 2 0zm-16 16V89.41l-34 34v-2.82l32-32V69.9a5 5 0 1 1 2 0zM12.1 32a5 5 0 1 1 0 2H9.41L0 43.41V40.6L8.59 32h3.51zm265.8 18a5 5 0 1 1 0-2h18.69l7.41-7.41v2.82L297.41 50H277.9zm-16 160a5 5 0 1 1 0-2H288v-71.41l16-16v2.82l-14 14V210h-28.1zm-208 32a5 5 0 1 1 0-2H64v-22.59L40.59 194H21.9a5 5 0 1 1 0-2H41.41L66 216.59V242H53.9zm150.2 14a5 5 0 1 1 0 2H96v-56.6L56.6 162H37.9a5 5 0 1 1 0-2h19.5L98 200.6V256h106.1zm-150.2 2a5 5 0 1 1 0-2H80v-46.59L48.59 178H21.9a5 5 0 1 1 0-2H49.41L82 208.59V258H53.9zM34 39.8v1.61L9.41 66H0v-2h8.59L32 40.59V0h2v39.8zM2 300.1a5 5 0 0 1 3.9 3.9H3.83A3 3 0 0 0 0 302.17V256h18v48h-2v-46H2v42.1zM34 241v63h-2v-62H0v-2h34v1zM17 18H0v-2h16V0h2v18h-1zm273-2h14v2h-16V0h2v16zm-32 273v15h-2v-14h-14v14h-2v-16h18v1zM0 92.1A5.02 5.02 0 0 1 6 97a5 5 0 0 1-6 4.9v-2.07a3 3 0 1 0 0-5.66V92.1zM80 272h2v32h-2v-32zm37.9 32h-2.07a3 3 0 0 0-5.66 0h-2.07a5 5 0 0 1 9.8 0zM5.9 0A5.02 5.02 0 0 1 0 5.9V3.83A3 3 0 0 0 3.83 0H5.9zm294.2 0h2.07A3 3 0 0 0 304 3.83V5.9a5 5 0 0 1-3.9-5.9zm3.9 300.1v2.07a3 3 0 0 0-1.83 1.83h-2.07a5 5 0 0 1 3.9-3.9zM97 100a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0-16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-48 32a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm32 48a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-16 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm32-16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0-32a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16 32a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm32 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0-16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-16-64a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16 96a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16-144a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 32a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16-32a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16-16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-96 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16-32a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm96 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-16-64a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16-16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-32 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0-16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-16 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-16 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-16 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM49 36a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-32 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm32 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM33 68a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16-48a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 240a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16 32a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-16-64a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-16-32a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm80-176a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-16-16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm32 48a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16-16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0-32a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm112 176a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-16 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM17 180a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0-32a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM17 84a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm32 64a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm16-16a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'%3E%3C/path%3E%3C/svg%3E"); - } - - [name='splash'] { - animation: crossfade 10s linear; - animation-iteration-count: infinite; - } `, ], imports: [ TopbarHeaderComponent, VideoCallPageComponent, ControlStatusBarComponent, - MatProgressSpinnerModule, - TranslatePipe, + ControlConnectingComponent, + SplashComponent, ], }) export class ControlVideoCallViewComponent { @@ -94,7 +67,6 @@ export class ControlVideoCallViewComponent { public readonly system = this._state.system; - public readonly powerOn = () => this._state.powerOn(); public readonly id = this._state.system_id; constructor() { diff --git a/apps/control/src/tests/bootstrap.component.spec.ts b/apps/control/src/tests/bootstrap.component.spec.ts index 7a8fe6afb1e..60f0f98d511 100644 --- a/apps/control/src/tests/bootstrap.component.spec.ts +++ b/apps/control/src/tests/bootstrap.component.spec.ts @@ -11,9 +11,7 @@ import { 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 }); @@ -22,13 +20,7 @@ describe('BootstrapComponent', () => { let spectator: SpectatorRouting; const createComponent = createRoutingFactory({ component: BootstrapComponent, - providers: [ - MockProvider(SpacesService, { - initialised: of(true), - space_list: [{ id: '1', name: 'Space 1' }], - } as any), - MockProvider(SettingsService, { get: vi.fn() }), - ], + providers: [MockProvider(SettingsService, { get: vi.fn() })], imports: [ FormsModule, MockModule(MatAutocompleteModule), @@ -66,7 +58,7 @@ describe('BootstrapComponent', () => { expect('p.description').toExist(); const button: HTMLButtonElement = spectator.query('button'); expect(button).toBeTruthy(); - // expect(button.disabled).toBeTruthy(); + expect(button.disabled).toBeTruthy(); spectator.component.system_id.set('sys-B0'); spectator.detectChanges(); expect(button.disabled).toBeFalsy(); diff --git a/apps/control/src/tests/control-state.service.spec.ts b/apps/control/src/tests/control-state.service.spec.ts index e7be40140f5..8658167170e 100644 --- a/apps/control/src/tests/control-state.service.spec.ts +++ b/apps/control/src/tests/control-state.service.spec.ts @@ -25,7 +25,10 @@ describe('ControlStateService', () => { { provide: CalendarService, useValue: { calendars: of([]) } }, { provide: SpacesService, - useValue: { loadSpaces: vi.fn(), loadSpace: vi.fn() }, + useValue: { + loadSpaces: vi.fn(), + loadSpace: vi.fn(async () => null), + }, }, ], }); diff --git a/apps/control/src/tests/main-view.component.spec.ts b/apps/control/src/tests/main-view.component.spec.ts index e8a878c20e5..b634a60c50d 100644 --- a/apps/control/src/tests/main-view.component.spec.ts +++ b/apps/control/src/tests/main-view.component.spec.ts @@ -8,8 +8,8 @@ import { ChangelogService } from '@placeos/components'; import { MockComponent, MockProvider } from 'ng-mocks'; import { ControlStateService } from '../app/control-state.service'; +import { ControlAdvancedViewComponent } from '../app/advanced-view.component'; import { ControlMainViewComponent } from '../app/main-view.component'; -import { ControlPageViewComponent } from '../app/page-view.component'; import { ControlStatusBarComponent } from '../app/status-bar.component'; import { TopbarHeaderComponent } from '../app/topbar-header.component'; import { ControlConnectingComponent } from '../app/ui/connecting.component'; @@ -28,7 +28,7 @@ describe('ControlMainViewComponent', () => { params: { system: 'space-0' }, declarations: [ MockComponent(TopbarHeaderComponent), - MockComponent(ControlPageViewComponent), + MockComponent(ControlAdvancedViewComponent), MockComponent(ControlStatusBarComponent), MockComponent(ControlConnectingComponent), MockComponent(NextMeetingComponent), @@ -79,7 +79,7 @@ describe('ControlMainViewComponent', () => { spectator.detectChanges(); expect('[name="splash"]').not.toExist(); expect('topbar-header').toExist(); - expect('[control-page-view]').toExist(); + expect('control-advanced-view').toExist(); expect('control-status-bar').toExist(); }); diff --git a/apps/control/src/tests/page-view.component.spec.ts b/apps/control/src/tests/page-view.component.spec.ts deleted file mode 100644 index 913e52f3d34..00000000000 --- a/apps/control/src/tests/page-view.component.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; -import { ControlAdvancedViewComponent } from '../app/advanced-view.component'; - -import { ControlPageViewComponent } from '../app/page-view.component'; -import { SourceSelectComponent } from '../app/ui/source-select.component'; - -describe('ControlPageViewComponent', () => { - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: ControlPageViewComponent, - declarations: [ - MockComponent(SourceSelectComponent), - MockComponent(ControlAdvancedViewComponent), - ], - }); - - beforeEach(() => (spectator = createComponent())); - - it('should show basic view', () => { - expect('source-select').not.toExist(); - spectator.component.view.set('basic'); - spectator.detectChanges(); - expect('source-select').toExist(); - }); - - it('should show advanced view', () => { - expect('control-advanced-view').toExist(); - spectator.component.view.set('basic'); - spectator.detectChanges(); - expect('control-advanced-view').not.toExist(); - }); -}); diff --git a/apps/control/src/tests/status-bar.component.spec.ts b/apps/control/src/tests/status-bar.component.spec.ts index abb06f1f273..88397eda9bf 100644 --- a/apps/control/src/tests/status-bar.component.spec.ts +++ b/apps/control/src/tests/status-bar.component.spec.ts @@ -26,7 +26,6 @@ describe('ControlStatusBarComponent', () => { ], providers: [ MockProvider(ControlStateService, { - volume: signal(0), system: signal({}), capture_list: signal([]), has_master_audio: signal(true), diff --git a/apps/control/src/tests/tabbed-view/output-list-item.component.spec.ts b/apps/control/src/tests/tabbed-view/output-list-item.component.spec.ts index c42e9aa1672..f8b314c6dca 100644 --- a/apps/control/src/tests/tabbed-view/output-list-item.component.spec.ts +++ b/apps/control/src/tests/tabbed-view/output-list-item.component.spec.ts @@ -22,8 +22,6 @@ describe('DeviceOutputListItemComponent', () => { useValue: { available_inputs: signal([]), system: signal({}), - setVolume: vi.fn(), - setRoute: vi.fn(), unroute: vi.fn(), setOutput: vi.fn(), }, @@ -105,14 +103,4 @@ describe('DeviceOutputListItemComponent', () => { expect(service.unroute).toHaveBeenCalledWith('o1'); expect(service.setOutput).not.toHaveBeenCalled(); }); - - it('should route to input when setting mute state off', () => { - const service: any = spectator.inject(ControlStateService); - spectator.setInput('item', { id: 'o1', name: 'o1' } as any); - spectator.detectChanges(); - spectator.component.last_input = 'i5'; - spectator.component.setMute('i7', false); - expect(service.setRoute).toHaveBeenCalledWith('i5', 'o1'); - expect(spectator.component.last_input).toBe('i7'); - }); }); 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 ad980c9093d..07d115d9fc8 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 @@ -11,7 +11,10 @@ import { IconComponent, TranslatePipe, } from '@placeos/components'; -import { ControlStateService } from '../../app/control-state.service'; +import { + ControlStateService, + RoomInput, +} from '../../app/control-state.service'; import { DeviceOutputListComponent } from '../../app/tabbed-view/output-list.component'; import { TabOutletComponent } from '../../app/tabbed-view/tab-outlet.component'; import { TVControlsComponent } from '../../app/tabbed-view/tv-controls.component'; @@ -121,7 +124,7 @@ describe('TabOutletComponent', () => { it('should set the output source when an input is chosen', () => { const service: any = spectator.inject(ControlStateService); - spectator.component.setInput({ id: 'i9' }); + spectator.component.setInput({ id: 'i9' } as RoomInput); expect(service.setOutputSource).toHaveBeenCalledWith('i9'); }); 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 ea26ab037cc..232e9a7f58c 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 @@ -116,7 +116,10 @@ describe('ControlTabbedViewComponent', () => { }); it('should open the deployed changelog', () => { - spectator.component.viewChangelog(); + const service: any = spectator.inject(ControlStateService); + service.system.set({ connected: true }); + spectator.detectChanges(); + spectator.click('[name="splash"] button'); expect(changelog.view).toHaveBeenCalled(); }); diff --git a/apps/control/src/tests/ui/camera-controls.component.spec.ts b/apps/control/src/tests/ui/camera-controls.component.spec.ts index 5b9c582ade9..4c25d1205bd 100644 --- a/apps/control/src/tests/ui/camera-controls.component.spec.ts +++ b/apps/control/src/tests/ui/camera-controls.component.spec.ts @@ -10,10 +10,8 @@ vi.mock('@placeos/ts-client', { spy: true }); import { IconComponent } from '@placeos/components'; import * as client from '@placeos/ts-client'; import { ControlStateService } from '../../app/control-state.service'; -import { - CameraControlsComponent, - ZoomDirection, -} from '../../app/ui/camera-controls.component'; +import { ZoomDirection } from '../../app/ui/camera-commands'; +import { CameraControlsComponent } from '../../app/ui/camera-controls.component'; import { JoystickComponent, JoystickPan, @@ -87,23 +85,6 @@ describe('CameraControlsComponent', () => { expect(execute_fn).toHaveBeenCalledWith('selected_camera', ['cam3']); }); - it('should recall a preset on the active camera module', () => { - spectator.component.active_camera.set({ - id: 'cam1', - name: 'Camera 1', - mod: 'Camera_1', - } as any); - spectator.component.recallPreset('preset-1'); - expect(client.getModule).toHaveBeenCalledWith('sys-1', 'Camera_1'); - expect(execute_fn).toHaveBeenCalledWith('recall', ['preset-1']); - }); - - it('should not recall a preset when no active camera', () => { - spectator.component.active_camera.set(undefined); - spectator.component.recallPreset('preset-1'); - expect(execute_fn).not.toHaveBeenCalled(); - }); - it('should stop then pan/tilt when moving the camera', async () => { spectator.component.active_camera.set({ id: 'cam1', diff --git a/apps/control/src/tests/ui/camera-tooltip.component.spec.ts b/apps/control/src/tests/ui/camera-tooltip.component.spec.ts index 7a6de4e8216..e51659ea5cf 100644 --- a/apps/control/src/tests/ui/camera-tooltip.component.spec.ts +++ b/apps/control/src/tests/ui/camera-tooltip.component.spec.ts @@ -11,10 +11,8 @@ import { ControlStateService, RoomInput, } from '../../app/control-state.service'; -import { - CameraTooltipComponent, - ZoomDirection, -} from '../../app/ui/camera-tooltip.component'; +import { ZoomDirection } from '../../app/ui/camera-commands'; +import { CameraTooltipComponent } from '../../app/ui/camera-tooltip.component'; import { JoystickComponent, JoystickPan, @@ -92,30 +90,6 @@ describe('CameraTooltipComponent', () => { expect(spectator.component.active_camera()).toBeUndefined(); }); - it('should allow for user to select a camera', () => { - // const cam_list = [{ id: 'cam1', name: 'Camera 1' }] as any; - // expect('p[empty]').toExist(); - // const service = spectator.inject(ControlStateService); - // (service as any).camera_list.set(cam_list); - // spectator.detectChanges(); - // expect('p[empty]').not.toExist(); - // expect('p[no-cam]').not.toExist(); - // spectator.component.selectCamera(cam_list[0]); - // spectator.detectChanges(); - // expect('p[no-cam]').not.toExist(); - }); - - it('should show camera joystick', () => { - // const service = spectator.inject(ControlStateService); - // (service as any).camera_list.set([]); - // spectator.detectChanges(); - // expect('p[empty]').toExist(); - // expect('joystick').not.toExist(); - // (service as any).camera_list.set([{ id: 'cam1', name: 'Camera 1' }]); - // spectator.detectChanges(); - // expect('joystick').toExist(); - }); - it('should stop before moving so a released axis does not keep moving', async () => { const execute = mockExecute(); spectator.component.active_camera.set({ @@ -147,20 +121,17 @@ describe('CameraTooltipComponent', () => { expect(execute).toHaveBeenLastCalledWith('zoom', [ZoomDirection.Stop]); }); - it('should allow user to select camera presets', () => { - // const cam_list = [{ id: 'cam1', name: 'Camera 1' }] as any; - // const service = spectator.inject(ControlStateService); - // (service as any).camera_list.set(cam_list); - // spectator.component.selectCamera(cam_list[0]); - // spectator.detectChanges(); - // expect('p[preset]').toExist(); - // spectator.component.presets = ['One', 'Two', 'Three']; - // spectator.detectChanges(); - // expect('p').not.toExist(); - // expect('button[preset].inverse').not.toExist(); - // expect('button[preset]').toHaveLength(3); - // spectator.click('button[preset]'); - // expect('button[preset].inverse').toExist(); - // expect('button[preset].inverse').toContainText('One'); + it('should highlight the last recalled preset', () => { + mockExecute(); + available_cameras.set([ + { id: 'cam1', name: 'Camera 1', mod: 'Camera_1' }, + ]); + selected_camera.set('cam1'); + spectator.component.presets.set(['Wide', 'Desk']); + spectator.detectChanges(); + expect('button[preset].inverse').toHaveLength(2); + spectator.component.recallPreset('Desk'); + spectator.detectChanges(); + expect('button[preset]:not(.inverse)').toHaveText('Desk'); }); }); diff --git a/apps/control/src/tests/ui/phone-dialling-tooltip.component.spec.ts b/apps/control/src/tests/ui/phone-dialling-tooltip.component.spec.ts index 780b86f09f7..d5bdff22479 100644 --- a/apps/control/src/tests/ui/phone-dialling-tooltip.component.spec.ts +++ b/apps/control/src/tests/ui/phone-dialling-tooltip.component.spec.ts @@ -44,10 +44,6 @@ describe('PhoneDiallingTooltipComponent', () => { spectator = createComponent(); }); - it('should expose the system id', () => { - expect(spectator.component.sys_id).toBe('sys-1'); - }); - it('should send the dialpad character to the System module on input', async () => { await spectator.component.handleInput('5'); expect(client.getModule).toHaveBeenCalledWith('sys-1', 'System'); 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 e0f4027d255..20a9ab28204 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 @@ -15,6 +15,7 @@ describe('SelectMeetingModalComponent', () => { let calendars: ReturnType>; let events: ReturnType>; let calendar: ReturnType>; + let events_loading: ReturnType>; let service: any; let dialog_ref: { close: any }; let dialog_open: any; @@ -38,9 +39,11 @@ describe('SelectMeetingModalComponent', () => { { title: 'Standup', date: Date.now(), organiser: { name: 'Ada' } }, ]); calendar = signal(calendars()[0]); + events_loading = signal(false); service = { calendars, events, + events_loading, calendar, setCalendar: vi.fn((c) => calendar.set(c)), setEvent: vi.fn(async () => undefined), @@ -82,7 +85,7 @@ describe('SelectMeetingModalComponent', () => { }); it('should show a loading spinner while loading', () => { - spectator.component.loading.set(true); + events_loading.set(true); spectator.detectChanges(); expect('mat-spinner').toExist(); expect(spectator.query('button[btn]')).not.toExist(); 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 00b808b93a6..eb9531f84d9 100644 --- a/apps/control/src/tests/ui/voice-assistant.service.spec.ts +++ b/apps/control/src/tests/ui/voice-assistant.service.spec.ts @@ -240,12 +240,4 @@ describe('VoiceAssistantService', () => { recognition.onerror({ error: 'not-allowed' }); expect(spectator.service.error().speech_recognition).toBe(true); }); - - it('should report waiting when the last message is from the current user', () => { - expect(spectator.service.waiting()).toBe(false); - messages.set([{ id: 'm1', user_id: 'user-1', message: 'hi' }]); - expect(spectator.service.waiting()).toBe(true); - messages.set([{ id: 'm2', user_id: 'other', message: 'reply' }]); - expect(spectator.service.waiting()).toBe(false); - }); }); 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 a329dfc5284..2df2b6af45a 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 @@ -3,29 +3,21 @@ import { FormsModule } from '@angular/forms'; 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 { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockComponent, MockModule, MockPipe } from 'ng-mocks'; import { IconComponent, TranslatePipe } from '@placeos/components'; -import { ControlStateService } from '../../app/control-state.service'; import { DialpadComponent } from '../../app/ui/dialpad.component'; import { VideoCallDialViewComponent } from '../../app/video-call/video-call-dial-view.component'; import { VideoCallStateService } from '../../app/video-call/video-call-state.service'; -vi.mock('@placeos/ts-client', { spy: true }); - -import * as client from '@placeos/ts-client'; - describe('VideoCallDialViewComponent', () => { let spectator: Spectator; - let execute_spy: any; - const router_mock = { navigate: vi.fn() }; - const show_camera_pip = signal(null); - const call_state: any = { - call: signal(null), + const show_camera_pip = signal(null); + const call_state = { show_camera_pip, showCameraPIP: vi.fn(), + dial: vi.fn(), }; const createComponent = createComponentFactory({ @@ -41,22 +33,13 @@ describe('VideoCallDialViewComponent', () => { MockModule(MatInputModule), MockModule(MatProgressSpinnerModule), ], - providers: [ - { provide: ControlStateService, useValue: { id: 'sys-1' } }, - { provide: VideoCallStateService, useValue: call_state }, - { provide: Router, useValue: router_mock }, - { provide: ActivatedRoute, useValue: {} }, - ], + providers: [{ provide: VideoCallStateService, useValue: call_state }], }); beforeEach(() => { - execute_spy = vi.fn().mockResolvedValue(null); show_camera_pip.set(null); - router_mock.navigate.mockClear(); call_state.showCameraPIP.mockClear(); - (client.getModule as any).mockImplementation(() => ({ - execute: execute_spy, - })); + call_state.dial.mockReset().mockResolvedValue(null); spectator = createComponent(); }); @@ -81,43 +64,21 @@ describe('VideoCallDialViewComponent', () => { expect(spectator.query('button[btn]')).not.toBeDisabled(); }); - it('should dial the entered number and navigate to the call page', async () => { + it('should dial the entered number and clear it', async () => { spectator.component.dial_number.set('5551234'); await spectator.component.joinConference(); - expect(client.getModule).toHaveBeenCalledWith('sys-1', 'VidConf'); - expect(execute_spy).toHaveBeenCalledWith('dial', ['5551234']); - expect(router_mock.navigate).toHaveBeenCalledWith( - ['call'], - expect.any(Object), - ); + expect(call_state.dial).toHaveBeenCalledWith('5551234'); expect(spectator.component.dial_number()).toBe(''); }); - - it('should not navigate when redirect is disabled', async () => { - spectator.setInput({ redirect: false }); - spectator.component.dial_number.set('5551234'); - await spectator.component.joinConference(); - expect(execute_spy).toHaveBeenCalledWith('dial', ['5551234']); - expect(router_mock.navigate).not.toHaveBeenCalled(); - }); - - it('should emit close after joining a conference', async () => { - const close_spy = vi.fn(); - spectator.output('close').subscribe(close_spy); - spectator.component.dial_number.set('5551234'); - await spectator.component.joinConference(); - expect(close_spy).toHaveBeenCalled(); - }); - it('should do nothing when joining with an empty number', async () => { spectator.component.dial_number.set(''); await spectator.component.joinConference(); - expect(execute_spy).not.toHaveBeenCalled(); + expect(call_state.dial).not.toHaveBeenCalled(); }); it('should reflect the camera PIP state from the call service', () => { expect(spectator.component.show_camera_pip()).toBe(false); - show_camera_pip.set({ foo: 'bar' }); + show_camera_pip.set(true); spectator.detectChanges(); expect(spectator.component.show_camera_pip()).toBe(true); }); @@ -125,14 +86,14 @@ describe('VideoCallDialViewComponent', () => { it('should toggle the camera PIP through the call service', async () => { await spectator.component.toggleCamera(); expect(call_state.showCameraPIP).toHaveBeenCalledWith(true); - show_camera_pip.set({ on: true }); + show_camera_pip.set(true); spectator.detectChanges(); 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')); + call_state.dial.mockRejectedValue(new Error('busy')); spectator.component.dial_number.set('1234'); await spectator.component.joinConference(); expect(spectator.component.loading()).toBe(false); 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 8463fd5fa18..91dc82d800b 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 @@ -178,10 +178,4 @@ describe('VideoCallPageComponent', () => { expect(client.getModule).toHaveBeenCalledWith('sys-1', 'System'); expect(execute_spy).toHaveBeenCalledWith('selected_camera', ['cam-1']); }); - - it('should not execute when the System module is unavailable', () => { - (client.getModule as any).mockReturnValue(null); - spectator.component.selectCamera('cam-1'); - expect(execute_spy).not.toHaveBeenCalled(); - }); }); diff --git a/apps/control/src/tests/video-call/video-call-view.component.spec.ts b/apps/control/src/tests/video-call/video-call-view.component.spec.ts index 9a262bf559f..ed332c74fc0 100644 --- a/apps/control/src/tests/video-call/video-call-view.component.spec.ts +++ b/apps/control/src/tests/video-call/video-call-view.component.spec.ts @@ -4,12 +4,13 @@ import { createRoutingFactory, SpectatorRouting, } from '@ngneat/spectator/vitest'; -import { MockComponent, MockPipe } from 'ng-mocks'; +import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; -import { TranslatePipe } from '@placeos/components'; +import { ChangelogService, TranslatePipe } from '@placeos/components'; import { ControlStateService } from '../../app/control-state.service'; import { ControlStatusBarComponent } from '../../app/status-bar.component'; import { TopbarHeaderComponent } from '../../app/topbar-header.component'; +import { ControlConnectingComponent } from '../../app/ui/connecting.component'; import { VideoCallPageComponent } from '../../app/video-call/video-call-page.component'; import { ControlVideoCallViewComponent } from '../../app/video-call/video-call-view.component'; @@ -30,10 +31,17 @@ describe('ControlVideoCallViewComponent', () => { MockComponent(TopbarHeaderComponent), MockComponent(VideoCallPageComponent), MockComponent(ControlStatusBarComponent), + MockComponent(ControlConnectingComponent), MockPipe(TranslatePipe, (v) => v), ], imports: [MatProgressSpinnerModule], - providers: [{ provide: ControlStateService, useValue: state_mock }], + providers: [ + { provide: ControlStateService, useValue: state_mock }, + MockProvider(ChangelogService, { + available: signal(false).asReadonly(), + view: vi.fn(), + }), + ], }); beforeEach(() => { @@ -49,10 +57,10 @@ describe('ControlVideoCallViewComponent', () => { }); it('should show the loader until the system is connected', () => { - expect('[name="loader"]').toExist(); + expect('control-connecting').toExist(); state_mock.system.set({ connected: true }); spectator.detectChanges(); - expect('[name="loader"]').not.toExist(); + expect('control-connecting').not.toExist(); }); it('should show the splash screen and power on when tapped', () => { From 6ed913d340789907ea1cc883c0e7e752ea291236 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 1 Oct 2026 15:33:02 +1000 Subject: [PATCH 2/3] fix(control): restore unknown system redirect and e2e splash selector - Return to bootstrap when the system does not exist (404). Clear the stored system ID first, so bootstrap does not open it again. The removed space resource used to redirect, but looped back. - Select the splash by `[name="splash"]` in the e2e specs. The splash is now the `control-splash` element, not a `div`. --- apps/control/e2e/additional-features.spec.ts | 4 +-- apps/control/e2e/camera-control.spec.ts | 2 +- apps/control/e2e/lighting-control.spec.ts | 2 +- apps/control/e2e/phone-recording.spec.ts | 2 +- apps/control/e2e/power-control.spec.ts | 18 +++++----- apps/control/e2e/room-joining.spec.ts | 2 +- apps/control/e2e/source-management.spec.ts | 2 +- apps/control/e2e/video-conferencing.spec.ts | 2 +- apps/control/e2e/volume-control.spec.ts | 2 +- apps/control/src/app/control-state.service.ts | 19 +++++++++- .../src/tests/control-state.service.spec.ts | 36 ++++++++++++++++--- 11 files changed, 67 insertions(+), 24 deletions(-) diff --git a/apps/control/e2e/additional-features.spec.ts b/apps/control/e2e/additional-features.spec.ts index ecea753f7a3..d2c185cdf56 100644 --- a/apps/control/e2e/additional-features.spec.ts +++ b/apps/control/e2e/additional-features.spec.ts @@ -18,7 +18,7 @@ async function ensurePoweredOn(page) { .waitFor({ timeout: LOAD_TIMEOUT }); // Wait for system to be connected (splash or topbar visible) - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); await Promise.race([ @@ -65,7 +65,7 @@ test.describe('US-038: Activate Voice Control', () => { .locator('app-control-tabbed-view') .waitFor({ timeout: LOAD_TIMEOUT }); - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); if (await splash.isVisible().catch(() => false)) { const voiceAssistant = splash.locator('voice-assistant'); diff --git a/apps/control/e2e/camera-control.spec.ts b/apps/control/e2e/camera-control.spec.ts index b254d88681d..a51dc82cc75 100644 --- a/apps/control/e2e/camera-control.spec.ts +++ b/apps/control/e2e/camera-control.spec.ts @@ -18,7 +18,7 @@ async function ensurePoweredOn(page) { .waitFor({ timeout: LOAD_TIMEOUT }); // Wait for system to be connected (splash or topbar visible) - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); await Promise.race([ diff --git a/apps/control/e2e/lighting-control.spec.ts b/apps/control/e2e/lighting-control.spec.ts index 7447eef4d81..a9e618244ef 100644 --- a/apps/control/e2e/lighting-control.spec.ts +++ b/apps/control/e2e/lighting-control.spec.ts @@ -18,7 +18,7 @@ async function ensurePoweredOn(page) { .waitFor({ timeout: LOAD_TIMEOUT }); // Wait for system to be connected (splash or topbar visible) - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); await Promise.race([ diff --git a/apps/control/e2e/phone-recording.spec.ts b/apps/control/e2e/phone-recording.spec.ts index 1011b52ac15..d6c2aea607a 100644 --- a/apps/control/e2e/phone-recording.spec.ts +++ b/apps/control/e2e/phone-recording.spec.ts @@ -18,7 +18,7 @@ async function ensurePoweredOn(page) { .waitFor({ timeout: LOAD_TIMEOUT }); // Wait for system to be connected (splash or topbar visible) - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); await Promise.race([ diff --git a/apps/control/e2e/power-control.spec.ts b/apps/control/e2e/power-control.spec.ts index 2b27ff2f93e..55134bcd50d 100644 --- a/apps/control/e2e/power-control.spec.ts +++ b/apps/control/e2e/power-control.spec.ts @@ -23,7 +23,7 @@ test.describe('US-003: Power On Room', () => { }) => { // When system is not active, splash screen should be visible // Wait for either splash (system connected but inactive) or topbar (system active) - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); // Wait for system to be connected and UI to render @@ -43,7 +43,7 @@ test.describe('US-003: Power On Room', () => { test('should show "touch to start" message on splash screen', async ({ page, }) => { - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); if (await splash.isVisible().catch(() => false)) { // Check for the touch to start heading @@ -53,7 +53,7 @@ test.describe('US-003: Power On Room', () => { }); test('should display room name on splash screen', async ({ page }) => { - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); if (await splash.isVisible().catch(() => false)) { // Room name should be displayed @@ -63,7 +63,7 @@ test.describe('US-003: Power On Room', () => { }); test('should power on when clicking splash screen', async ({ page }) => { - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); if (await splash.isVisible().catch(() => false)) { // Click to power on @@ -80,7 +80,7 @@ test.describe('US-003: Power On Room', () => { page, }) => { // Wait for splash or topbar to appear first - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); await Promise.race([ @@ -105,7 +105,7 @@ test.describe('US-003: Power On Room', () => { }); test('should display version info on splash screen', async ({ page }) => { - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); if (await splash.isVisible().catch(() => false)) { // Version info should be in bottom left @@ -123,7 +123,7 @@ test.describe('US-004: Power Off Room', () => { .waitFor({ timeout: LOAD_TIMEOUT }); // Wait for system to be connected (splash or topbar visible) - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); await Promise.race([ @@ -185,7 +185,7 @@ test.describe('Power Control - Voice Assistant on Splash', () => { test('should display voice assistant on splash screen when enabled', async ({ page, }) => { - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); if (await splash.isVisible().catch(() => false)) { // Voice assistant should be in bottom right @@ -234,7 +234,7 @@ test.describe('Power Control - Responsive', () => { .waitFor({ timeout: LOAD_TIMEOUT }); // Wait for system to be connected (splash or topbar visible) - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); await Promise.race([ diff --git a/apps/control/e2e/room-joining.spec.ts b/apps/control/e2e/room-joining.spec.ts index c8ba72372de..1d26b492f52 100644 --- a/apps/control/e2e/room-joining.spec.ts +++ b/apps/control/e2e/room-joining.spec.ts @@ -18,7 +18,7 @@ async function ensurePoweredOn(page) { .waitFor({ timeout: LOAD_TIMEOUT }); // Wait for system to be connected (splash or topbar visible) - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); await Promise.race([ diff --git a/apps/control/e2e/source-management.spec.ts b/apps/control/e2e/source-management.spec.ts index b8cc31572ba..9d7120bdff2 100644 --- a/apps/control/e2e/source-management.spec.ts +++ b/apps/control/e2e/source-management.spec.ts @@ -18,7 +18,7 @@ async function ensurePoweredOn(page) { .waitFor({ timeout: LOAD_TIMEOUT }); // Wait for system to be connected (splash or topbar visible) - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); await Promise.race([ diff --git a/apps/control/e2e/video-conferencing.spec.ts b/apps/control/e2e/video-conferencing.spec.ts index e213ad79fa4..628cfa23db7 100644 --- a/apps/control/e2e/video-conferencing.spec.ts +++ b/apps/control/e2e/video-conferencing.spec.ts @@ -18,7 +18,7 @@ async function ensurePoweredOn(page) { .waitFor({ timeout: LOAD_TIMEOUT }); // Wait for system to be connected (splash or topbar visible) - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); await Promise.race([ diff --git a/apps/control/e2e/volume-control.spec.ts b/apps/control/e2e/volume-control.spec.ts index 11c0b2b0601..d1a471c2e81 100644 --- a/apps/control/e2e/volume-control.spec.ts +++ b/apps/control/e2e/volume-control.spec.ts @@ -18,7 +18,7 @@ async function ensurePoweredOn(page) { .waitFor({ timeout: LOAD_TIMEOUT }); // Wait for system to be connected (splash or topbar visible) - const splash = page.locator('div[name="splash"]'); + const splash = page.locator('[name="splash"]'); const topbar = page.locator('topbar-header'); await Promise.race([ diff --git a/apps/control/src/app/control-state.service.ts b/apps/control/src/app/control-state.service.ts index 8894db83abb..e50eae85e53 100644 --- a/apps/control/src/app/control-state.service.ts +++ b/apps/control/src/app/control-state.service.ts @@ -21,6 +21,7 @@ import { firstValueWhere, HashMap, i18n, + log, } from '@placeos/common'; import { openConfirmModal } from '@placeos/components'; import { CalendarService, queryEvents, SpacesService } from '@placeos/events'; @@ -332,10 +333,26 @@ export class ControlStateService extends AsyncHandler { if (id !== this._id()) { this._id.set(id); // Caches the space details for the space pipe - this._spaces.loadSpace(id).catch(() => null); + this._spaces + .loadSpace(id) + .catch((error) => this._onSystemLoadError(id, error)); } } + /** + * Send the panel back to bootstrap when the system does not exist. + * Clears the stored ID first so bootstrap does not open it again. + */ + private _onSystemLoadError(id: string, error: unknown) { + const status = (error as { status?: number } | null)?.status; + log('Control', 'Error loading system details:', [id, status], 'error'); + if (status !== 404 || id !== this._id()) return; + if (localStorage.getItem(CONTROL_STORE_KEY) === id) { + localStorage.removeItem(CONTROL_STORE_KEY); + } + this._router.navigate(['/bootstrap']); + } + /** Power on the active system */ public join(id: string) { return this._execute('join_mode', [id]); diff --git a/apps/control/src/tests/control-state.service.spec.ts b/apps/control/src/tests/control-state.service.spec.ts index 8658167170e..3ec90a03865 100644 --- a/apps/control/src/tests/control-state.service.spec.ts +++ b/apps/control/src/tests/control-state.service.spec.ts @@ -9,8 +9,12 @@ import { of } from 'rxjs'; vi.mock('@placeos/ts-client', { spy: true }); +import { Router } from '@angular/router'; import * as client from '@placeos/ts-client'; -import { ControlStateService } from '../app/control-state.service'; +import { + CONTROL_STORE_KEY, + ControlStateService, +} from '../app/control-state.service'; describe('ControlStateService', () => { let spectator: SpectatorService; @@ -18,6 +22,7 @@ describe('ControlStateService', () => { let bindings: Record void>; /** Names of bindings that were released */ let released: string[]; + const loadSpace = vi.fn(); const createService = createServiceFactory({ service: ControlStateService, providers: [ @@ -25,15 +30,14 @@ describe('ControlStateService', () => { { provide: CalendarService, useValue: { calendars: of([]) } }, { provide: SpacesService, - useValue: { - loadSpaces: vi.fn(), - loadSpace: vi.fn(async () => null), - }, + useValue: { loadSpaces: vi.fn(), loadSpace }, }, ], }); beforeEach(() => { + loadSpace.mockReset().mockResolvedValue(null); + localStorage.clear(); bindings = {}; released = []; vi.mocked(client.getModule).mockImplementation( @@ -78,4 +82,26 @@ describe('ControlStateService', () => { TestBed.tick(); expect(released).toContain('input/a'); }); + + it('should return to bootstrap and forget the system when it does not exist', async () => { + const router = spectator.inject(Router); + const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); + localStorage.setItem(CONTROL_STORE_KEY, 'missing'); + loadSpace.mockRejectedValue({ status: 404 }); + spectator.service.setID('missing'); + await new Promise((r) => setTimeout(r)); + expect(navigate).toHaveBeenCalledWith(['/bootstrap']); + expect(localStorage.getItem(CONTROL_STORE_KEY)).toBeNull(); + }); + + it('should stay on the system when loading fails for another reason', async () => { + const router = spectator.inject(Router); + const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); + localStorage.setItem(CONTROL_STORE_KEY, 'sys-1'); + loadSpace.mockRejectedValue({ status: 500 }); + spectator.service.setID('sys-1'); + await new Promise((r) => setTimeout(r)); + expect(navigate).not.toHaveBeenCalled(); + expect(localStorage.getItem(CONTROL_STORE_KEY)).toBe('sys-1'); + }); }); From 54e1b7b15d557e23efe94bf789354ea084c2a733 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 2 Oct 2026 01:19:54 +1000 Subject: [PATCH 3/3] fix(control): clear the recalled preset when the camera changes Another control can change the selected camera. The preset highlight belonged to the previous camera. --- .../src/app/ui/camera-tooltip.component.ts | 19 +++++++++++++++---- .../tests/ui/camera-tooltip.component.spec.ts | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/control/src/app/ui/camera-tooltip.component.ts b/apps/control/src/app/ui/camera-tooltip.component.ts index 43d3bf366bd..d396c4bce44 100644 --- a/apps/control/src/app/ui/camera-tooltip.component.ts +++ b/apps/control/src/app/ui/camera-tooltip.component.ts @@ -1,4 +1,11 @@ -import { Component, DestroyRef, effect, inject, signal } from '@angular/core'; +import { + Component, + DestroyRef, + effect, + inject, + signal, + untracked, +} from '@angular/core'; import { BindingDirective, CustomTooltipData, @@ -269,9 +276,13 @@ export class CameraTooltipComponent { constructor() { inject(DestroyRef).onDestroy(() => this.stopZoom()); effect(() => { - const l = this.camera_list(); - const cam = this._selected_camera(); - this.active_camera.set(l?.find((_) => _.id === cam)); + const id = this._selected_camera(); + const camera = this.camera_list()?.find((_) => _.id === id); + // The recalled preset belongs to the previous camera + if (camera?.id !== untracked(this.active_camera)?.id) { + this.preset.set(''); + } + this.active_camera.set(camera); }); } diff --git a/apps/control/src/tests/ui/camera-tooltip.component.spec.ts b/apps/control/src/tests/ui/camera-tooltip.component.spec.ts index e51659ea5cf..2462b6b4c78 100644 --- a/apps/control/src/tests/ui/camera-tooltip.component.spec.ts +++ b/apps/control/src/tests/ui/camera-tooltip.component.spec.ts @@ -134,4 +134,18 @@ describe('CameraTooltipComponent', () => { spectator.detectChanges(); expect('button[preset]:not(.inverse)').toHaveText('Desk'); }); + + it('should clear the recalled preset when another control changes camera', () => { + mockExecute(); + available_cameras.set([ + { id: 'cam1', name: 'Camera 1', mod: 'Camera_1' }, + { id: 'cam2', name: 'Camera 2', mod: 'Camera_2' }, + ]); + selected_camera.set('cam1'); + spectator.detectChanges(); + spectator.component.recallPreset('Desk'); + selected_camera.set('cam2'); + spectator.detectChanges(); + expect(spectator.component.preset()).toBe(''); + }); });