From adf048e44b74303394e732ad840c38adb3d87298 Mon Sep 17 00:00:00 2001 From: Bryce Tham Date: Fri, 28 Jul 2023 15:19:31 -0400 Subject: [PATCH] feat: add generics to device management (#61) Co-authored-by: Bryce Tham --- src/device/device-management.spec.ts | 111 +++++++++++++++++++++--- src/device/device-management.ts | 123 +++++++++++++++++++++------ 2 files changed, 198 insertions(+), 36 deletions(-) diff --git a/src/device/device-management.spec.ts b/src/device/device-management.spec.ts index 0eb42b9..1d8d72e 100644 --- a/src/device/device-management.spec.ts +++ b/src/device/device-management.spec.ts @@ -7,6 +7,7 @@ import { createBrowserMock } from '../mocks/create-browser-mock'; import MediaStreamStub from '../mocks/media-stream-stub'; import { createMockedStream, createMockedStreamWithAudio } from '../util/test-utils'; import { + createCameraAndMicrophoneStreams, createCameraStream, createDisplayStream, createDisplayStreamWithAudio, @@ -29,7 +30,7 @@ describe('Device Management', () => { it('should call getUserMedia', async () => { expect.assertions(1); - await createMicrophoneStream({ deviceId: 'test-device-id' }); + await createMicrophoneStream(LocalMicrophoneStream, { deviceId: 'test-device-id' }); expect(media.getUserMedia).toHaveBeenCalledWith({ audio: { deviceId: 'test-device-id', @@ -37,10 +38,37 @@ describe('Device Management', () => { }); }); + it('should call getUserMedia with constraints', async () => { + expect.assertions(1); + + await createMicrophoneStream(LocalMicrophoneStream, { + deviceId: 'test-device-id', + autoGainControl: false, + channelCount: 2, + echoCancellation: false, + noiseSuppression: false, + sampleRate: 48000, + sampleSize: 16, + suppressLocalAudioPlayback: false, + }); + expect(media.getUserMedia).toHaveBeenCalledWith({ + audio: { + deviceId: 'test-device-id', + autoGainControl: false, + channelCount: 2, + echoCancellation: false, + noiseSuppression: false, + sampleRate: 48000, + sampleSize: 16, + suppressLocalAudioPlayback: false, + }, + }); + }); + it('should return a LocalMicrophoneStream instance', async () => { expect.assertions(1); - const localMicrophoneStream = await createMicrophoneStream({ + const localMicrophoneStream = await createMicrophoneStream(LocalMicrophoneStream, { deviceId: 'test-device-id', }); expect(localMicrophoneStream).toBeInstanceOf(LocalMicrophoneStream); @@ -55,7 +83,7 @@ describe('Device Management', () => { it('should call getUserMedia', async () => { expect.assertions(1); - await createCameraStream({ deviceId: 'test-device-id' }); + await createCameraStream(LocalCameraStream, { deviceId: 'test-device-id' }); expect(media.getUserMedia).toHaveBeenCalledWith({ video: { deviceId: 'test-device-id', @@ -66,7 +94,7 @@ describe('Device Management', () => { it('should call getUserMedia with constraints', async () => { expect.assertions(1); - await createCameraStream({ + await createCameraStream(LocalCameraStream, { deviceId: 'test-device-id', aspectRatio: 1.777, width: 1920, @@ -89,13 +117,62 @@ describe('Device Management', () => { it('should return a LocalCameraStream instance', async () => { expect.assertions(1); - const localCameraStream = await createCameraStream({ + const localCameraStream = await createCameraStream(LocalCameraStream, { deviceId: 'test-device-id', }); expect(localCameraStream).toBeInstanceOf(LocalCameraStream); }); }); + describe('createCameraAndMicrophoneStreams', () => { + jest + .spyOn(media, 'getUserMedia') + .mockReturnValue(Promise.resolve(mockStream as unknown as MediaStream)); + + it('should call getUserMedia', async () => { + expect.assertions(1); + + await createCameraAndMicrophoneStreams(LocalCameraStream, LocalMicrophoneStream, { + video: { deviceId: 'test-device-id' }, + audio: { deviceId: 'test-device-id' }, + }); + expect(media.getUserMedia).toHaveBeenCalledWith({ + video: { + deviceId: 'test-device-id', + }, + audio: { + deviceId: 'test-device-id', + }, + }); + }); + + it('should return a LocalCameraStream and a LocalMicrophoneStream instance', async () => { + expect.assertions(2); + + const [localCameraStream, localMicrophoneStream] = await createCameraAndMicrophoneStreams( + LocalCameraStream, + LocalMicrophoneStream, + { + video: { deviceId: 'test-device-id' }, + audio: { deviceId: 'test-device-id' }, + } + ); + expect(localCameraStream).toBeInstanceOf(LocalCameraStream); + expect(localMicrophoneStream).toBeInstanceOf(LocalMicrophoneStream); + }); + + it('should return a LocalCameraStream and a LocalMicrophoneStream instance without constraints', async () => { + expect.assertions(2); + + const [localCameraStream, localMicrophoneStream] = await createCameraAndMicrophoneStreams( + LocalCameraStream, + LocalMicrophoneStream + ); + expect(localCameraStream).toBeInstanceOf(LocalCameraStream); + expect(localMicrophoneStream).toBeInstanceOf(LocalMicrophoneStream); + }); + }); + describe('createDisplayStream', () => { jest .spyOn(media, 'getDisplayMedia') @@ -104,14 +181,14 @@ describe('Device Management', () => { it('should call getDisplayMedia', async () => { expect.assertions(1); - await createDisplayStream(); + await createDisplayStream(LocalDisplayStream); expect(media.getDisplayMedia).toHaveBeenCalledWith({ video: true }); }); it('should return a LocalDisplayStream instance', async () => { expect.assertions(2); - const localDisplayStream = await createDisplayStream(); + const localDisplayStream = await createDisplayStream(LocalDisplayStream); expect(localDisplayStream).toBeInstanceOf(LocalDisplayStream); expect(localDisplayStream.contentHint).toBeUndefined(); }); @@ -119,7 +196,7 @@ describe('Device Management', () => { it('should preserve the content hint', async () => { expect.assertions(1); - const localDisplayStream = await createDisplayStream('motion'); + const localDisplayStream = await createDisplayStream(LocalDisplayStream, 'motion'); expect(localDisplayStream.contentHint).toBe('motion'); }); }); @@ -137,14 +214,17 @@ describe('Device Management', () => { it('should call getDisplayMedia with audio', async () => { expect.assertions(1); - await createDisplayStreamWithAudio(); + await createDisplayStreamWithAudio(LocalDisplayStream, LocalSystemAudioStream); expect(media.getDisplayMedia).toHaveBeenCalledWith({ video: true, audio: true }); }); it('should return a LocalDisplayStream instance and null if no audio track exists', async () => { expect.assertions(2); - const [localDisplayStream, localSystemAudioStream] = await createDisplayStreamWithAudio(); + const [localDisplayStream, localSystemAudioStream] = await createDisplayStreamWithAudio( + LocalDisplayStream, + LocalSystemAudioStream + ); expect(localDisplayStream).toBeInstanceOf(LocalDisplayStream); expect(localSystemAudioStream).toBeNull(); }); @@ -157,7 +237,10 @@ describe('Device Management', () => { .spyOn(media, 'getDisplayMedia') .mockReturnValueOnce(Promise.resolve(mockStreamWithAudio as unknown as MediaStream)); - const [localDisplayStream, localSystemAudioStream] = await createDisplayStreamWithAudio(); + const [localDisplayStream, localSystemAudioStream] = await createDisplayStreamWithAudio( + LocalDisplayStream, + LocalSystemAudioStream + ); expect(localDisplayStream).toBeInstanceOf(LocalDisplayStream); expect(localSystemAudioStream).toBeInstanceOf(LocalSystemAudioStream); }); @@ -165,7 +248,11 @@ describe('Device Management', () => { it('should preserve the content hint', async () => { expect.assertions(1); - const [localDisplayStream] = await createDisplayStreamWithAudio('motion'); + const [localDisplayStream] = await createDisplayStreamWithAudio( + LocalDisplayStream, + LocalSystemAudioStream, + 'motion' + ); expect(localDisplayStream.contentHint).toBe('motion'); }); }); diff --git a/src/device/device-management.ts b/src/device/device-management.ts index 60315fe..0e3d982 100644 --- a/src/device/device-management.ts +++ b/src/device/device-management.ts @@ -7,10 +7,12 @@ import { VideoContentHint } from '../media/local-video-stream'; export enum ErrorTypes { DEVICE_PERMISSION_DENIED = 'DEVICE_PERMISSION_DENIED', - CREATE_CAMERA_STREAM_FAILED = 'CREATE_CAMERA_STREAM_FAILED', - CREATE_MICROPHONE_STREAM_FAILED = 'CREATE_MICROPHONE_STREAM_FAILED', + CREATE_STREAM_FAILED = 'CREATE_CAMERA_STREAM', } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Constructor = new (...args: any[]) => T; + /** * Represents a WCME error, which contains error type and error message. */ @@ -54,56 +56,109 @@ export type VideoDeviceConstraints = Pick< * 1. Previous captured video stream from the same device is not stopped. * 2. Previous createCameraStream() call for the same device is in progress. * + * @param constructor - Constructor for the local camera stream. * @param constraints - Video device constraints. * @returns A LocalCameraStream object or an error. */ -export async function createCameraStream( +export async function createCameraStream( + constructor: Constructor, constraints?: VideoDeviceConstraints -): Promise { +): Promise { let stream: MediaStream; try { stream = await media.getUserMedia({ video: { ...constraints } }); } catch (error) { throw new WcmeError( - ErrorTypes.CREATE_CAMERA_STREAM_FAILED, - `Failed to create camera stream ${error}` + ErrorTypes.CREATE_STREAM_FAILED, + `Failed to create camera stream: ${error}` ); } - return new LocalCameraStream(stream); + return new constructor(stream); } /** * Creates a LocalMicrophoneStream with the given constraints. * + * @param constructor - Constructor for the local microphone stream. * @param constraints - Audio device constraints. * @returns A LocalMicrophoneStream object or an error. */ -export async function createMicrophoneStream( +export async function createMicrophoneStream( + constructor: Constructor, constraints?: AudioDeviceConstraints -): Promise { +): Promise { let stream: MediaStream; try { stream = await media.getUserMedia({ audio: { ...constraints } }); } catch (error) { throw new WcmeError( - ErrorTypes.CREATE_MICROPHONE_STREAM_FAILED, - `Failed to create microphone stream ${error}` + ErrorTypes.CREATE_STREAM_FAILED, + `Failed to create microphone stream: ${error}` + ); + } + return new constructor(stream); +} + +/** + * Creates a LocalCameraStream and a LocalMicrophoneStream with the given constraints. + * + * @param cameraStreamConstructor - Constructor for the local camera stream. + * @param microphoneStreamConstructor - Constructor for the local microphone stream. + * @param constraints - Object containing video and audio device constraints. + * @param constraints.video - Video device constraints. + * @param constraints.audio - Audio device constraints. + * @returns A Promise that resolves to a LocalCameraStream and a LocalMicrophoneStream or an error. + */ +export async function createCameraAndMicrophoneStreams< + T extends LocalCameraStream, + U extends LocalMicrophoneStream +>( + cameraStreamConstructor: Constructor, + microphoneStreamConstructor: Constructor, + constraints?: { video?: VideoDeviceConstraints; audio?: AudioDeviceConstraints } +): Promise<[T, U]> { + let stream; + try { + stream = await media.getUserMedia({ + video: { ...constraints?.video }, + audio: { ...constraints?.audio }, + }); + } catch (error) { + throw new WcmeError( + ErrorTypes.CREATE_STREAM_FAILED, + `Failed to create camera and microphone streams: ${error}` ); } - return new LocalMicrophoneStream(stream); + // eslint-disable-next-line new-cap + const localCameraStream = new cameraStreamConstructor(new MediaStream(stream.getVideoTracks())); + // eslint-disable-next-line new-cap + const localMicrophoneStream = new microphoneStreamConstructor( + new MediaStream(stream.getAudioTracks()) + ); + return [localCameraStream, localMicrophoneStream]; } /** * Creates a LocalDisplayStream with the given parameters. * + * @param constructor - Constructor for the local display stream. * @param videoContentHint - An optional parameter to give a hint for the content of the stream. - * @returns A Promise that resolves to a LocalDisplayStream. + * @returns A Promise that resolves to a LocalDisplayStream or an error. */ -export async function createDisplayStream( +export async function createDisplayStream( + constructor: Constructor, videoContentHint?: VideoContentHint -): Promise { - const stream = await media.getDisplayMedia({ video: true }); - const localDisplayStream = new LocalDisplayStream(stream); +): Promise { + let stream; + try { + stream = await media.getDisplayMedia({ video: true }); + } catch (error) { + throw new WcmeError( + ErrorTypes.CREATE_STREAM_FAILED, + `Failed to create display stream: ${error}` + ); + } + const localDisplayStream = new constructor(stream); if (videoContentHint) { localDisplayStream.contentHint = videoContentHint; } @@ -113,21 +168,41 @@ export async function createDisplayStream( /** * Creates a LocalDisplayStream and a LocalSystemAudioStream with the given parameters. * + * @param displayStreamConstructor - Constructor for the local display stream. + * @param systemAudioStreamConstructor - Constructor for the local system audio stream. * @param videoContentHint - An optional parameter to give a hint for the content of the stream. - * @returns A Promise that resolves to a LocalDisplayStream and a LocalSystemAudioStream. If no system - * audio is available, the LocalSystemAudioStream will be resolved as null instead. + * @returns A Promise that resolves to a LocalDisplayStream and a LocalSystemAudioStream or an + * error. If no system audio is available, the LocalSystemAudioStream will be resolved as null + * instead. */ -export async function createDisplayStreamWithAudio( +export async function createDisplayStreamWithAudio< + T extends LocalDisplayStream, + U extends LocalSystemAudioStream +>( + displayStreamConstructor: Constructor, + systemAudioStreamConstructor: Constructor, videoContentHint?: VideoContentHint -): Promise<[LocalDisplayStream, LocalSystemAudioStream | null]> { - const stream = await media.getDisplayMedia({ video: true, audio: true }); - const localDisplayStream = new LocalDisplayStream(new MediaStream(stream.getVideoTracks())); +): Promise<[T, U | null]> { + let stream; + try { + stream = await media.getDisplayMedia({ video: true, audio: true }); + } catch (error) { + throw new WcmeError( + ErrorTypes.CREATE_STREAM_FAILED, + `Failed to create display and system audio streams: ${error}` + ); + } + // eslint-disable-next-line new-cap + const localDisplayStream = new displayStreamConstructor(new MediaStream(stream.getVideoTracks())); if (videoContentHint) { localDisplayStream.contentHint = videoContentHint; } let localSystemAudioStream = null; if (stream.getAudioTracks().length > 0) { - localSystemAudioStream = new LocalSystemAudioStream(new MediaStream(stream.getAudioTracks())); + // eslint-disable-next-line new-cap + localSystemAudioStream = new systemAudioStreamConstructor( + new MediaStream(stream.getAudioTracks()) + ); } return [localDisplayStream, localSystemAudioStream]; }