ov-components: Refactors device service for performance

Replaces the old device service with a new implementation using Angular Signals for reactive state management.

This enhances performance by:
- Improving permission requests
- Providing live device detection
- Providing better error handling

The new implementation also integrates with the LiveKit client for track management.
master
CSantosM 2026-01-27 15:40:42 +01:00
parent 5edb36670b
commit f4264a2a8a
1 changed files with 605 additions and 158 deletions

View File

@ -1,87 +1,467 @@
import { Injectable } from '@angular/core';
import { computed, Injectable, OnDestroy, signal } from '@angular/core';
import { createLocalTracks, LocalTrack, Room, Track } from 'livekit-client';
import { CameraType, CustomDevice, DeviceType } from '../../models/device.model';
import { ILogger } from '../../models/logger.model';
import { LoggerService } from '../logger/logger.service';
import { PlatformService } from '../platform/platform.service';
import { StorageService } from '../storage/storage.service';
import { LocalTrack, Room, createLocalTracks } from 'livekit-client';
/**
* Device availability state for each media type
*/
interface DeviceAvailabilityState {
hasDevices: boolean;
isEnabled: boolean;
permissionGranted: boolean;
error?: string;
}
/**
* Device service with improved performance and independent audio/video handling.
*
* Key improvements:
* - Smart permission requests (single prompt when possible, fallback to separate)
* - Angular Signals for reactive state management (cameras, microphones as signals)
* - Live device detection - automatically updates when devices are connected/disconnected
* - Better error handling with specific error types per device
* - Performance optimizations with caching
* - LiveKit client integration for modern track management
*
* @internal
*/
@Injectable({
providedIn: 'root'
})
export class DeviceService {
private devices: MediaDeviceInfo[];
private cameras: CustomDevice[] = [];
private microphones: CustomDevice[] = [];
private cameraSelected?: CustomDevice;
private microphoneSelected?: CustomDevice;
export class DeviceService implements OnDestroy {
// Reactive device lists with Signals
readonly cameras = signal<CustomDevice[]>([]);
readonly microphones = signal<CustomDevice[]>([]);
readonly cameraSelected = signal<CustomDevice | undefined>(undefined);
readonly microphoneSelected = signal<CustomDevice | undefined>(undefined);
// Reactive state management with Signals
private readonly videoState = signal<DeviceAvailabilityState>({
hasDevices: false,
isEnabled: true,
permissionGranted: false
});
private readonly audioState = signal<DeviceAvailabilityState>({
hasDevices: false,
isEnabled: true,
permissionGranted: false
});
// Computed signals for common checks
readonly hasVideoDevices = computed(() =>
this.videoState().hasDevices && this.cameras().length > 0
);
readonly hasAudioDevices = computed(() =>
this.audioState().hasDevices && this.microphones().length > 0
);
readonly hasVideoPermission = computed(() =>
this.videoState().permissionGranted
);
readonly hasAudioPermission = computed(() =>
this.audioState().permissionGranted
);
readonly allPermissionsGranted = computed(() =>
this.videoState().permissionGranted && this.audioState().permissionGranted
);
// Constants
private readonly CACHE_DURATION = 5000; // 5 seconds
// Internal state
private devicesCache: {
timestamp: number;
devices: MediaDeviceInfo[];
} | null = null;
private log: ILogger;
private videoDevicesEnabled: boolean = true;
private audioDevicesEnabled: boolean = true;
private deviceAccessDeniedError: boolean = false;
private initializationPromise: Promise<void> | null = null;
private deviceChangeHandler: (() => void) | null = null;
constructor(
private loggerSrv: LoggerService,
private platformSrv: PlatformService,
private storageSrv: StorageService
) {
this.log = this.loggerSrv.get('DevicesService');
this.log = this.loggerSrv.get('DeviceService');
}
/**
* Initialize media devices and select a devices checking in local storage (if exists) or
* first devices found by default
* Cleanup when service is destroyed
*/
async initializeDevices() {
ngOnDestroy(): void {
// Remove device change listener
if (this.deviceChangeHandler && navigator.mediaDevices?.removeEventListener) {
navigator.mediaDevices.removeEventListener('devicechange', this.deviceChangeHandler);
this.deviceChangeHandler = null;
this.log.d('Device change detection disabled');
}
}
/**
* Initialize media devices with parallel audio/video handling
* Returns a promise that resolves when initialization is complete
*/
async initializeDevices(): Promise<void> {
// Prevent multiple simultaneous initializations
if (this.initializationPromise) {
return this.initializationPromise;
}
this.initializationPromise = this.performInitialization();
try {
await this.initializationPromise;
} finally {
this.initializationPromise = null;
}
}
private async performInitialization(): Promise<void> {
this.clear();
try {
this.devices = await this.getLocalDevices();
if (this.deviceAccessDeniedError) {
this.log.w('Media devices permissions were not granted.');
// Try to get devices with parallel audio/video permission requests
const devices = await this.getLocalDevicesOptimized();
if (devices.length === 0) {
this.log.w('No media devices found or permissions denied');
return;
}
this.initializeCustomDevices();
this.processDevices(devices);
this.updateSelectedDevices();
this.log.d('Media devices', this.cameras, this.microphones);
// Setup live device detection
this.setupDeviceChangeDetection();
this.log.d('Media devices initialized', {
cameras: this.cameras().length,
microphones: this.microphones().length
});
} catch (error) {
this.log.e('Error getting media devices', error);
this.log.e('Error initializing devices', error);
throw error;
}
}
/**
* Check and update the media devices available
* Optimized device retrieval with independent audio/video handling
* This solves the critical bug where audio device failure affects video device detection
*/
async refreshDevices() {
if (!this.deviceAccessDeniedError) {
this.devices = await this.getLocalDevices();
this.initializeCustomDevices();
private async getLocalDevicesOptimized(): Promise<MediaDeviceInfo[]> {
// Check cache first
if (this.devicesCache && Date.now() - this.devicesCache.timestamp < this.CACHE_DURATION) {
this.log.d('Using cached devices');
return this.devicesCache.devices;
}
try {
// Try parallel permission requests for better performance
const results = await this.requestPermissionsParallel();
// Get devices after permissions are granted
const devices = await this.enumerateDevices();
// Update cache
this.devicesCache = {
timestamp: Date.now(),
devices
};
// Update state based on results
this.updateDeviceStates(results);
return devices;
} catch (error) {
this.log.e('Error getting devices', error);
// Fallback: try to enumerate devices without permissions
return await this.fallbackDeviceEnumeration();
}
}
private initializeCustomDevices(): void {
this.cameras = this.devices
/**
* Smart permission request strategy:
* 1. Try both together (single prompt - better UX)
* 2. If fails, try individually (fallback for granular permissions)
*
* This minimizes user friction while maintaining independence
*/
private async requestPermissionsParallel(): Promise<{
video: { success: boolean; error?: any };
audio: { success: boolean; error?: any };
}> {
const results = {
video: { success: false, error: undefined as any },
audio: { success: false, error: undefined as any }
};
// Strategy 1: Try requesting both together (single prompt)
try {
this.log.d('Requesting both audio and video permissions together');
const tracks = await createLocalTracks({ audio: true, video: true });
// Check which tracks we got
const videoTrack = tracks.find(t => t.kind === Track.Kind.Video);
const audioTrack = tracks.find(t => t.kind === Track.Kind.Audio);
if (videoTrack) {
results.video.success = true;
this.log.d('Video permission granted');
}
if (audioTrack) {
results.audio.success = true;
this.log.d('Audio permission granted');
}
// Stop tracks immediately after getting permission
tracks.forEach(t => t.stop());
// If both succeeded, return early (best case - single prompt!)
if (results.video.success && results.audio.success) {
this.log.d('Both permissions granted with single prompt');
return results;
}
} catch (error: any) {
this.log.w('Combined permission request failed, trying individually', error);
// Continue to fallback strategy
}
// Strategy 2: Fallback - request individually if combined request failed
// This handles cases where user denied one but might allow the other
const promises: Promise<void>[] = [];
// Try video if not already granted
if (!results.video.success) {
promises.push(
this.requestVideoPermission().then(
(tracks) => {
results.video.success = true;
tracks.forEach(t => t.stop());
this.log.d('Video permission granted individually');
},
(error) => {
results.video.error = error;
this.log.w('Video permission denied', error);
}
)
);
}
// Try audio if not already granted
if (!results.audio.success) {
promises.push(
this.requestAudioPermission().then(
(tracks) => {
results.audio.success = true;
tracks.forEach(t => t.stop());
this.log.d('Audio permission granted individually');
},
(error) => {
results.audio.error = error;
this.log.w('Audio permission denied', error);
}
)
);
}
// Wait for fallback requests to complete
if (promises.length > 0) {
await Promise.allSettled(promises);
}
return results;
}
/**
* Request video permission independently
*/
private async requestVideoPermission(): Promise<LocalTrack[]> {
try {
return await createLocalTracks({ audio: false, video: true });
} catch (error: any) {
this.videoState.update(state => ({
...state,
permissionGranted: false,
error: error.name || 'Unknown error'
}));
throw error;
}
}
/**
* Request audio permission independently
*/
private async requestAudioPermission(): Promise<LocalTrack[]> {
try {
return await createLocalTracks({ audio: true, video: false });
} catch (error: any) {
this.audioState.update(state => ({
...state,
permissionGranted: false,
error: error.name || 'Unknown error'
}));
throw error;
}
}
/**
* Enumerate devices using LiveKit's Room API or browser API
*/
private async enumerateDevices(): Promise<MediaDeviceInfo[]> {
try {
// Use LiveKit's Room.getLocalDevices if available, otherwise fallback to browser API
const devices = await Room.getLocalDevices();
return this.filterValidDevices(devices);
} catch (error) {
this.log.w('LiveKit device enumeration failed, using browser API', error);
// Firefox compatibility
if (this.platformSrv.isFirefox()) {
return await this.getDevicesFirefox();
}
const devices = await navigator.mediaDevices.enumerateDevices();
return this.filterValidDevices(devices);
}
}
/**
* Firefox-specific device enumeration
*/
private async getDevicesFirefox(): Promise<MediaDeviceInfo[]> {
try {
// Firefox may need explicit getUserMedia call
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
stream.getTracks().forEach(track => track.stop());
const devices = await navigator.mediaDevices.enumerateDevices();
return this.filterValidDevices(devices);
} catch (error) {
this.log.w('Firefox getUserMedia failed, trying enumerate directly', error);
const devices = await navigator.mediaDevices.enumerateDevices();
return this.filterValidDevices(devices);
}
}
/**
* Filter out invalid or default devices
*/
private filterValidDevices(devices: MediaDeviceInfo[]): MediaDeviceInfo[] {
return devices.filter(
(d) => d.label && d.deviceId && d.deviceId !== 'default'
);
}
/**
* Fallback device enumeration without permissions
*/
private async fallbackDeviceEnumeration(): Promise<MediaDeviceInfo[]> {
try {
this.log.d('Attempting device enumeration without permissions');
const devices = await navigator.mediaDevices.enumerateDevices();
// Filter devices that have IDs but may not have labels
return devices.filter(d => d.deviceId && d.deviceId !== 'default');
} catch (error) {
this.log.e('Fallback device enumeration failed', error);
return [];
}
}
/**
* Update device states based on permission results
*/
private updateDeviceStates(results: {
video: { success: boolean; error?: any };
audio: { success: boolean; error?: any };
}): void {
// Update video state
this.videoState.update(state => ({
...state,
permissionGranted: results.video.success,
error: results.video.error?.name
}));
// Update audio state
this.audioState.update(state => ({
...state,
permissionGranted: results.audio.success,
error: results.audio.error?.name
}));
}
/**
* Process raw devices into typed camera and microphone lists
*/
private processDevices(devices: MediaDeviceInfo[]): void {
// Process video devices
const camerasArray = devices
.filter((d) => d.kind === DeviceType.VIDEO_INPUT)
.map((d) => this.createCustomDevice(d, CameraType.BACK));
this.microphones = this.devices
// Process audio devices
const microphonesArray = devices
.filter((d) => d.kind === DeviceType.AUDIO_INPUT)
.map((d) => ({ label: d.label, device: d.deviceId }));
// Detect camera types (front/back)
this.detectCameraTypes(camerasArray);
// Update signals
this.cameras.set(camerasArray);
this.microphones.set(microphonesArray);
// Update availability states
this.updateDeviceAvailability(camerasArray.length, microphonesArray.length);
}
/**
* Detect camera types (front/back) based on platform and labels
*/
private detectCameraTypes(cameras: CustomDevice[]): void {
if (cameras.length === 0) return;
if (this.platformSrv.isMobile()) {
this.cameras.forEach((c) => {
if (c.label.toLowerCase().includes(CameraType.FRONT.toLowerCase())) {
c.type = CameraType.FRONT;
// On mobile, detect by label
cameras.forEach((camera) => {
if (camera.label.toLowerCase().includes(CameraType.FRONT.toLowerCase())) {
camera.type = CameraType.FRONT;
}
});
} else if (this.cameras.length > 0) {
this.cameras[0].type = CameraType.FRONT;
} else {
// On desktop, first camera is typically front-facing
cameras[0].type = CameraType.FRONT;
}
}
/**
* Update device availability states
*/
private updateDeviceAvailability(cameraCount: number, microphoneCount: number): void {
this.videoState.update(state => ({
...state,
hasDevices: cameraCount > 0
}));
this.audioState.update(state => ({
...state,
hasDevices: microphoneCount > 0
}));
}
/**
* Create custom device object
*/
private createCustomDevice(device: MediaDeviceInfo, defaultType: CameraType): CustomDevice {
return {
label: device.label,
@ -90,181 +470,248 @@ export class DeviceService {
};
}
private updateSelectedDevices() {
this.cameraSelected = this.getDeviceFromStorage(this.cameras, this.storageSrv.getVideoDevice()) || this.cameras[0];
this.microphoneSelected = this.getDeviceFromStorage(this.microphones, this.storageSrv.getAudioDevice()) || this.microphones[0];
}
/**
* Update selected devices from storage or use defaults
*/
private updateSelectedDevices(): void {
const storedCamera = this.storageSrv.getVideoDevice();
const selectedCam = this.findDeviceOrDefault(
this.cameras(),
storedCamera?.device
);
if (selectedCam) {
this.cameraSelected.set(selectedCam);
}
private getDeviceFromStorage(devices: CustomDevice[], storageDevice: CustomDevice | null): CustomDevice | undefined {
if (!storageDevice) return;
return devices.find((d) => d.device === storageDevice.device);
const storedMic = this.storageSrv.getAudioDevice();
const selectedMic = this.findDeviceOrDefault(
this.microphones(),
storedMic?.device
);
if (selectedMic) {
this.microphoneSelected.set(selectedMic);
}
}
/**
* @internal
* Find device by ID or return first available
*/
private findDeviceOrDefault(devices: CustomDevice[], deviceId?: string): CustomDevice | undefined {
if (devices.length === 0) return undefined;
return deviceId
? devices.find((d) => d.device === deviceId) || devices[0]
: devices[0];
}
/**
* Refresh devices (e.g., when a device is plugged/unplugged)
*/
async refreshDevices(): Promise<void> {
// Invalidate cache
this.devicesCache = null;
const devices = await this.getLocalDevicesOptimized();
this.processDevices(devices);
this.updateSelectedDevices();
this.log.d('Devices refreshed', {
cameras: this.cameras().length,
microphones: this.microphones().length
});
}
/**
* Setup live device change detection
* Automatically refreshes device list when devices are connected/disconnected
*/
private setupDeviceChangeDetection(): void {
if (!navigator.mediaDevices?.addEventListener) {
this.log.w('Device change detection not supported');
return;
}
// Remove existing listener if any
if (this.deviceChangeHandler) {
navigator.mediaDevices.removeEventListener('devicechange', this.deviceChangeHandler);
}
// Create new handler
this.deviceChangeHandler = async () => {
this.log.d('Device change detected, refreshing device list');
await this.refreshDevices();
};
// Register listener
navigator.mediaDevices.addEventListener('devicechange', this.deviceChangeHandler);
this.log.d('Device change detection enabled');
}
// Public API methods (compatible with original DeviceService)
/**
* Check if camera is enabled based on storage and device availability
*/
isCameraEnabled(): boolean {
return this.hasVideoDeviceAvailable() && this.storageSrv.isCameraEnabled();
}
/**
* Check if microphone is enabled based on storage and device availability
*/
isMicrophoneEnabled(): boolean {
return this.hasAudioDeviceAvailable() && this.storageSrv.isMicrophoneEnabled();
}
/**
* Get currently selected camera
*/
getCameraSelected(): CustomDevice | undefined {
return this.cameraSelected;
return this.cameraSelected();
}
/**
* Get currently selected microphone
*/
getMicrophoneSelected(): CustomDevice | undefined {
return this.microphoneSelected;
return this.microphoneSelected();
}
setCameraSelected(deviceId: any) {
this.cameraSelected = this.getDeviceById(this.cameras, deviceId);
const saveFunction = (device) => this.storageSrv.setVideoDevice(device);
this.saveDeviceToStorage(this.cameraSelected, saveFunction);
/**
* Set selected camera and persist to storage
*/
setCameraSelected(deviceId: string): void {
const device = this.cameras().find((c) => c.device === deviceId);
if (!device) {
this.log.w('Camera not found:', deviceId);
return;
}
this.cameraSelected.set(device);
this.storageSrv.setVideoDevice(device);
this.log.d('Camera selected:', device.label);
}
setMicSelected(deviceId: string) {
this.microphoneSelected = this.getDeviceById(this.microphones, deviceId);
const saveFunction = (device) => this.storageSrv.setAudioDevice(device);
this.saveDeviceToStorage(this.microphoneSelected, saveFunction);
/**
* Set selected microphone and persist to storage
*/
setMicSelected(deviceId: string): void {
const device = this.microphones().find((m) => m.device === deviceId);
if (!device) {
this.log.w('Microphone not found:', deviceId);
return;
}
this.microphoneSelected.set(device);
this.storageSrv.setAudioDevice(device);
this.log.d('Microphone selected:', device.label);
}
/**
* Check if video track needs to be updated
*/
needUpdateVideoTrack(newDevice: CustomDevice): boolean {
return this.cameraSelected?.device !== newDevice.device || this.cameraSelected?.label !== newDevice.label;
const current = this.cameraSelected();
return (
current?.device !== newDevice.device ||
current?.label !== newDevice.label
);
}
/**
* Check if audio track needs to be updated
*/
needUpdateAudioTrack(newDevice: CustomDevice): boolean {
return this.microphoneSelected?.device !== newDevice.device || this.microphoneSelected?.label !== newDevice.label;
const current = this.microphoneSelected();
return (
current?.device !== newDevice.device ||
current?.label !== newDevice.label
);
}
// ==========================================
// Public API - Device Access
// ==========================================
/**
* Get list of available cameras
*/
getCameras(): CustomDevice[] {
return this.cameras;
return this.cameras();
}
/**
* Get list of available microphones
*/
getMicrophones(): CustomDevice[] {
return this.microphones;
return this.microphones();
}
// ==========================================
// Public API - Device State
// ==========================================
/**
* Check if video devices are available
*/
hasVideoDeviceAvailable(): boolean {
return this.videoDevicesEnabled && this.cameras.length > 0;
return this.hasVideoDevices();
}
/**
* Check if audio devices are available
*/
hasAudioDeviceAvailable(): boolean {
return this.audioDevicesEnabled && this.microphones.length > 0;
return this.hasAudioDevices();
}
clear() {
this.devices = [];
this.cameras = [];
this.microphones = [];
this.cameraSelected = undefined;
this.microphoneSelected = undefined;
this.videoDevicesEnabled = true;
this.audioDevicesEnabled = true;
}
// ==========================================
// Public API - Permission State
// ==========================================
private getDeviceById(devices: CustomDevice[], deviceId: string): CustomDevice | undefined {
return devices.find((d) => d.device === deviceId);
}
private saveDeviceToStorage(device: CustomDevice | undefined, saveFunction: (device: CustomDevice) => void) {
if (device) saveFunction(device);
/**
* Check if video permission was granted
*/
hasVideoPermissionGranted(): boolean {
return this.hasVideoPermission();
}
/**
* Retrieves the local media devices (audio and video) available for the user.
*
* @returns A promise that resolves to an array of `MediaDeviceInfo` objects representing the available local devices.
* Check if audio permission was granted
*/
private async getLocalDevices(): Promise<MediaDeviceInfo[]> {
const strategies = this.getPermissionStrategies();
for (const strategy of strategies) {
try {
this.log.d(`Trying strategy: audio=${strategy.audio}, video=${strategy.video}`);
const devices = await this.tryPermissionStrategy(strategy);
if (devices) {
return this.filterValidDevices(devices);
}
} catch (error: any) {
this.log.w(`Strategy failed: audio=${strategy.audio}, video=${strategy.video}`, error);
// If it's the last attempt and failed, we handle the error
if (strategy === strategies[strategies.length - 1]) {
return await this.handleFinalFallback(error);
}
}
}
return [];
hasAudioPermissionGranted(): boolean {
return this.hasAudioPermission();
}
// ==========================================
// Public API - Reactive State Access
// For components that need direct signal access, use:
// - this.cameras, this.microphones (device lists)
// - this.cameraSelected, this.microphoneSelected (selections)
// - this.hasVideoDevices, this.hasAudioDevices (availability)
// - this.hasVideoPermission, this.hasAudioPermission (permissions)
// - this.allPermissionsGranted (combined permissions)
// ==========================================
/**
* @internal
* Get the list of permission strategies to try
* Clear all device data
*/
protected getPermissionStrategies(): Array<{ audio: boolean; video: boolean }> {
return [
{ audio: true, video: true },
{ audio: true, video: false },
{ audio: false, video: true }
];
}
clear(): void {
this.cameras.set([]);
this.microphones.set([]);
this.cameraSelected.set(undefined);
this.microphoneSelected.set(undefined);
this.devicesCache = null;
/**
* @internal
* Try a specific permission strategy and return devices if successful
*/
protected async tryPermissionStrategy(strategy: { audio: boolean; video: boolean }): Promise<MediaDeviceInfo[] | null> {
const localTracks = await createLocalTracks(strategy);
localTracks.forEach((track) => track.stop());
this.videoState.set({
hasDevices: false,
isEnabled: true,
permissionGranted: false
});
// Permission granted
return this.platformSrv.isFirefox() ? await this.getMediaDevicesFirefox() : await Room.getLocalDevices();
}
/**
* @internal
* Filter devices to remove default and invalid entries
*/
protected filterValidDevices(devices: MediaDeviceInfo[]): MediaDeviceInfo[] {
return devices.filter((d: MediaDeviceInfo) => d.label && d.deviceId && d.deviceId !== 'default');
}
private async getMediaDevicesFirefox(): Promise<MediaDeviceInfo[]> {
// Firefox requires to get user media to get the devices
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
return navigator.mediaDevices.enumerateDevices();
}
private async handleFinalFallback(error: any): Promise<MediaDeviceInfo[]> {
this.log.w('All permission strategies failed, trying device enumeration without permissions');
try {
return await this.handleFallbackByErrorType(error);
} catch (error) {
this.log.e('Complete failure getting devices', error);
this.deviceAccessDeniedError = true;
return [];
}
}
/**
* @internal
* Handle fallback based on error type
*/
protected async handleFallbackByErrorType(error: any): Promise<MediaDeviceInfo[]> {
if (error?.name === 'NotReadableError' || error?.name === 'AbortError') {
this.log.w('Device busy, using enumerateDevices() instead');
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter((d) => d.deviceId && d.deviceId !== 'default');
}
if (error?.name === 'NotAllowedError' || error?.name === 'SecurityError') {
this.log.w('Permission denied to access devices');
this.deviceAccessDeniedError = true;
}
return [];
this.audioState.set({
hasDevices: false,
isEnabled: true,
permissionGranted: false
});
}
}