UNPKG

@100mslive/hms-video-store

Version:

@100mslive Core SDK which abstracts the complexities of webRTC while providing a reactive store for data management with a unidirectional data flow

599 lines (560 loc) • 22.3 kB
import { DeviceStorageManager } from './DeviceStorage'; import AnalyticsEventFactory from '../analytics/AnalyticsEventFactory'; import { ErrorFactory } from '../error/ErrorFactory'; import { HMSException } from '../error/HMSException'; import { EventBus } from '../events/EventBus'; import { DeviceMap, HMSDeviceChangeEvent, SelectedDevices } from '../interfaces'; import { getAudioDeviceCategory, HMSAudioDeviceCategory, isIOS } from '../internal'; import { HMSAudioTrackSettingsBuilder, HMSVideoTrackSettingsBuilder } from '../media/settings'; import { HMSLocalAudioTrack, HMSLocalTrack, HMSLocalVideoTrack } from '../media/tracks'; import { HMSTrackExceptionTrackType } from '../media/tracks/HMSTrackExceptionTrackType'; import { Store } from '../sdk/store'; import HMSLogger from '../utils/logger'; import { debounce, sleep } from '../utils/timer-utils'; type DeviceAndGroup = Partial<MediaTrackSettings>; interface HMSDeviceManager extends DeviceMap { outputDevice?: MediaDeviceInfo; hasWebcamPermission: boolean; hasMicrophonePermission: boolean; } export class DeviceManager implements HMSDeviceManager { audioInput: InputDeviceInfo[] = []; audioOutput: MediaDeviceInfo[] = []; videoInput: InputDeviceInfo[] = []; outputDevice?: MediaDeviceInfo; // true if user has allowed the permission // false if user has denied the permission or prompt was never shown or ignored // or if the camera/mic is not available in the device hasWebcamPermission = false; hasMicrophonePermission = false; currentSelection: SelectedDevices = { audioInput: undefined, videoInput: undefined, audioOutput: undefined, }; private readonly TAG = '[Device Manager]:'; private initialized = false; private videoInputChanged = false; private audioInputChanged = false; private earpieceSelected = false; private timer: ReturnType<typeof setTimeout> | null = null; constructor(private store: Store, private eventBus: EventBus) { const isLocalTrackEnabled = ({ enabled, track }: { enabled: boolean; track: HMSLocalTrack }) => enabled && track.source === 'regular'; this.eventBus.localVideoEnabled.waitFor(isLocalTrackEnabled).then(() => { // Do this only if length is 0 i.e. when permissions are denied if (this.videoInput.length === 0) { this.init(true); } }); this.eventBus.localAudioEnabled.waitFor(isLocalTrackEnabled).then(() => { // Do this only if length is 0 i.e. when permissions are denied if (this.audioInput.length === 0) { this.init(true); } }); this.eventBus.deviceChange.subscribe(({ type, isUserSelection, selection }) => { if (isUserSelection) { const inputType = type === 'video' ? 'videoInput' : type; const newSelection = this[inputType].find( device => this.createIdentifier(device) === this.createIdentifier(selection), ); this.eventBus.analytics.publish( AnalyticsEventFactory.deviceChange({ selection: { [inputType]: newSelection }, devices: this.getDevices(), type, isUserSelection, }), ); } }); } updateOutputDevice = async (deviceId?: string, isUserSelection?: boolean) => { const newDevice = this.audioOutput.find(device => device.deviceId === deviceId); if (newDevice) { this.outputDevice = newDevice; await this.store.updateAudioOutputDevice(newDevice); this.eventBus.analytics.publish( AnalyticsEventFactory.deviceChange({ isUserSelection, selection: { audioOutput: newDevice }, devices: this.getDevices(), type: 'audioOutput', }), ); DeviceStorageManager.updateSelection('audioOutput', { deviceId: newDevice.deviceId, groupId: newDevice.groupId }); } return newDevice; }; async init(force = false, logAnalytics = true) { if (this.initialized && !force) { return; } !this.initialized && navigator.mediaDevices.addEventListener('devicechange', this.handleDeviceChange); this.initialized = true; await this.enumerateDevices(); // do it only on initial load. if (!force) { await this.updateToActualDefaultDevice(); this.startPollingForDevices(); } await this.autoSelectAudioOutput(); this.logDevices('Init'); await this.setOutputDevice(); this.eventBus.deviceChange.publish({ devices: this.getDevices(), } as HMSDeviceChangeEvent); if (logAnalytics) { this.eventBus.analytics.publish( AnalyticsEventFactory.deviceChange({ selection: this.getCurrentSelection(), type: 'list', devices: this.getDevices(), }), ); } } getDevices(): DeviceMap { return { audioInput: this.audioInput, audioOutput: this.audioOutput, videoInput: this.videoInput, }; } cleanup() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } this.initialized = false; this.earpieceSelected = false; this.audioInput = []; this.audioOutput = []; this.videoInput = []; this.outputDevice = undefined; navigator.mediaDevices.removeEventListener('devicechange', this.handleDeviceChange); } getCurrentSelection = (): SelectedDevices => { const localPeer = this.store.getLocalPeer(); const audioDevice = this.createIdentifier(localPeer?.audioTrack?.getMediaTrackSettings()); const videoDevice = this.createIdentifier(localPeer?.videoTrack?.getMediaTrackSettings()); const audioSelection = this.audioInput.find(device => { const id = this.createIdentifier(device); return id === audioDevice; }); const videoSelection = this.videoInput.find(device => this.createIdentifier(device) === videoDevice); return { audioInput: audioSelection, videoInput: videoSelection, audioOutput: this.outputDevice, }; }; private createIdentifier(deviceInfo?: DeviceAndGroup) { if (!deviceInfo) { return ''; } return `${deviceInfo.deviceId}${deviceInfo.groupId}`; } private computeChange = (prevDevices: string[], currentDevices: MediaDeviceInfo[]) => { if (prevDevices.length !== currentDevices.length) { return true; } return currentDevices.some(device => !prevDevices.includes(this.createIdentifier(device))); }; private enumerateDevices = async () => { try { const devices = await navigator.mediaDevices.enumerateDevices(); const prevVideoInput = this.videoInput.map(this.createIdentifier); const prevAudioInput = this.audioInput.map(this.createIdentifier); this.audioInput = []; this.audioOutput = []; this.videoInput = []; devices.forEach(device => { if (device.kind === 'audioinput' && device.label) { this.hasMicrophonePermission = true; this.audioInput.push(device as InputDeviceInfo); } else if (device.kind === 'audiooutput') { this.audioOutput.push(device); } else if (device.kind === 'videoinput' && device.label) { this.hasWebcamPermission = true; this.videoInput.push(device as InputDeviceInfo); } }); this.videoInputChanged = this.computeChange(prevVideoInput, this.videoInput); this.audioInputChanged = this.computeChange(prevAudioInput, this.audioInput); DeviceStorageManager.setDevices({ videoInput: [...this.videoInput], audioInput: [...this.audioInput], audioOutput: [...this.audioOutput], }); this.logDevices('Enumerate Devices'); } catch (error) { HMSLogger.e(this.TAG, 'Failed enumerating devices', error); } }; /** * For example, if a different device, say OBS is selected as default from chrome settings, when you do getUserMedia with default, that is not the device * you get. So update to the browser settings default device * Update only when initial deviceId is not passed */ private updateToActualDefaultDevice = async () => { const localPeer = this.store.getLocalPeer(); const videoDeviceId = this.store.getConfig()?.settings?.videoDeviceId; if (!videoDeviceId && localPeer?.videoTrack) { await localPeer.videoTrack.setSettings({ deviceId: this.videoInput[0]?.deviceId }, true); } const audioDeviceId = this.store.getConfig()?.settings?.audioInputDeviceId; if (!audioDeviceId && localPeer?.audioTrack) { const getInitialDeviceId = () => { const nonIPhoneDevice = this.audioInput.find(device => !device.label.toLowerCase().includes('iphone')); return isIOS() && nonIPhoneDevice ? nonIPhoneDevice?.deviceId : this.getNewAudioInputDevice()?.deviceId; }; const deviceIdToUpdate = getInitialDeviceId(); if (deviceIdToUpdate) { await localPeer.audioTrack.setSettings({ deviceId: getInitialDeviceId() }, true); } } }; private handleDeviceChange = debounce(async () => { await this.enumerateDevices(); this.logDevices('After Device Change'); const localPeer = this.store.getLocalPeer(); await this.setOutputDevice(true); await this.handleAudioInputDeviceChange(localPeer?.audioTrack); await this.handleVideoInputDeviceChange(localPeer?.videoTrack); this.eventBus.analytics.publish( AnalyticsEventFactory.deviceChange({ selection: this.getCurrentSelection(), type: 'change', devices: this.getDevices(), }), ); }, 500).bind(this); /** * Function to get the device after device change * Chrome and Edge provide a default device from which we select the actual device * Firefox and safari give 0th device as system default * @returns {MediaDeviceInfo} */ getNewAudioInputDevice() { const manualSelection = this.getManuallySelectedAudioDevice(); if (manualSelection) { return manualSelection; } // if manually selected device is not available, reset on the track this.store.getLocalPeer()?.audioTrack?.resetManuallySelectedDeviceId(); const defaultDevice = this.audioInput.find(device => device.deviceId === 'default'); if (defaultDevice) { // Selecting a non-default device so that the deviceId comparision does not give // false positives when device is removed, because the other available device // get's the deviceId as default once this device is removed const nextDevice = this.audioInput.find(device => { return device.deviceId !== 'default' && defaultDevice.label.includes(device.label); }); return nextDevice; } return this.audioInput[0]; } /** * This method is to select the input/output from same group * same group meaning both input/output are of same device * This method might override the default coming from browser and system so as to select options from same * device type. This is required in certain cases where browser's default is not correct. * Algo: * 1. find the non default input device if selected one is default by matching device label * 2. find the corresponding output device which has the same group id or same label * 3. select the previous selected device if nothing was found * 4. select the default one if no matching device was found and previous device doesn't exist anymore * 5. select the first option if there is no default */ async setOutputDevice(deviceChange = false) { const inputDevice = this.getNewAudioInputDevice(); const prevSelection = this.createIdentifier(this.outputDevice); this.outputDevice = this.getAudioOutputDeviceMatchingInput(inputDevice); if (!this.outputDevice) { // there is no matching device, let's revert back to the prev selected device this.outputDevice = this.audioOutput.find(device => this.createIdentifier(device) === prevSelection); if (!this.outputDevice) { // prev device doesn't exist as well, select default deviceId device if available, otherwise select 0th device this.outputDevice = this.audioOutput.find(device => device.deviceId === 'default') || this.audioOutput[0]; } } await this.store.updateAudioOutputDevice(this.outputDevice); // send event only on device change and device is not same as previous if (deviceChange && prevSelection !== this.createIdentifier(this.outputDevice)) { this.eventBus.analytics.publish( AnalyticsEventFactory.deviceChange({ selection: { audioOutput: this.outputDevice }, devices: this.getDevices(), type: 'audioOutput', }), ); this.eventBus.deviceChange.publish({ selection: this.outputDevice, type: 'audioOutput', devices: this.getDevices(), } as HMSDeviceChangeEvent); } } private handleAudioInputDeviceChange = async (audioTrack?: HMSLocalAudioTrack) => { if (!audioTrack) { HMSLogger.d(this.TAG, 'No Audio track on local peer'); return; } // no need to proceed further if input has not changed if (!this.audioInputChanged) { HMSLogger.d(this.TAG, 'No Change in AudioInput Device'); return; } const newSelection = this.getNewAudioInputDevice(); if (!newSelection || !newSelection.deviceId) { this.eventBus.analytics.publish( AnalyticsEventFactory.deviceChange({ selection: { audioInput: newSelection }, error: ErrorFactory.TracksErrors.SelectedDeviceMissing(HMSTrackExceptionTrackType.AUDIO), devices: this.getDevices(), type: 'audioInput', }), ); HMSLogger.e(this.TAG, 'Audio device not found'); this.eventBus.deviceChange.publish({ error: ErrorFactory.TracksErrors.SelectedDeviceMissing(HMSTrackExceptionTrackType.AUDIO), selection: newSelection, type: 'audioInput', devices: this.getDevices(), } as HMSDeviceChangeEvent); return; } const { settings } = audioTrack; const newAudioTrackSettings = new HMSAudioTrackSettingsBuilder() .codec(settings.codec) .maxBitrate(settings.maxBitrate) .deviceId(newSelection.deviceId) .audioMode(settings.audioMode) .build(); try { await audioTrack.setSettings(newAudioTrackSettings, true); this.eventBus.deviceChange.publish({ devices: this.getDevices(), selection: newSelection, type: 'audioInput', } as HMSDeviceChangeEvent); this.logDevices('Audio Device Change Success'); } catch (error) { HMSLogger.e(this.TAG, '[Audio Device Change]', error); this.eventBus.analytics.publish( AnalyticsEventFactory.deviceChange({ selection: { audioInput: newSelection }, devices: this.getDevices(), type: 'audioInput', error: error as HMSException, }), ); this.eventBus.deviceChange.publish({ error, selection: newSelection, type: 'audioInput', devices: this.getDevices(), } as HMSDeviceChangeEvent); } }; private handleVideoInputDeviceChange = async (videoTrack?: HMSLocalVideoTrack) => { if (!videoTrack) { HMSLogger.d(this.TAG, 'No video track on local peer'); return; } // no need to proceed further if input has not changed if (!this.videoInputChanged) { HMSLogger.d(this.TAG, 'No Change in VideoInput Device'); return; } const newSelection = this.videoInput[0]; if (!newSelection || !newSelection.deviceId) { this.eventBus.analytics.publish( AnalyticsEventFactory.deviceChange({ selection: { videoInput: newSelection }, error: ErrorFactory.TracksErrors.SelectedDeviceMissing(HMSTrackExceptionTrackType.VIDEO), devices: this.getDevices(), type: 'video', }), ); HMSLogger.e(this.TAG, 'Video device not found'); this.eventBus.deviceChange.publish({ error: ErrorFactory.TracksErrors.SelectedDeviceMissing(HMSTrackExceptionTrackType.VIDEO), selection: newSelection, type: 'video', devices: this.getDevices(), } as HMSDeviceChangeEvent); return; } const { settings } = videoTrack; const newVideoTrackSettings = new HMSVideoTrackSettingsBuilder() .codec(settings.codec) .maxBitrate(settings.maxBitrate) .maxFramerate(settings.maxFramerate) .setWidth(settings.width) .setHeight(settings.height) .deviceId(newSelection.deviceId) .build(); try { await videoTrack.setSettings(newVideoTrackSettings, true); // On replace track, enabled will be true. Need to be set to previous state // videoTrack.setEnabled(enabled); // TODO: remove this once verified on qa. this.eventBus.deviceChange.publish({ devices: this.getDevices(), selection: newSelection, type: 'video', } as HMSDeviceChangeEvent); this.logDevices('Video Device Change Success'); } catch (error) { HMSLogger.e(this.TAG, '[Video Device Change]', error); this.eventBus.analytics.publish( AnalyticsEventFactory.deviceChange({ selection: { videoInput: newSelection }, devices: this.getDevices(), type: 'video', error: error as HMSException, }), ); this.eventBus.deviceChange.publish({ error: error as HMSException, type: 'video', selection: newSelection, devices: this.getDevices(), } as HMSDeviceChangeEvent); } }; getManuallySelectedAudioDevice() { const localPeer = this.store.getLocalPeer(); const audioTrack = localPeer?.audioTrack; return this.audioInput.find(device => device.deviceId === audioTrack?.getManuallySelectedDeviceId()); } // specifically used for mweb categorizeAudioInputDevices() { let bluetoothDevice: InputDeviceInfo | null = null; let speakerPhone: InputDeviceInfo | null = null; let wired: InputDeviceInfo | null = null; let earpiece: InputDeviceInfo | null = null; for (const device of this.audioInput) { const deviceCategory = getAudioDeviceCategory(device.label); if (deviceCategory === HMSAudioDeviceCategory.SPEAKERPHONE) { speakerPhone = device; } else if (deviceCategory === HMSAudioDeviceCategory.WIRED) { wired = device; } else if (deviceCategory === HMSAudioDeviceCategory.BLUETOOTH) { bluetoothDevice = device; } else if (deviceCategory === HMSAudioDeviceCategory.EARPIECE) { earpiece = device; } } return { bluetoothDevice, speakerPhone, wired, earpiece }; } private startPollingForDevices = async () => { const { earpiece } = this.categorizeAudioInputDevices(); // device change supported, no polling needed if (!earpiece) { return; } this.timer = setTimeout(() => { (async () => { await this.enumerateDevices(); await this.autoSelectAudioOutput(); this.startPollingForDevices(); })(); }, 5000); }; /** * Mweb is not able to play via call channel by default, this is to switch from media channel to call channel */ // eslint-disable-next-line complexity public autoSelectAudioOutput = async (delay?: number) => { const { bluetoothDevice, earpiece, speakerPhone, wired } = this.categorizeAudioInputDevices(); const localAudioTrack = this.store.getLocalPeer()?.audioTrack; if (!localAudioTrack || !earpiece) { return; } const manualSelection = this.getManuallySelectedAudioDevice(); const externalDeviceID = manualSelection?.deviceId || bluetoothDevice?.deviceId || wired?.deviceId || speakerPhone?.deviceId; // already selected appropriate device if (!delay && localAudioTrack.settings.deviceId === externalDeviceID && this.earpieceSelected) { return; } try { if (!this.earpieceSelected || delay) { if (bluetoothDevice?.deviceId === externalDeviceID) { this.earpieceSelected = true; } else { HMSLogger.d(this.TAG, 'selecting earpiece'); await localAudioTrack.setSettings({ deviceId: earpiece?.deviceId }, true); if (delay) { await sleep(delay); } this.earpieceSelected = true; } } await localAudioTrack.setSettings( { deviceId: externalDeviceID, }, true, ); const groupId = this.audioInput.find(input => input.deviceId === externalDeviceID)?.groupId; this.eventBus.deviceChange.publish({ isUserSelection: false, type: 'audioInput', selection: { deviceId: externalDeviceID, groupId: groupId, }, devices: this.getDevices(), internal: true, }); } catch (error) { this.eventBus.error.publish(error as HMSException); } }; // eslint-disable-next-line complexity private getAudioOutputDeviceMatchingInput(inputDevice?: MediaDeviceInfo) { const blacklist = this.store.getConfig()?.settings?.speakerAutoSelectionBlacklist || []; if (blacklist === 'all' || !inputDevice) { return; } const inputLabel = inputDevice.label.toLowerCase() || ''; if (blacklist.some(label => inputLabel.includes(label.toLowerCase()))) { return; } const matchingLabel = this.audioOutput.find( device => inputDevice.deviceId !== 'default' && device.label === inputDevice.label, ); if (matchingLabel) { return matchingLabel; } const matchingGroupId = this.audioOutput.find( device => device.groupId === inputDevice.groupId && device.deviceId !== 'default', ); // Select the device with matching group only when it is the default device // if a earphone without mic is connected, the above would pick system speakers instead of the earphone if (matchingGroupId) { return matchingGroupId; } return; } private logDevices(label = '') { HMSLogger.d( this.TAG, label, JSON.stringify( { videoInput: [...this.videoInput], audioInput: [...this.audioInput], audioOutput: [...this.audioOutput], selected: this.getCurrentSelection(), }, null, 4, ), ); } }