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

View File

@ -1,8 +1,6 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { Component, effect, EventEmitter, Input, OnInit, Output, Signal, WritableSignal } from '@angular/core';
import { Subscription } from 'rxjs';
import { CustomDevice } from '../../../models/device.model'; import { CustomDevice } from '../../../models/device.model';
import { ILogger } from '../../../models/logger.model'; import { ILogger } from '../../../models/logger.model';
import { ParticipantModel } from '../../../models/participant.model';
import { DeviceService } from '../../../services/device/device.service'; import { DeviceService } from '../../../services/device/device.service';
import { LoggerService } from '../../../services/logger/logger.service'; import { LoggerService } from '../../../services/logger/logger.service';
import { ParticipantService } from '../../../services/participant/participant.service'; import { ParticipantService } from '../../../services/participant/participant.service';
@ -17,19 +15,20 @@ import { StorageService } from '../../../services/storage/storage.service';
styleUrls: ['./audio-devices.component.scss'], styleUrls: ['./audio-devices.component.scss'],
standalone: false standalone: false
}) })
export class AudioDevicesComponent implements OnInit, OnDestroy { export class AudioDevicesComponent implements OnInit {
@Input() compact: boolean = false; @Input() compact: boolean = false;
@Output() onAudioDeviceChanged = new EventEmitter<CustomDevice>(); @Output() onAudioDeviceChanged = new EventEmitter<CustomDevice>();
@Output() onAudioEnabledChanged = new EventEmitter<boolean>(); @Output() onAudioEnabledChanged = new EventEmitter<boolean>();
microphoneStatusChanging: boolean; microphoneStatusChanging: boolean = false;
hasAudioDevices: boolean; isMicrophoneEnabled: boolean = false;
isMicrophoneEnabled: boolean;
microphoneSelected: CustomDevice | undefined;
microphones: CustomDevice[] = [];
private localParticipantSubscription: Subscription;
private log: ILogger; 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( constructor(
private deviceSrv: DeviceService, private deviceSrv: DeviceService,
private storageSrv: StorageService, private storageSrv: StorageService,
@ -37,24 +36,24 @@ export class AudioDevicesComponent implements OnInit, OnDestroy {
private loggerSrv: LoggerService private loggerSrv: LoggerService
) { ) {
this.log = this.loggerSrv.get('AudioDevicesComponent'); 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() { 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(); this.isMicrophoneEnabled = this.participantService.isMyMicrophoneEnabled();
} }
ngOnDestroy() {
this.microphones = [];
if (this.localParticipantSubscription) this.localParticipantSubscription.unsubscribe();
}
async toggleMic(event: any) { async toggleMic(event: any) {
event.stopPropagation(); event.stopPropagation();
this.microphoneStatusChanging = true; this.microphoneStatusChanging = true;
@ -72,8 +71,7 @@ export class AudioDevicesComponent implements OnInit, OnDestroy {
this.microphoneStatusChanging = true; this.microphoneStatusChanging = true;
await this.participantService.switchMicrophone(device.device); await this.participantService.switchMicrophone(device.device);
this.deviceSrv.setMicSelected(device.device); this.deviceSrv.setMicSelected(device.device);
this.microphoneSelected = this.deviceSrv.getMicrophoneSelected(); this.onAudioDeviceChanged.emit(this.microphoneSelected());
this.onAudioDeviceChanged.emit(this.microphoneSelected);
} }
} catch (error) { } catch (error) {
this.log.e('Error switching microphone', error); this.log.e('Error switching microphone', error);
@ -89,17 +87,4 @@ export class AudioDevicesComponent implements OnInit, OnDestroy {
compareObjectDevices(o1: CustomDevice, o2: CustomDevice): boolean { compareObjectDevices(o1: CustomDevice, o2: CustomDevice): boolean {
return o1.label === o2.label; 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"> <div class="video-device-selector" [class.compact]="compact">
<!-- Unified Device Button (Compact Mode) --> <!-- Unified Device Button (Compact Mode) -->
@if (compact) { @if (compact) {
@if (hasVideoDevices) { @if (hasVideoDevices()) {
<div class="unified-device-button"> <div class="unified-device-button">
<!-- Main toggle button --> <!-- Main toggle button -->
<button <button
mat-flat-button mat-flat-button
class="toggle-section" class="toggle-section"
[disabled]="!hasVideoDevices || cameraStatusChanging" [disabled]="!hasVideoDevices() || cameraStatusChanging"
[class.device-enabled]="isCameraEnabled" [class.device-enabled]="isCameraEnabled"
[class.device-disabled]="!isCameraEnabled" [class.device-disabled]="!isCameraEnabled"
(click)="toggleCam($event)" (click)="toggleCam($event)"
[matTooltip]="isCameraEnabled ? ('TOOLBAR.STOP_VIDEO' | translate) : ('TOOLBAR.START_VIDEO' | translate)" [matTooltip]="isCameraEnabled ? ('TOOLBAR.STOP_VIDEO' | translate) : ('TOOLBAR.START_VIDEO' | translate)"
[matTooltipDisabled]="!hasVideoDevices" [matTooltipDisabled]="!hasVideoDevices()"
id="camera-button" id="camera-button"
> >
<mat-icon [id]="isCameraEnabled ? 'videocam' : 'videocam_off'"> <mat-icon [id]="isCameraEnabled ? 'videocam' : 'videocam_off'">
@ -51,15 +51,15 @@
id="video-dropdown" id="video-dropdown"
class="selector-button" class="selector-button"
[matMenuTriggerFor]="cameraMenu" [matMenuTriggerFor]="cameraMenu"
[disabled]="cameraStatusChanging || cameras.length <= 1" [disabled]="cameraStatusChanging || cameras().length <= 1"
> >
<mat-icon class="device-icon">videocam</mat-icon> <mat-icon class="device-icon">videocam</mat-icon>
<span class="selected-device-name">{{ cameraSelected?.label || 'No camera selected' }}</span> <span class="selected-device-name">{{ cameraSelected()?.label || 'No camera selected' }}</span>
<mat-icon class="dropdown-icon" *ngIf="cameras.length > 1">expand_more</mat-icon> <mat-icon class="dropdown-icon" *ngIf="cameras().length > 1">expand_more</mat-icon>
</button> </button>
</div> </div>
} @else { } @else {
@if (hasVideoDevices) { @if (hasVideoDevices()) {
<!-- Disabled state message --> <!-- Disabled state message -->
<div class="device-input-selector disabled"> <div class="device-input-selector disabled">
<div class="selector-button disabled"> <div class="selector-button disabled">
@ -80,14 +80,14 @@
<!-- Device Selection Menu (Shared) --> <!-- Device Selection Menu (Shared) -->
<mat-menu #cameraMenu="matMenu" class="device-menu"> <mat-menu #cameraMenu="matMenu" class="device-menu">
@for (camera of cameras; track camera.device) { @for (camera of cameras(); track camera.device) {
<button <button
mat-menu-item mat-menu-item
id="option-{{ camera.label }}" id="option-{{ camera.label }}"
(click)="onCameraSelected({ value: camera })" (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> <span>{{ camera.label }}</span>
</button> </button>
} }

View File

@ -1,8 +1,6 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { Component, effect, EventEmitter, Input, OnInit, Output, Signal, WritableSignal } from '@angular/core';
import { Subscription } from 'rxjs';
import { CustomDevice } from '../../../models/device.model'; import { CustomDevice } from '../../../models/device.model';
import { ILogger } from '../../../models/logger.model'; import { ILogger } from '../../../models/logger.model';
import { ParticipantModel } from '../../../models/participant.model';
import { DeviceService } from '../../../services/device/device.service'; import { DeviceService } from '../../../services/device/device.service';
import { LoggerService } from '../../../services/logger/logger.service'; import { LoggerService } from '../../../services/logger/logger.service';
import { ParticipantService } from '../../../services/participant/participant.service'; import { ParticipantService } from '../../../services/participant/participant.service';
@ -17,18 +15,18 @@ import { StorageService } from '../../../services/storage/storage.service';
styleUrls: ['./video-devices.component.scss'], styleUrls: ['./video-devices.component.scss'],
standalone: false standalone: false
}) })
export class VideoDevicesComponent implements OnInit, OnDestroy { export class VideoDevicesComponent implements OnInit {
@Input() compact: boolean = false; @Input() compact: boolean = false;
@Output() onVideoDeviceChanged = new EventEmitter<CustomDevice>(); @Output() onVideoDeviceChanged = new EventEmitter<CustomDevice>();
@Output() onVideoEnabledChanged = new EventEmitter<boolean>(); @Output() onVideoEnabledChanged = new EventEmitter<boolean>();
@Output() onVideoDevicesLoaded = new EventEmitter<CustomDevice[]>(); @Output() onVideoDevicesLoaded = new EventEmitter<CustomDevice[]>();
cameraStatusChanging: boolean; cameraStatusChanging: boolean = false;
isCameraEnabled: boolean; isCameraEnabled: boolean = false;
cameraSelected: CustomDevice | undefined;
hasVideoDevices: boolean; protected readonly cameras: WritableSignal<CustomDevice[]>;
cameras: CustomDevice[] = []; protected readonly cameraSelected: WritableSignal<CustomDevice | undefined>;
localParticipantSubscription: Subscription; protected readonly hasVideoDevices: Signal<boolean>;
private log: ILogger; private log: ILogger;
@ -39,26 +37,26 @@ export class VideoDevicesComponent implements OnInit, OnDestroy {
private loggerSrv: LoggerService private loggerSrv: LoggerService
) { ) {
this.log = this.loggerSrv.get('VideoDevicesComponent'); 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() { async ngOnInit() {
this.subscribeToParticipantMediaProperties(); // Emit initial device list (reactively)
this.onVideoDevicesLoaded.emit(this.cameras());
this.hasVideoDevices = this.deviceSrv.hasVideoDeviceAvailable();
if (this.hasVideoDevices) {
this.cameras = this.deviceSrv.getCameras();
this.cameraSelected = this.deviceSrv.getCameraSelected();
}
this.onVideoDevicesLoaded.emit(this.cameras);
this.isCameraEnabled = this.participantService.isMyCameraEnabled(); this.isCameraEnabled = this.participantService.isMyCameraEnabled();
} }
async ngOnDestroy() {
this.cameras = [];
if (this.localParticipantSubscription) this.localParticipantSubscription.unsubscribe();
}
async toggleCam(event: any) { async toggleCam(event: any) {
event.stopPropagation(); event.stopPropagation();
this.cameraStatusChanging = true; this.cameraStatusChanging = true;
@ -75,14 +73,10 @@ export class VideoDevicesComponent implements OnInit, OnDestroy {
// Is New deviceId different from the old one? // Is New deviceId different from the old one?
if (this.deviceSrv.needUpdateVideoTrack(device)) { if (this.deviceSrv.needUpdateVideoTrack(device)) {
this.cameraStatusChanging = true; this.cameraStatusChanging = true;
await this.participantService.switchCamera(device.device); await this.participantService.switchCamera(device.device);
this.deviceSrv.setCameraSelected(device.device); this.deviceSrv.setCameraSelected(device.device);
this.cameraSelected = device; this.onVideoDeviceChanged.emit(this.cameraSelected());
this.onVideoDeviceChanged.emit(this.cameraSelected);
} }
} catch (error) { } catch (error) {
this.log.e('Error switching camera', error); this.log.e('Error switching camera', error);
@ -98,17 +92,4 @@ export class VideoDevicesComponent implements OnInit, OnDestroy {
compareObjectDevices(o1: CustomDevice, o2: CustomDevice): boolean { compareObjectDevices(o1: CustomDevice, o2: CustomDevice): boolean {
return o1.label === o2.label; 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 // Video device
if (videoDeviceId === true) { if (videoDeviceId === true) {
options.video = this.deviceService.hasVideoDeviceAvailable() if (this.deviceService.hasVideoDeviceAvailable()) {
? { deviceId: this.deviceService.getCameraSelected()?.device || 'default' } const selectedCamera = this.deviceService.getCameraSelected();
: false; options.video = { deviceId: selectedCamera?.device || 'default' };
} else {
options.video = false;
}
} else if (videoDeviceId === false) { } else if (videoDeviceId === false) {
options.video = false; options.video = false;
} else { } else {
@ -433,8 +436,8 @@ export class OpenViduService {
// Audio device // Audio device
if (audioDeviceId === true) { if (audioDeviceId === true) {
if (this.deviceService.hasAudioDeviceAvailable()) { if (this.deviceService.hasAudioDeviceAvailable()) {
audioDeviceId = this.deviceService.getMicrophoneSelected()?.device || 'default'; const selectedMic = this.deviceService.getMicrophoneSelected();
(options.audio as AudioCaptureOptions).deviceId = audioDeviceId; (options.audio as AudioCaptureOptions).deviceId = selectedMic?.device || 'default';
} else { } else {
options.audio = false; options.audio = false;
} }