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/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..e50eae85e53 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 { @@ -28,11 +22,11 @@ import { 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 +140,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 +155,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 +301,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,10 +332,27 @@ 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((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]); @@ -467,10 +444,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 +464,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 +586,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 +634,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 +657,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..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, @@ -14,17 +21,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 +68,7 @@ export enum ZoomDirection { btn matRipple class="w-48" - [class.inverse]="preset !== name" + [class.inverse]="preset() !== name" (click)="recallPreset(name)" > {{ name }} @@ -238,15 +246,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 */ @@ -268,65 +276,52 @@ 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); }); } 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 +329,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 +339,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..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,12 +30,14 @@ describe('ControlStateService', () => { { provide: CalendarService, useValue: { calendars: of([]) } }, { provide: SpacesService, - useValue: { loadSpaces: vi.fn(), loadSpace: vi.fn() }, + useValue: { loadSpaces: vi.fn(), loadSpace }, }, ], }); beforeEach(() => { + loadSpace.mockReset().mockResolvedValue(null); + localStorage.clear(); bindings = {}; released = []; vi.mocked(client.getModule).mockImplementation( @@ -75,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'); + }); }); 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..2462b6b4c78 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,31 @@ 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'); + }); + + 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(''); }); }); 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', () => {