From 8a29cee3bacb3ad6355b4987c79b4c1a4a6ac142 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Sat, 4 Nov 2023 16:38:22 +0100 Subject: [PATCH] fix errors --- .../src/devices/InputMediaDeviceManager.ts | 88 +++++++++++++------ packages/client/src/devices/SpeakerManager.ts | 22 ++--- .../devices/__tests__/CameraManager.test.ts | 8 +- .../__tests__/InputMediaDeviceManager.test.ts | 66 +++++++++++--- .../__tests__/MicrophoneManager.test.ts | 8 +- .../__tests__/ScreenShareManager.test.ts | 3 +- .../devices/__tests__/SpeakerManager.test.ts | 11 +-- .../client/src/devices/__tests__/mocks.ts | 15 ++-- packages/client/src/devices/devices.ts | 49 ++++++++--- 9 files changed, 191 insertions(+), 79 deletions(-) diff --git a/packages/client/src/devices/InputMediaDeviceManager.ts b/packages/client/src/devices/InputMediaDeviceManager.ts index 36aff25e5a..c86c301304 100644 --- a/packages/client/src/devices/InputMediaDeviceManager.ts +++ b/packages/client/src/devices/InputMediaDeviceManager.ts @@ -1,4 +1,4 @@ -import { Observable, Subscription, take } from 'rxjs'; +import { Observable, Subscription, combineLatest, pairwise, take } from 'rxjs'; import { Call } from '../Call'; import { CallingState } from '../store'; import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState'; @@ -6,7 +6,7 @@ import { isReactNative } from '../helpers/platforms'; import { Logger } from '../coordinator/connection/types'; import { getLogger } from '../logger'; import { TrackType } from '../gen/video/sfu/models/models'; -import { watchForDisconnectedDevice } from './devices'; +import { deviceIds$ } from './devices'; export abstract class InputMediaDeviceManager< T extends InputMediaDeviceManagerState, @@ -30,21 +30,11 @@ export abstract class InputMediaDeviceManager< ) { this.logger = getLogger([`${TrackType[trackType].toLowerCase()} manager`]); if ( - typeof navigator !== 'undefined' && - typeof navigator.mediaDevices !== 'undefined' && + deviceIds$ && !isReactNative() && (this.trackType === TrackType.AUDIO || this.trackType === TrackType.VIDEO) ) { - this.subscriptions.push( - watchForDisconnectedDevice(this.state.selectedDevice$).subscribe( - async (isDisconnected) => { - if (isDisconnected) { - await this.disable(); - this.select(undefined); - } - }, - ), - ); + this.handleDisconnectedOrReplacedDevices(); } } @@ -251,20 +241,66 @@ export abstract class InputMediaDeviceManager< return; } await this.disable(); - if (this.state.selectedDevice) { - this.listDevices() - .pipe(take(1)) - .subscribe(async (devices) => { - if ( - devices && - devices.find((d) => d.deviceId === this.state.selectedDevice) - ) { - await this.enable(); - } - }); - } }); }); } } + + private get mediaDeviceKind() { + if (this.trackType === TrackType.AUDIO) { + return 'audioinput'; + } + if (this.trackType === TrackType.VIDEO) { + return 'videoinput'; + } + } + + private handleDisconnectedOrReplacedDevices() { + this.subscriptions.push( + combineLatest([ + deviceIds$!.pipe(pairwise()), + this.state.selectedDevice$, + ]).subscribe(async ([[prevDevices, currentDevices], deviceId]) => { + if (!deviceId) { + return; + } + if (this.enablePromise) { + await this.enablePromise; + } + if (this.disablePromise) { + await this.disablePromise; + } + + let isDeviceDisconnected = false; + let isDeviceReplaced = false; + const currentDevice = this.findDeviceInList(currentDevices, deviceId); + const prevDevice = this.findDeviceInList(prevDevices, deviceId); + if (!currentDevice && prevDevice) { + isDeviceDisconnected = true; + } else if ( + currentDevice && + prevDevice && + currentDevice.deviceId === prevDevice.deviceId && + currentDevice.groupId !== prevDevice.groupId + ) { + isDeviceReplaced = true; + } + + if (isDeviceDisconnected) { + await this.disable(); + this.select(undefined); + } + if (isDeviceReplaced && this.state.status === 'enabled') { + await this.disable(); + await this.enable(); + } + }), + ); + } + + private findDeviceInList(devices: MediaDeviceInfo[], deviceId: string) { + return devices.find( + (d) => d.deviceId === deviceId && d.kind == this.mediaDeviceKind, + ); + } } diff --git a/packages/client/src/devices/SpeakerManager.ts b/packages/client/src/devices/SpeakerManager.ts index bce79009d4..8feb8a128a 100644 --- a/packages/client/src/devices/SpeakerManager.ts +++ b/packages/client/src/devices/SpeakerManager.ts @@ -1,22 +1,24 @@ -import { Subscription } from 'rxjs'; +import { Subscription, combineLatest } from 'rxjs'; import { isReactNative } from '../helpers/platforms'; import { SpeakerState } from './SpeakerState'; -import { getAudioOutputDevices, watchForDisconnectedDevice } from './devices'; +import { deviceIds$, getAudioOutputDevices } from './devices'; export class SpeakerManager { public readonly state = new SpeakerState(); private subscriptions: Subscription[] = []; constructor() { - if ( - typeof navigator !== 'undefined' && - typeof navigator.mediaDevices !== 'undefined' && - !isReactNative() - ) { + if (deviceIds$ && !isReactNative()) { this.subscriptions.push( - watchForDisconnectedDevice(this.state.selectedDevice$).subscribe( - async (isDisconnected) => { - if (isDisconnected) { + combineLatest([deviceIds$!, this.state.selectedDevice$]).subscribe( + ([devices, deviceId]) => { + if (!deviceId) { + return; + } + const device = devices.find( + (d) => d.deviceId === deviceId && d.kind === 'audiooutput', + ); + if (!device) { this.select(''); } }, diff --git a/packages/client/src/devices/__tests__/CameraManager.test.ts b/packages/client/src/devices/__tests__/CameraManager.test.ts index d435666702..5e18ee93a7 100644 --- a/packages/client/src/devices/__tests__/CameraManager.test.ts +++ b/packages/client/src/devices/__tests__/CameraManager.test.ts @@ -3,7 +3,12 @@ import { StreamClient } from '../../coordinator/connection/client'; import { CallingState, StreamVideoWriteableStateStore } from '../../store'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { mockCall, mockVideoDevices, mockVideoStream } from './mocks'; +import { + mockCall, + mockDeviceIds$, + mockVideoDevices, + mockVideoStream, +} from './mocks'; import { getVideoStream } from '../devices'; import { TrackType } from '../../gen/video/sfu/models/models'; import { CameraManager } from '../CameraManager'; @@ -17,6 +22,7 @@ vi.mock('../devices.ts', () => { return of(mockVideoDevices); }), getVideoStream: vi.fn(() => Promise.resolve(mockVideoStream())), + deviceIds$: mockDeviceIds$(), }; }); diff --git a/packages/client/src/devices/__tests__/InputMediaDeviceManager.test.ts b/packages/client/src/devices/__tests__/InputMediaDeviceManager.test.ts index df7cce31f5..d04aedd360 100644 --- a/packages/client/src/devices/__tests__/InputMediaDeviceManager.test.ts +++ b/packages/client/src/devices/__tests__/InputMediaDeviceManager.test.ts @@ -5,9 +5,9 @@ import { CallingState, StreamVideoWriteableStateStore } from '../../store'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MockTrack, - disconnectDevice, + emitDeviceIds, mockCall, - mockDeviceDisconnectWatcher, + mockDeviceIds$, mockVideoDevices, mockVideoStream, } from './mocks'; @@ -26,7 +26,7 @@ vi.mock('../../Call.ts', () => { vi.mock('../devices.ts', () => { console.log('MOCKING devices API'); return { - watchForDisconnectedDevice: mockDeviceDisconnectWatcher(), + deviceIds$: mockDeviceIds$(), }; }); @@ -250,30 +250,70 @@ describe('InputMediaDeviceManager.test', () => { expect(manager.enable).not.toHaveBeenCalled(); }); - it('should restart track if a new default device is connected', async () => { + it('should restart track if the default device is replaced and status is enabled', async () => { + vi.useFakeTimers(); + emitDeviceIds(mockVideoDevices); + await manager.enable(); + const device = mockVideoDevices[0]; + await manager.select(device.deviceId); vi.spyOn(manager, 'enable'); - await ( - (manager.state.mediaStream?.getTracks()[0] as MockTrack).eventHandlers[ - 'ended' - ] as Function - )(); + vi.spyOn(manager, 'disable'); - expect(manager.enable).toHaveBeenCalled(); + emitDeviceIds([ + { ...device, groupId: device.groupId + 'new' }, + ...mockVideoDevices.slice(1), + ]); + + await vi.runAllTimersAsync(); + + expect(manager.enable).toHaveBeenCalledOnce(); + expect(manager.disable).toHaveBeenCalledOnce(); + expect(manager.state.status).toBe('enabled'); + + vi.useRealTimers(); + }); + + it('should do nothing if default device is replaced and status is disabled', async () => { + vi.useFakeTimers(); + emitDeviceIds(mockVideoDevices); + + const device = mockVideoDevices[0]; + await manager.select(device.deviceId); + await manager.disable(); + + vi.spyOn(manager, 'enable'); + vi.spyOn(manager, 'disable'); + + emitDeviceIds([ + { ...device, groupId: device.groupId + 'new' }, + ...mockVideoDevices.slice(1), + ]); + + await vi.runAllTimersAsync(); + + expect(manager.enable).not.toHaveBeenCalledOnce(); + expect(manager.disable).not.toHaveBeenCalledOnce(); + expect(manager.state.status).toBe('disabled'); + expect(manager.state.selectedDevice).toBe(device.deviceId); + + vi.useRealTimers(); }); it('should disable stream and deselect device if selected device is disconnected', async () => { vi.useFakeTimers(); + emitDeviceIds(mockVideoDevices); await manager.enable(); - const deviceId = mockVideoDevices[1].deviceId; - await manager.select(deviceId); + const device = mockVideoDevices[0]; + await manager.select(device.deviceId); - disconnectDevice(); + emitDeviceIds(mockVideoDevices.slice(1)); await vi.runAllTimersAsync(); + expect(manager.state.selectedDevice).toBe(undefined); expect(manager.state.status).toBe('disabled'); vi.useRealTimers(); diff --git a/packages/client/src/devices/__tests__/MicrophoneManager.test.ts b/packages/client/src/devices/__tests__/MicrophoneManager.test.ts index 238378942e..3637dd7982 100644 --- a/packages/client/src/devices/__tests__/MicrophoneManager.test.ts +++ b/packages/client/src/devices/__tests__/MicrophoneManager.test.ts @@ -3,7 +3,12 @@ import { StreamClient } from '../../coordinator/connection/client'; import { CallingState, StreamVideoWriteableStateStore } from '../../store'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { mockAudioDevices, mockAudioStream, mockCall } from './mocks'; +import { + mockAudioDevices, + mockAudioStream, + mockCall, + mockDeviceIds$, +} from './mocks'; import { getAudioStream } from '../devices'; import { TrackType } from '../../gen/video/sfu/models/models'; import { MicrophoneManager } from '../MicrophoneManager'; @@ -22,6 +27,7 @@ vi.mock('../devices.ts', () => { return of(mockAudioDevices); }), getAudioStream: vi.fn(() => Promise.resolve(mockAudioStream())), + deviceIds$: mockDeviceIds$(), }; }); diff --git a/packages/client/src/devices/__tests__/ScreenShareManager.test.ts b/packages/client/src/devices/__tests__/ScreenShareManager.test.ts index fa771f1159..37fb96ebd2 100644 --- a/packages/client/src/devices/__tests__/ScreenShareManager.test.ts +++ b/packages/client/src/devices/__tests__/ScreenShareManager.test.ts @@ -4,7 +4,7 @@ import { Call } from '../../Call'; import { StreamClient } from '../../coordinator/connection/client'; import { CallingState, StreamVideoWriteableStateStore } from '../../store'; import * as RxUtils from '../../store/rxUtils'; -import { mockCall, mockScreenShareStream } from './mocks'; +import { mockCall, mockDeviceIds$, mockScreenShareStream } from './mocks'; import { getScreenShareStream } from '../devices'; import { TrackType } from '../../gen/video/sfu/models/models'; @@ -14,6 +14,7 @@ vi.mock('../devices.ts', () => { disposeOfMediaStream: vi.fn(), getScreenShareStream: vi.fn(() => Promise.resolve(mockScreenShareStream())), checkIfAudioOutputChangeSupported: vi.fn(() => Promise.resolve(true)), + deviceIds$: () => mockDeviceIds$(), }; }); diff --git a/packages/client/src/devices/__tests__/SpeakerManager.test.ts b/packages/client/src/devices/__tests__/SpeakerManager.test.ts index d04967150e..7a2cf45c39 100644 --- a/packages/client/src/devices/__tests__/SpeakerManager.test.ts +++ b/packages/client/src/devices/__tests__/SpeakerManager.test.ts @@ -1,9 +1,5 @@ import { afterEach, beforeEach, describe, vi, it, expect } from 'vitest'; -import { - disconnectDevice, - mockAudioDevices, - mockDeviceDisconnectWatcher, -} from './mocks'; +import { emitDeviceIds, mockAudioDevices, mockDeviceIds$ } from './mocks'; import { of } from 'rxjs'; import { SpeakerManager } from '../SpeakerManager'; import { checkIfAudioOutputChangeSupported } from '../devices'; @@ -13,7 +9,7 @@ vi.mock('../devices.ts', () => { return { getAudioOutputDevices: vi.fn(() => of(mockAudioDevices)), checkIfAudioOutputChangeSupported: vi.fn(() => true), - watchForDisconnectedDevice: mockDeviceDisconnectWatcher(), + deviceIds$: mockDeviceIds$(), }; }); @@ -65,10 +61,11 @@ describe('SpeakerManager.test', () => { }); it('should disable device if selected device is disconnected', () => { + emitDeviceIds(mockAudioDevices); const deviceId = mockAudioDevices[1].deviceId; manager.select(deviceId); - disconnectDevice(); + emitDeviceIds(mockAudioDevices.slice(2)); expect(manager.state.selectedDevice).toBe(''); }); diff --git a/packages/client/src/devices/__tests__/mocks.ts b/packages/client/src/devices/__tests__/mocks.ts index 2f8ee47f5f..fc3af6423a 100644 --- a/packages/client/src/devices/__tests__/mocks.ts +++ b/packages/client/src/devices/__tests__/mocks.ts @@ -2,7 +2,7 @@ import { vi } from 'vitest'; import { CallingState, CallState } from '../../store'; import { OwnCapability } from '../../gen/coordinator'; import { Call } from '../../Call'; -import { Subject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; export const mockVideoDevices = [ { @@ -184,15 +184,16 @@ export const mockScreenShareStream = (includeAudio: boolean = true) => { } as any as MediaStream; }; -const deviceDisconnectSubject = new Subject(); -export const mockDeviceDisconnectWatcher = () => { +let deviceIds: Subject; +export const mockDeviceIds$ = () => { global.navigator = { - // @ts-expect-error + //@ts-expect-error mediaDevices: {}, }; - return () => deviceDisconnectSubject; + deviceIds = new Subject(); + return deviceIds; }; -export const disconnectDevice = () => { - deviceDisconnectSubject.next(true); +export const emitDeviceIds = (values: MediaDeviceInfo[]) => { + deviceIds.next(values); }; diff --git a/packages/client/src/devices/devices.ts b/packages/client/src/devices/devices.ts index e26a8baebe..cd09931236 100644 --- a/packages/client/src/devices/devices.ts +++ b/packages/client/src/devices/devices.ts @@ -226,17 +226,34 @@ export const getScreenShareStream = async ( } }; -const getDeviceIds = memoizedObservable(() => - merge( - from(navigator.mediaDevices.enumerateDevices()), - getDeviceChangeObserver(), - ).pipe(shareReplay(1)), -); +export const deviceIds$ = + typeof navigator !== 'undefined' && + typeof navigator.mediaDevices !== 'undefined' + ? memoizedObservable(() => + merge( + from(navigator.mediaDevices.enumerateDevices()), + getDeviceChangeObserver(), + ).pipe(shareReplay(1)), + )() + : undefined; export const watchForDisconnectedDevice = ( + kind: MediaDeviceKind, deviceId$: Observable, ) => { - return combineLatest([getDeviceIds(), deviceId$]).pipe( + let devices$; + switch (kind) { + case 'audioinput': + devices$ = getAudioDevices(); + break; + case 'videoinput': + devices$ = getVideoDevices(); + break; + case 'audiooutput': + devices$ = getAudioOutputDevices(); + break; + } + return combineLatest([devices$, deviceId$]).pipe( filter( ([devices, deviceId]) => !!deviceId && !devices.find((d) => d.deviceId === deviceId), @@ -252,12 +269,12 @@ export const watchForDisconnectedDevice = ( * @param deviceId$ an Observable that specifies which device to watch for * @returns * - * @deprecated use `watchForDisconnectedDevice` + * @deprecated use the [new device API](https://getstream.io/video/docs/javascript/guides/camera-and-microphone/) */ export const watchForDisconnectedAudioDevice = ( deviceId$: Observable, ) => { - return watchForDisconnectedDevice(deviceId$); + return watchForDisconnectedDevice('audioinput', deviceId$); }; /** @@ -267,12 +284,12 @@ export const watchForDisconnectedAudioDevice = ( * @param deviceId$ an Observable that specifies which device to watch for * @returns * - * @deprecated use `watchForDisconnectedDevice` + * @deprecated use the [new device API](https://getstream.io/video/docs/javascript/guides/camera-and-microphone/) */ export const watchForDisconnectedVideoDevice = ( deviceId$: Observable, ) => { - return watchForDisconnectedDevice(deviceId$); + return watchForDisconnectedDevice('videoinput', deviceId$); }; /** @@ -282,12 +299,12 @@ export const watchForDisconnectedVideoDevice = ( * @param deviceId$ an Observable that specifies which device to watch for * @returns * - * @deprecated use `watchForDisconnectedDevice` + * @deprecated use the [new device API](https://getstream.io/video/docs/javascript/guides/camera-and-microphone/) */ export const watchForDisconnectedAudioOutputDevice = ( deviceId$: Observable, ) => { - return watchForDisconnectedDevice(deviceId$); + return watchForDisconnectedDevice('audiooutput', deviceId$); }; const watchForAddedDefaultDevice = (kind: MediaDeviceKind) => { @@ -327,6 +344,8 @@ const watchForAddedDefaultDevice = (kind: MediaDeviceKind) => { /** * Notifies the subscriber about newly added default audio input device. * @returns Observable + * + * @deprecated use the [new device API](https://getstream.io/video/docs/javascript/guides/camera-and-microphone/) */ export const watchForAddedDefaultAudioDevice = () => watchForAddedDefaultDevice('audioinput'); @@ -334,6 +353,8 @@ export const watchForAddedDefaultAudioDevice = () => /** * Notifies the subscriber about newly added default audio output device. * @returns Observable + * + * @deprecated use the [new device API](https://getstream.io/video/docs/javascript/guides/camera-and-microphone/) */ export const watchForAddedDefaultAudioOutputDevice = () => watchForAddedDefaultDevice('audiooutput'); @@ -341,6 +362,8 @@ export const watchForAddedDefaultAudioOutputDevice = () => /** * Notifies the subscriber about newly added default video input device. * @returns Observable + * + * @deprecated use the [new device API](https://getstream.io/video/docs/javascript/guides/camera-and-microphone/) */ export const watchForAddedDefaultVideoDevice = () => watchForAddedDefaultDevice('videoinput');