mirror of https://github.com/OpenVidu/openvidu.git
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
parent
5edb36670b
commit
f4264a2a8a
|
|
@ -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 { CameraType, CustomDevice, DeviceType } from '../../models/device.model';
|
||||||
import { ILogger } from '../../models/logger.model';
|
import { ILogger } from '../../models/logger.model';
|
||||||
import { LoggerService } from '../logger/logger.service';
|
import { LoggerService } from '../logger/logger.service';
|
||||||
import { PlatformService } from '../platform/platform.service';
|
import { PlatformService } from '../platform/platform.service';
|
||||||
import { StorageService } from '../storage/storage.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
|
* @internal
|
||||||
*/
|
*/
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class DeviceService {
|
export class DeviceService implements OnDestroy {
|
||||||
private devices: MediaDeviceInfo[];
|
// Reactive device lists with Signals
|
||||||
private cameras: CustomDevice[] = [];
|
readonly cameras = signal<CustomDevice[]>([]);
|
||||||
private microphones: CustomDevice[] = [];
|
readonly microphones = signal<CustomDevice[]>([]);
|
||||||
private cameraSelected?: CustomDevice;
|
readonly cameraSelected = signal<CustomDevice | undefined>(undefined);
|
||||||
private microphoneSelected?: CustomDevice;
|
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 log: ILogger;
|
||||||
private videoDevicesEnabled: boolean = true;
|
private initializationPromise: Promise<void> | null = null;
|
||||||
private audioDevicesEnabled: boolean = true;
|
private deviceChangeHandler: (() => void) | null = null;
|
||||||
private deviceAccessDeniedError: boolean = false;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private loggerSrv: LoggerService,
|
private loggerSrv: LoggerService,
|
||||||
private platformSrv: PlatformService,
|
private platformSrv: PlatformService,
|
||||||
private storageSrv: StorageService
|
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
|
* Cleanup when service is destroyed
|
||||||
* first devices found by default
|
|
||||||
*/
|
*/
|
||||||
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();
|
this.clear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.devices = await this.getLocalDevices();
|
// Try to get devices with parallel audio/video permission requests
|
||||||
if (this.deviceAccessDeniedError) {
|
const devices = await this.getLocalDevicesOptimized();
|
||||||
this.log.w('Media devices permissions were not granted.');
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
this.log.w('No media devices found or permissions denied');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.initializeCustomDevices();
|
this.processDevices(devices);
|
||||||
this.updateSelectedDevices();
|
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) {
|
} 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() {
|
private async getLocalDevicesOptimized(): Promise<MediaDeviceInfo[]> {
|
||||||
if (!this.deviceAccessDeniedError) {
|
// Check cache first
|
||||||
this.devices = await this.getLocalDevices();
|
if (this.devicesCache && Date.now() - this.devicesCache.timestamp < this.CACHE_DURATION) {
|
||||||
this.initializeCustomDevices();
|
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)
|
.filter((d) => d.kind === DeviceType.VIDEO_INPUT)
|
||||||
.map((d) => this.createCustomDevice(d, CameraType.BACK));
|
.map((d) => this.createCustomDevice(d, CameraType.BACK));
|
||||||
this.microphones = this.devices
|
|
||||||
|
// Process audio devices
|
||||||
|
const microphonesArray = devices
|
||||||
.filter((d) => d.kind === DeviceType.AUDIO_INPUT)
|
.filter((d) => d.kind === DeviceType.AUDIO_INPUT)
|
||||||
.map((d) => ({ label: d.label, device: d.deviceId }));
|
.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()) {
|
if (this.platformSrv.isMobile()) {
|
||||||
this.cameras.forEach((c) => {
|
// On mobile, detect by label
|
||||||
if (c.label.toLowerCase().includes(CameraType.FRONT.toLowerCase())) {
|
cameras.forEach((camera) => {
|
||||||
c.type = CameraType.FRONT;
|
if (camera.label.toLowerCase().includes(CameraType.FRONT.toLowerCase())) {
|
||||||
|
camera.type = CameraType.FRONT;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (this.cameras.length > 0) {
|
} else {
|
||||||
this.cameras[0].type = CameraType.FRONT;
|
// 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 {
|
private createCustomDevice(device: MediaDeviceInfo, defaultType: CameraType): CustomDevice {
|
||||||
return {
|
return {
|
||||||
label: device.label,
|
label: device.label,
|
||||||
|
|
@ -90,181 +470,248 @@ export class DeviceService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSelectedDevices() {
|
/**
|
||||||
this.cameraSelected = this.getDeviceFromStorage(this.cameras, this.storageSrv.getVideoDevice()) || this.cameras[0];
|
* Update selected devices from storage or use defaults
|
||||||
this.microphoneSelected = this.getDeviceFromStorage(this.microphones, this.storageSrv.getAudioDevice()) || this.microphones[0];
|
*/
|
||||||
}
|
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 {
|
const storedMic = this.storageSrv.getAudioDevice();
|
||||||
if (!storageDevice) return;
|
const selectedMic = this.findDeviceOrDefault(
|
||||||
return devices.find((d) => d.device === storageDevice.device);
|
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 {
|
isCameraEnabled(): boolean {
|
||||||
return this.hasVideoDeviceAvailable() && this.storageSrv.isCameraEnabled();
|
return this.hasVideoDeviceAvailable() && this.storageSrv.isCameraEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if microphone is enabled based on storage and device availability
|
||||||
|
*/
|
||||||
isMicrophoneEnabled(): boolean {
|
isMicrophoneEnabled(): boolean {
|
||||||
return this.hasAudioDeviceAvailable() && this.storageSrv.isMicrophoneEnabled();
|
return this.hasAudioDeviceAvailable() && this.storageSrv.isMicrophoneEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get currently selected camera
|
||||||
|
*/
|
||||||
getCameraSelected(): CustomDevice | undefined {
|
getCameraSelected(): CustomDevice | undefined {
|
||||||
return this.cameraSelected;
|
return this.cameraSelected();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get currently selected microphone
|
||||||
|
*/
|
||||||
getMicrophoneSelected(): CustomDevice | undefined {
|
getMicrophoneSelected(): CustomDevice | undefined {
|
||||||
return this.microphoneSelected;
|
return this.microphoneSelected();
|
||||||
}
|
}
|
||||||
|
|
||||||
setCameraSelected(deviceId: any) {
|
/**
|
||||||
this.cameraSelected = this.getDeviceById(this.cameras, deviceId);
|
* Set selected camera and persist to storage
|
||||||
const saveFunction = (device) => this.storageSrv.setVideoDevice(device);
|
*/
|
||||||
this.saveDeviceToStorage(this.cameraSelected, saveFunction);
|
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);
|
* Set selected microphone and persist to storage
|
||||||
const saveFunction = (device) => this.storageSrv.setAudioDevice(device);
|
*/
|
||||||
this.saveDeviceToStorage(this.microphoneSelected, saveFunction);
|
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 {
|
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 {
|
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[] {
|
getCameras(): CustomDevice[] {
|
||||||
return this.cameras;
|
return this.cameras();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available microphones
|
||||||
|
*/
|
||||||
getMicrophones(): CustomDevice[] {
|
getMicrophones(): CustomDevice[] {
|
||||||
return this.microphones;
|
return this.microphones();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Public API - Device State
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if video devices are available
|
||||||
|
*/
|
||||||
hasVideoDeviceAvailable(): boolean {
|
hasVideoDeviceAvailable(): boolean {
|
||||||
return this.videoDevicesEnabled && this.cameras.length > 0;
|
return this.hasVideoDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if audio devices are available
|
||||||
|
*/
|
||||||
hasAudioDeviceAvailable(): boolean {
|
hasAudioDeviceAvailable(): boolean {
|
||||||
return this.audioDevicesEnabled && this.microphones.length > 0;
|
return this.hasAudioDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
// ==========================================
|
||||||
this.devices = [];
|
// Public API - Permission State
|
||||||
this.cameras = [];
|
// ==========================================
|
||||||
this.microphones = [];
|
|
||||||
this.cameraSelected = undefined;
|
|
||||||
this.microphoneSelected = undefined;
|
|
||||||
this.videoDevicesEnabled = true;
|
|
||||||
this.audioDevicesEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDeviceById(devices: CustomDevice[], deviceId: string): CustomDevice | undefined {
|
/**
|
||||||
return devices.find((d) => d.device === deviceId);
|
* Check if video permission was granted
|
||||||
}
|
*/
|
||||||
|
hasVideoPermissionGranted(): boolean {
|
||||||
private saveDeviceToStorage(device: CustomDevice | undefined, saveFunction: (device: CustomDevice) => void) {
|
return this.hasVideoPermission();
|
||||||
if (device) saveFunction(device);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the local media devices (audio and video) available for the user.
|
* Check if audio permission was granted
|
||||||
*
|
|
||||||
* @returns A promise that resolves to an array of `MediaDeviceInfo` objects representing the available local devices.
|
|
||||||
*/
|
*/
|
||||||
private async getLocalDevices(): Promise<MediaDeviceInfo[]> {
|
hasAudioPermissionGranted(): boolean {
|
||||||
const strategies = this.getPermissionStrategies();
|
return this.hasAudioPermission();
|
||||||
|
|
||||||
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 [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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
|
* Clear all device data
|
||||||
* Get the list of permission strategies to try
|
|
||||||
*/
|
*/
|
||||||
protected getPermissionStrategies(): Array<{ audio: boolean; video: boolean }> {
|
clear(): void {
|
||||||
return [
|
this.cameras.set([]);
|
||||||
{ audio: true, video: true },
|
this.microphones.set([]);
|
||||||
{ audio: true, video: false },
|
this.cameraSelected.set(undefined);
|
||||||
{ audio: false, video: true }
|
this.microphoneSelected.set(undefined);
|
||||||
];
|
this.devicesCache = null;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
this.videoState.set({
|
||||||
* @internal
|
hasDevices: false,
|
||||||
* Try a specific permission strategy and return devices if successful
|
isEnabled: true,
|
||||||
*/
|
permissionGranted: false
|
||||||
protected async tryPermissionStrategy(strategy: { audio: boolean; video: boolean }): Promise<MediaDeviceInfo[] | null> {
|
});
|
||||||
const localTracks = await createLocalTracks(strategy);
|
|
||||||
localTracks.forEach((track) => track.stop());
|
|
||||||
|
|
||||||
// Permission granted
|
this.audioState.set({
|
||||||
return this.platformSrv.isFirefox() ? await this.getMediaDevicesFirefox() : await Room.getLocalDevices();
|
hasDevices: false,
|
||||||
}
|
isEnabled: true,
|
||||||
|
permissionGranted: false
|
||||||
/**
|
});
|
||||||
* @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 [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue