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 { 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue