Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/control/src/app/control-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,11 +541,14 @@ export class ControlStateService extends AsyncHandler {

/** Open select meeting modal */
public async selectMeeting(input?: string) {
const cals = this.calendars();
if (cals?.length) this.setCalendar(cals[0]);
this._dialog.open(SelectMeetingModalComponent, {
data: { input },
});
// Untracked so an effect that calls this does not re-run when the
// calendars load, which would open the list again
await untracked(() => this._cal.loadCalendars());
const first = untracked(this.calendars)[0];
if (!untracked(this._calendar) && first) this.setCalendar(first);
}

/** Open view help modal */
Expand Down
18 changes: 18 additions & 0 deletions apps/control/src/app/error-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { errorMessage } from '@placeos/common';

/**
* Readable text for a failed request, for error notifications.
* Driver and API errors are plain objects (realtime errors carry `msg`),
* so they print as `[object Object]` when passed to a message directly.
*/
export function errorText(error: unknown): string {
const message = errorMessage(error);
if (message) return message;
const { msg, code, status } = (error ?? {}) as {
msg?: unknown;
code?: number;
status?: number;
};
if (typeof msg === 'string' && msg) return msg;
return `${code ?? status ?? 'unknown'}`;
}
1 change: 1 addition & 0 deletions apps/control/src/app/tabbed-view/tab-outlet.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ import { TVControlsComponent } from './tv-controls.component';
@if (call()) {
<div
video-call-page
[reserve_top]="!!tab()?.help"
[present_output]="
tab()?.presentation_source
"
Expand Down
7 changes: 6 additions & 1 deletion apps/control/src/app/ui/select-meeting-modal.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '@placeos/components';
import { format } from 'date-fns';
import { ControlStateService } from '../control-state.service';
import { errorText } from '../error-text';

@Component({
selector: 'select-meeting-modal',
Expand Down Expand Up @@ -143,7 +144,11 @@ export class SelectMeetingModalComponent {
try {
await this._service.setEvent(e);
} catch (error) {
notifyError(i18n('APP.CONTROL.MEETING_JOIN_ERROR', { error }));
notifyError(
i18n('APP.CONTROL.MEETING_JOIN_ERROR', {
error: errorText(error),
}),
);
return;
} finally {
details.close();
Expand Down
5 changes: 4 additions & 1 deletion apps/control/src/app/ui/source-select.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { i18n, notifyError, unique } from '@placeos/common';
import { TranslatePipe } from '@placeos/components';
import { ControlStateService, RoomInput } from '../control-state.service';
import { errorText } from '../error-text';

@Component({
selector: 'source-select',
Expand Down Expand Up @@ -124,7 +125,9 @@ export class SourceSelectComponent {
try {
await this._state.setRoute(input.id, this.output());
} catch (error) {
notifyError(i18n('APP.CONTROL.ROUTE_ERROR', { error }));
notifyError(
i18n('APP.CONTROL.ROUTE_ERROR', { error: errorText(error) }),
);
return;
} finally {
this.loading.set(false);
Expand Down
2 changes: 2 additions & 0 deletions apps/control/src/app/ui/voice-assistant.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ export class VoiceAssistantService extends AsyncHandler {
speech.onend = null;
speech.abort();
this._user_speech = undefined;
// Commands need voice control, so drop the chat connection with it
this._chat_service.endChat();
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

private _sendCommand(command: string, attempt = 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { i18n, notifyError } from '@placeos/common';
import { IconComponent, TranslatePipe } from '@placeos/components';
import { errorText } from '../error-text';
import { DialpadComponent } from '../ui/dialpad.component';
import { VideoCallStateService } from './video-call-state.service';

Expand Down Expand Up @@ -116,7 +117,9 @@ export class VideoCallDialViewComponent {
try {
await this._call.dial(dial_number);
} catch (error) {
notifyError(i18n('APP.CONTROL.VC_DIAL_ERROR', { error }));
notifyError(
i18n('APP.CONTROL.VC_DIAL_ERROR', { error: errorText(error) }),
);
return;
} finally {
this.loading.set(false);
Expand Down
50 changes: 40 additions & 10 deletions apps/control/src/app/video-call/video-call-page.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
effect,
inject,
input,
linkedSignal,
signal,
untracked,
} from '@angular/core';
Expand All @@ -23,6 +24,7 @@ import {
} from '@placeos/common';
import { IconComponent, TranslatePipe } from '@placeos/components';
import { ControlStateService } from '../control-state.service';
import { errorText } from '../error-text';
import { selectCamera } from '../ui/camera-commands';
import { DialpadComponent } from '../ui/dialpad.component';
import {
Expand Down Expand Up @@ -133,7 +135,9 @@ import {
></dialpad>
</div>
<div
actions
class="flex flex-1 flex-col items-center justify-center space-y-4 p-2"
[class.pt-14]="reserve_top()"
>
<button
btn
Expand Down Expand Up @@ -245,19 +249,30 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit {
private _router = inject(Router);
private _injector = inject(Injector);

/** Latest layout and presentation mode change requests */
private _layout_request = 0;
private _mode_request = 0;
/** Whether the page has already left for the ended call */
private _left = false;

public readonly redirect = input(true);
public readonly present_output = input('');
/** Leave space above the call actions for a button placed over the page */
public readonly reserve_top = input(false);
public readonly loading = signal('');
public readonly call = this._state.call;
private readonly _show_camera_pip = this._state.show_camera_pip;
public readonly show_camera_pip = computed(() => !!this._show_camera_pip());
private readonly _mic_mute = this._state.mic_mute;
public readonly mic_mute = computed(() => !!this._mic_mute());
public readonly video_layout = this._state.video_layout;
public readonly presentation_mode = this._state.presentation_mode;
/** Selected layout. Set on change, and reset to the codec value when the change fails. */
public readonly video_layout = linkedSignal(() =>
this._state.video_layout(),
);
/** Selected presentation mode. Reset to the codec value when a change fails. */
public readonly presentation_mode = linkedSignal(() =>
this._state.presentation_mode(),
);
public readonly presentables = this._control.presentables;
/** List of available cameras to select from */
public readonly camera_list = this._control.camera_list;
Expand All @@ -274,22 +289,37 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit {
public readonly sentDTMF = (d) => this._state.sendDTMF(d);
public readonly setPresentationSource = (i) =>
this._control.setRoute(i.id, this.present_output(), false);
public readonly setPresentationMode = (d: PresentationMode) =>
this._state.setPresentationMode(d);
public readonly setVideoLayout = (d: VideoLayout) =>
this._state.setVideoLayout(d);
/** Reset on failure only if no newer change started, so a later choice is kept */
public readonly setPresentationMode = async (d: PresentationMode) => {
const request = ++this._mode_request;
this.presentation_mode.set(d);
if (await this._state.setPresentationMode(d)) return;
if (request !== this._mode_request) return;
this.presentation_mode.set(this._state.presentation_mode());
};
public readonly setVideoLayout = async (d: VideoLayout) => {
const request = ++this._layout_request;
this.video_layout.set(d);
if (await this._state.setVideoLayout(d)) return;
if (request !== this._layout_request) return;
this.video_layout.set(this._state.video_layout());
Comment thread
greptile-apps[bot] marked this conversation as resolved.
};
public readonly toggleCamera = async () =>
this._state.showCameraPIP(!this.show_camera_pip());
public readonly toggleMute = async () =>
this._state.muteMicrophone(!this.mic_mute());
public readonly toggleOnHold = () => this._state.toggleCallOnHold();
public readonly endCall = async () => {
this.loading.set(i18n('APP.CONTROL.VC_LEAVE_LOADING'));
await this._state.hangup().catch((_) => {
try {
await this._state.hangup();
} catch (error) {
this.loading.set('');
notifyError(i18n('APP.CONTROL.VC_LEAVE_ERROR', { error: _ }));
throw _;
});
notifyError(
i18n('APP.CONTROL.VC_LEAVE_ERROR', { error: errorText(error) }),
);
return;
}
this._onCallEnded();
};

Expand Down
19 changes: 13 additions & 6 deletions apps/control/src/app/video-call/video-call-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 { errorText } from '../error-text';
import { systemBinding } from '../system-binding';

export type VideoLayout = 'Auto' | 'Equal' | 'Overlay' | 'Prominent' | 'Single';
Expand Down Expand Up @@ -108,22 +109,28 @@ export class VideoCallStateService {
return this._exec('dtmf_send', [digit]);
}

public toggleCallOnHold() {
public async toggleCallOnHold() {
const call = this.call();
if (!call) return;
if (!call) return false;
return this._exec(
call.Status === 'OnHold' ? 'call_resume' : 'call_place_on_hold',
);
}

/** Run a VidConf method. Shows an error and resolves when it fails. */
/** Run a VidConf method. Shows an error when it fails. Resolves `true` on success. */
private async _exec(method: string, args: unknown[] = []) {
const id = this._control.id;
if (!id) return;
if (!id) return false;
try {
return await getModule(id, 'VidConf').execute(method, args);
await getModule(id, 'VidConf').execute(method, args);
return true;
} catch (error) {
notifyError(i18n('APP.CONTROL.VC_COMMAND_ERROR', { error }));
notifyError(
i18n('APP.CONTROL.VC_COMMAND_ERROR', {
error: errorText(error),
}),
);
return false;
}
}

Expand Down
38 changes: 36 additions & 2 deletions apps/control/src/tests/control-state.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { effect, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import {
createServiceFactory,
SpectatorService,
} from '@ngneat/spectator/vitest';
import { Calendar } from '@placeos/common';
import { CalendarService, SpacesService } from '@placeos/events';
import { of } from 'rxjs';

vi.mock('@placeos/ts-client', { spy: true });

Expand All @@ -23,11 +24,21 @@ describe('ControlStateService', () => {
/** Names of bindings that were released */
let released: string[];
const loadSpace = vi.fn();
const calendar_list = signal<Calendar[]>([]);
// Reads the list before awaiting, like CalendarService.loadCalendars
const loadCalendars = vi.fn(async () => {
if (calendar_list().length) return;
await Promise.resolve();
calendar_list.set([{ id: 'cal-1' } as Calendar]);
});
const createService = createServiceFactory({
service: ControlStateService,
providers: [
{ provide: MatDialog, useValue: { open: vi.fn() } },
{ provide: CalendarService, useValue: { calendars: of([]) } },
{
provide: CalendarService,
useValue: { calendar_list, loadCalendars },
},
{
provide: SpacesService,
useValue: { loadSpaces: vi.fn(), loadSpace },
Expand Down Expand Up @@ -104,4 +115,27 @@ describe('ControlStateService', () => {
expect(navigate).not.toHaveBeenCalled();
expect(localStorage.getItem(CONTROL_STORE_KEY)).toBe('sys-1');
});

it('should load calendars and pick the first when selecting a meeting', async () => {
calendar_list.set([]);
await spectator.service.selectMeeting();
expect(loadCalendars).toHaveBeenCalled();
expect(spectator.service.calendar()?.id).toBe('cal-1');
});

it('should not open the meeting list again when calendars load', async () => {
const dialog = spectator.inject(MatDialog);
vi.mocked(dialog.open).mockClear();
calendar_list.set([]);
const ref = TestBed.runInInjectionContext(() =>
effect(() => {
spectator.service.selectMeeting();
}),
);
TestBed.tick();
await new Promise((r) => setTimeout(r));
TestBed.tick();
expect(dialog.open).toHaveBeenCalledTimes(1);
ref.destroy();
});
});
16 changes: 16 additions & 0 deletions apps/control/src/tests/error-text.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { errorText } from '../app/error-text';

describe('errorText', () => {
it('should use the error message when there is one', () => {
expect(errorText(new Error('busy'))).toBe('busy');
expect(errorText({ message: 'not found' })).toBe('not found');
expect(errorText('offline')).toBe('offline');
});

it('should fall back to the error code or status', () => {
expect(errorText({ code: 3, msg: 'Codec busy' })).toBe('Codec busy');
expect(errorText({ code: 500 })).toBe('500');
expect(errorText({ status: 404 })).toBe('404');
expect(errorText(undefined)).toBe('unknown');
});
});
14 changes: 13 additions & 1 deletion apps/control/src/tests/tabbed-view/tab-outlet.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
SpectatorRouting,
} from '@ngneat/spectator/vitest';
import { mockComponent, mockDirective } from '@placeos/common/tests';
import { MockPipe, MockProvider } from 'ng-mocks';
import { MockPipe, MockProvider, ngMocks } from 'ng-mocks';

import {
BindingDirective,
Expand Down Expand Up @@ -264,4 +264,16 @@ describe('TabOutletComponent', () => {
);
vi.useRealTimers();
});

it('should leave room for the help button above the call controls', () => {
const service: any = spectator.inject(ControlStateService);
const call_state: any = spectator.inject(VideoCallStateService);
service.tabs.set([
{ id: 'tab1', controls: 'vidconf-controls', help: 'vc-help' },
]);
call_state.call.set({ Status: 'Connected' });
spectator.detectChanges();
expect(ngMocks.input('[video-call-page]', 'reserve_top')).toBe(true);
call_state.call.set(null);
});
});
2 changes: 2 additions & 0 deletions apps/control/src/tests/ui/voice-assistant.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe('VoiceAssistantService', () => {
startChat: vi.fn(),
sendMessage: vi.fn(),
close: vi.fn(),
endChat: vi.fn(),
};
spectator = createService({
providers: [{ provide: ChatService, useValue: chat }],
Expand Down Expand Up @@ -106,6 +107,7 @@ describe('VoiceAssistantService', () => {
expect(spectator.service.enabled()).toBe(false);
expect(instance.abort).toHaveBeenCalled();
expect(instance.onresult).toBeNull();
expect(chat.endChat).toHaveBeenCalled();
});

it('should report speech recognition as unavailable when the browser lacks it', () => {
Expand Down
Loading
Loading