ov-components: Refactor audio and video device components to use reactive signals for improved performance and cleaner code

master
CSantosM 2026-01-27 16:01:53 +01:00
parent f4264a2a8a
commit ed64c3a305
5 changed files with 87 additions and 118 deletions

View File

@ -1,18 +1,18 @@
<div class="audio-device-selector" [class.compact]="compact">
<!-- Unified Device Button (Compact Mode) -->
@if (compact) {
@if (hasAudioDevices) {
@if (hasAudioDevices()) {
<div class="unified-device-button">
<!-- Main toggle button -->
<button
mat-flat-button
class="toggle-section"
[disabled]="!hasAudioDevices || microphoneStatusChanging"
[class.device-enabled]="isMicrophoneEnabled"
[class.device-disabled]="!isMicrophoneEnabled"
(click)="toggleMic($event)"
[matTooltip]="isMicrophoneEnabled ? ('TOOLBAR.MUTE_AUDIO' | translate) : ('TOOLBAR.UNMUTE_AUDIO' | translate)"
[matTooltipDisabled]="!hasAudioDevices"
[disabled]="!hasAudioDevices() || microphoneStatusChanging"
[class.device-enabled]="isMicrophoneEnabled"
[class.device-disabled]="!isMicrophoneEnabled"
(click)="toggleMic($event)"
[matTooltip]="isMicrophoneEnabled ? ('TOOLBAR.MUTE_AUDIO' | translate) : ('TOOLBAR.UNMUTE_AUDIO' | translate)"
[matTooltipDisabled]="!hasAudioDevices()"
id="microphone-button"
>
<mat-icon [id]="isMicrophoneEnabled ? 'mic' : 'mic_off'">{{ isMicrophoneEnabled ? 'mic' : 'mic_off' }}</mat-icon>
@ -42,7 +42,7 @@
<!-- Normal Mode - Input Style Selector -->
<div class="normal-device-selector">
<!-- Input-style Device Selector -->
<div class="device-input-selector" [class.disabled]="!hasAudioDevices || !isMicrophoneEnabled">
<div class="device-input-selector" [class.disabled]="!hasAudioDevices() || !isMicrophoneEnabled">
<!-- When microphone is enabled -->
@if (isMicrophoneEnabled) {
<div class="device-input-selector">
@ -50,23 +50,23 @@
mat-flat-button
id="audio-dropdown"
class="selector-button"
[disabled]="microphoneStatusChanging || microphones.length <= 1"
[matMenuTriggerFor]="microphoneMenu"
[attr.aria-expanded]="false"
>
<mat-icon class="device-icon">mic</mat-icon>
<span class="selected-device-name">{{ microphoneSelected?.label || 'No microphone selected' }}</span>
<mat-icon class="dropdown-icon" *ngIf="microphones.length > 1">expand_more</mat-icon>
[disabled]="microphoneStatusChanging || microphones().length <= 1"
[matMenuTriggerFor]="microphoneMenu"
[attr.aria-expanded]="false"
>
<mat-icon class="device-icon">mic</mat-icon>
<span class="selected-device-name">{{ microphoneSelected()?.label || 'No microphone selected' }}</span>
<mat-icon class="dropdown-icon" *ngIf="microphones().length > 1">expand_more</mat-icon>
</button>
</div>
} @else {
@if (hasAudioDevices) {
@if (hasAudioDevices()) {
<!-- When microphone is disabled -->
<div class="device-input-selector disabled">
<div class="selector-button disabled">
<mat-icon class="device-icon">mic_off</mat-icon>
<span class="selected-device-name">
{{ !hasAudioDevices ? ('PREJOIN.NO_AUDIO_DEVICE' | translate) : 'Microphone disabled' }}
{{ !hasAudioDevices() ? ('PREJOIN.NO_AUDIO_DEVICE' | translate) : 'Microphone disabled' }}
</span>
</div>
</div>
@ -84,14 +84,14 @@
<!-- Device Selection Menu (Shared) -->
<mat-menu #microphoneMenu="matMenu" class="device-menu">
@for (microphone of microphones; track microphone.device) {
@for (microphone of microphones(); track microphone.device) {
<button
mat-menu-item
id="option-{{ microphone.label }}"
(click)="onMicrophoneSelected({ value: microphone })"
[class.selected]="microphone.device === microphoneSelected.device"
[class.selected]="microphone.device === microphoneSelected()?.device"
>
<mat-icon *ngIf="microphone.device === microphoneSelected.device">check</mat-icon>
<mat-icon *ngIf="microphone.device === microphoneSelected()?.device">check</mat-icon>
<span>{{ microphone.label }}</span>
</button>
}

View File

@ -1,8 +1,6 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { Subscription } from 'rxjs';
import { Component, effect, EventEmitter, Input, OnInit, Output, Signal, WritableSignal } from '@angular/core';
import { CustomDevice } from '../../../models/device.model';
import { ILogger } from '../../../models/logger.model';
import { ParticipantModel } from '../../../models/participant.model';
import { DeviceService } from '../../../services/device/device.service';
import { LoggerService } from '../../../services/logger/logger.service';
import { ParticipantService } from '../../../services/participant/participant.service';
@ -17,19 +15,20 @@ import { StorageService } from '../../../services/storage/storage.service';
styleUrls: ['./audio-devices.component.scss'],
standalone: false
})
export class AudioDevicesComponent implements OnInit, OnDestroy {
export class AudioDevicesComponent implements OnInit {
@Input() compact: boolean = false;
@Output() onAudioDeviceChanged = new EventEmitter<CustomDevice>();
@Output() onAudioEnabledChanged = new EventEmitter<boolean>();
microphoneStatusChanging: boolean;
hasAudioDevices: boolean;
isMicrophoneEnabled: boolean;
microphoneSelected: CustomDevice | undefined;
microphones: CustomDevice[] = [];
private localParticipantSubscription: Subscription;
microphoneStatusChanging: boolean = false;
isMicrophoneEnabled: boolean = false;
private log: ILogger;
// Expose signals directly from service (reactive)
protected readonly microphones: WritableSignal<CustomDevice[]>;
protected readonly microphoneSelected: WritableSignal<CustomDevice | undefined>;
protected readonly hasAudioDevices: Signal<boolean>;
constructor(
private deviceSrv: DeviceService,
private storageSrv: StorageService,
@ -37,24 +36,24 @@ export class AudioDevicesComponent implements OnInit, OnDestroy {
private loggerSrv: LoggerService
) {
this.log = this.loggerSrv.get('AudioDevicesComponent');
this.microphones = this.deviceSrv.microphones;
this.microphoneSelected = this.deviceSrv.microphoneSelected;
this.hasAudioDevices = this.deviceSrv.hasAudioDevices;
// Use effect instead of subscription for reactive updates
effect(() => {
const participant = this.participantService.localParticipantSignal();
if (participant) {
this.isMicrophoneEnabled = participant.isMicrophoneEnabled;
this.storageSrv.setMicrophoneEnabled(this.isMicrophoneEnabled);
}
});
}
async ngOnInit() {
this.subscribeToParticipantMediaProperties();
this.hasAudioDevices = this.deviceSrv.hasAudioDeviceAvailable();
if (this.hasAudioDevices) {
this.microphones = this.deviceSrv.getMicrophones();
this.microphoneSelected = this.deviceSrv.getMicrophoneSelected();
}
this.isMicrophoneEnabled = this.participantService.isMyMicrophoneEnabled();
}
ngOnDestroy() {
this.microphones = [];
if (this.localParticipantSubscription) this.localParticipantSubscription.unsubscribe();
}
async toggleMic(event: any) {
event.stopPropagation();
this.microphoneStatusChanging = true;
@ -72,8 +71,7 @@ export class AudioDevicesComponent implements OnInit, OnDestroy {
this.microphoneStatusChanging = true;
await this.participantService.switchMicrophone(device.device);
this.deviceSrv.setMicSelected(device.device);
this.microphoneSelected = this.deviceSrv.getMicrophoneSelected();
this.onAudioDeviceChanged.emit(this.microphoneSelected);
this.onAudioDeviceChanged.emit(this.microphoneSelected());
}
} catch (error) {
this.log.e('Error switching microphone', error);
@ -89,17 +87,4 @@ export class AudioDevicesComponent implements OnInit, OnDestroy {
compareObjectDevices(o1: CustomDevice, o2: CustomDevice): boolean {
return o1.label === o2.label;
}
/**
* This subscription is necessary to update the microphone status when the user changes it from toolbar and
* the settings panel is opened. With this, the microphone status is updated in the settings panel.
*/
private subscribeToParticipantMediaProperties() {
this.localParticipantSubscription = this.participantService.localParticipant$.subscribe((p: ParticipantModel | undefined) => {
if (p) {
this.isMicrophoneEnabled = p.isMicrophoneEnabled;
this.storageSrv.setMicrophoneEnabled(this.isMicrophoneEnabled);
}
});
}
}

View File

@ -1,18 +1,18 @@
<div class="video-device-selector" [class.compact]="compact">
<!-- Unified Device Button (Compact Mode) -->
@if (compact) {
@if (hasVideoDevices) {
@if (hasVideoDevices()) {
<div class="unified-device-button">
<!-- Main toggle button -->
<button
mat-flat-button
class="toggle-section"
[disabled]="!hasVideoDevices || cameraStatusChanging"
[class.device-enabled]="isCameraEnabled"
[class.device-disabled]="!isCameraEnabled"
(click)="toggleCam($event)"
[matTooltip]="isCameraEnabled ? ('TOOLBAR.STOP_VIDEO' | translate) : ('TOOLBAR.START_VIDEO' | translate)"
[matTooltipDisabled]="!hasVideoDevices"
[disabled]="!hasVideoDevices() || cameraStatusChanging"
[class.device-enabled]="isCameraEnabled"
[class.device-disabled]="!isCameraEnabled"
(click)="toggleCam($event)"
[matTooltip]="isCameraEnabled ? ('TOOLBAR.STOP_VIDEO' | translate) : ('TOOLBAR.START_VIDEO' | translate)"
[matTooltipDisabled]="!hasVideoDevices()"
id="camera-button"
>
<mat-icon [id]="isCameraEnabled ? 'videocam' : 'videocam_off'">
@ -51,15 +51,15 @@
id="video-dropdown"
class="selector-button"
[matMenuTriggerFor]="cameraMenu"
[disabled]="cameraStatusChanging || cameras.length <= 1"
[disabled]="cameraStatusChanging || cameras().length <= 1"
>
<mat-icon class="device-icon">videocam</mat-icon>
<span class="selected-device-name">{{ cameraSelected?.label || 'No camera selected' }}</span>
<mat-icon class="dropdown-icon" *ngIf="cameras.length > 1">expand_more</mat-icon>
<span class="selected-device-name">{{ cameraSelected()?.label || 'No camera selected' }}</span>
<mat-icon class="dropdown-icon" *ngIf="cameras().length > 1">expand_more</mat-icon>
</button>
</div>
} @else {
@if (hasVideoDevices) {
@if (hasVideoDevices()) {
<!-- Disabled state message -->
<div class="device-input-selector disabled">
<div class="selector-button disabled">
@ -80,14 +80,14 @@
<!-- Device Selection Menu (Shared) -->
<mat-menu #cameraMenu="matMenu" class="device-menu">
@for (camera of cameras; track camera.device) {
@for (camera of cameras(); track camera.device) {
<button
mat-menu-item
id="option-{{ camera.label }}"
(click)="onCameraSelected({ value: camera })"
[class.selected]="camera.device === cameraSelected?.device"
[class.selected]="camera.device === cameraSelected()?.device"
>
<mat-icon *ngIf="camera.device === cameraSelected?.device" class="check-icon">check</mat-icon>
<mat-icon *ngIf="camera.device === cameraSelected()?.device" class="check-icon">check</mat-icon>
<span>{{ camera.label }}</span>
</button>
}

View File

@ -1,8 +1,6 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { Subscription } from 'rxjs';
import { Component, effect, EventEmitter, Input, OnInit, Output, Signal, WritableSignal } from '@angular/core';
import { CustomDevice } from '../../../models/device.model';
import { ILogger } from '../../../models/logger.model';
import { ParticipantModel } from '../../../models/participant.model';
import { DeviceService } from '../../../services/device/device.service';
import { LoggerService } from '../../../services/logger/logger.service';
import { ParticipantService } from '../../../services/participant/participant.service';
@ -17,18 +15,18 @@ import { StorageService } from '../../../services/storage/storage.service';
styleUrls: ['./video-devices.component.scss'],
standalone: false
})
export class VideoDevicesComponent implements OnInit, OnDestroy {
export class VideoDevicesComponent implements OnInit {
@Input() compact: boolean = false;
@Output() onVideoDeviceChanged = new EventEmitter<CustomDevice>();
@Output() onVideoEnabledChanged = new EventEmitter<boolean>();
@Output() onVideoDevicesLoaded = new EventEmitter<CustomDevice[]>();
cameraStatusChanging: boolean;
isCameraEnabled: boolean;
cameraSelected: CustomDevice | undefined;
hasVideoDevices: boolean;
cameras: CustomDevice[] = [];
localParticipantSubscription: Subscription;
cameraStatusChanging: boolean = false;
isCameraEnabled: boolean = false;
protected readonly cameras: WritableSignal<CustomDevice[]>;
protected readonly cameraSelected: WritableSignal<CustomDevice | undefined>;
protected readonly hasVideoDevices: Signal<boolean>;
private log: ILogger;
@ -39,26 +37,26 @@ export class VideoDevicesComponent implements OnInit, OnDestroy {
private loggerSrv: LoggerService
) {
this.log = this.loggerSrv.get('VideoDevicesComponent');
this.cameras = this.deviceSrv.cameras;
this.cameraSelected = this.deviceSrv.cameraSelected;
this.hasVideoDevices = this.deviceSrv.hasVideoDevices;
// Use effect instead of subscription for reactive updates
effect(() => {
const participant = this.participantService.localParticipantSignal();
if (participant) {
this.isCameraEnabled = participant.isCameraEnabled;
this.storageSrv.setCameraEnabled(this.isCameraEnabled);
}
});
}
async ngOnInit() {
this.subscribeToParticipantMediaProperties();
this.hasVideoDevices = this.deviceSrv.hasVideoDeviceAvailable();
if (this.hasVideoDevices) {
this.cameras = this.deviceSrv.getCameras();
this.cameraSelected = this.deviceSrv.getCameraSelected();
}
this.onVideoDevicesLoaded.emit(this.cameras);
// Emit initial device list (reactively)
this.onVideoDevicesLoaded.emit(this.cameras());
this.isCameraEnabled = this.participantService.isMyCameraEnabled();
}
async ngOnDestroy() {
this.cameras = [];
if (this.localParticipantSubscription) this.localParticipantSubscription.unsubscribe();
}
async toggleCam(event: any) {
event.stopPropagation();
this.cameraStatusChanging = true;
@ -75,14 +73,10 @@ export class VideoDevicesComponent implements OnInit, OnDestroy {
// Is New deviceId different from the old one?
if (this.deviceSrv.needUpdateVideoTrack(device)) {
this.cameraStatusChanging = true;
await this.participantService.switchCamera(device.device);
this.deviceSrv.setCameraSelected(device.device);
this.cameraSelected = device;
this.onVideoDeviceChanged.emit(this.cameraSelected);
this.onVideoDeviceChanged.emit(this.cameraSelected());
}
} catch (error) {
this.log.e('Error switching camera', error);
@ -98,17 +92,4 @@ export class VideoDevicesComponent implements OnInit, OnDestroy {
compareObjectDevices(o1: CustomDevice, o2: CustomDevice): boolean {
return o1.label === o2.label;
}
/**
* This subscription is necessary to update the camera status when the user changes it from toolbar and
* the settings panel is opened. With this, the camera status is updated in the settings panel.
*/
private subscribeToParticipantMediaProperties() {
this.localParticipantSubscription = this.participantService.localParticipant$.subscribe((p: ParticipantModel | undefined) => {
if (p) {
this.isCameraEnabled = p.isCameraEnabled;
this.storageSrv.setCameraEnabled(this.isCameraEnabled);
}
});
}
}

View File

@ -421,9 +421,12 @@ export class OpenViduService {
// Video device
if (videoDeviceId === true) {
options.video = this.deviceService.hasVideoDeviceAvailable()
? { deviceId: this.deviceService.getCameraSelected()?.device || 'default' }
: false;
if (this.deviceService.hasVideoDeviceAvailable()) {
const selectedCamera = this.deviceService.getCameraSelected();
options.video = { deviceId: selectedCamera?.device || 'default' };
} else {
options.video = false;
}
} else if (videoDeviceId === false) {
options.video = false;
} else {
@ -433,8 +436,8 @@ export class OpenViduService {
// Audio device
if (audioDeviceId === true) {
if (this.deviceService.hasAudioDeviceAvailable()) {
audioDeviceId = this.deviceService.getMicrophoneSelected()?.device || 'default';
(options.audio as AudioCaptureOptions).deviceId = audioDeviceId;
const selectedMic = this.deviceService.getMicrophoneSelected();
(options.audio as AudioCaptureOptions).deviceId = selectedMic?.device || 'default';
} else {
options.audio = false;
}