diff --git a/src/device/device-management.spec.ts b/src/device/device-management.spec.ts index 3d3d344..1224073 100644 --- a/src/device/device-management.spec.ts +++ b/src/device/device-management.spec.ts @@ -1,4 +1,6 @@ +import { WebrtcCoreError, WebrtcCoreErrorType } from '../errors'; import * as media from '../media'; +import { CaptureController } from '../media'; import { LocalCameraStream } from '../media/local-camera-stream'; import { LocalDisplayStream } from '../media/local-display-stream'; import { LocalMicrophoneStream } from '../media/local-microphone-stream'; @@ -9,12 +11,12 @@ import { createMockedStream, createMockedStreamWithAudio } from '../util/test-ut import { createCameraAndMicrophoneStreams, createCameraStream, + createDisplayMedia, createDisplayStream, createDisplayStreamWithAudio, createMicrophoneStream, getDevices, } from './device-management'; -import { WebrtcCoreError, WebrtcCoreErrorType } from '../errors'; jest.mock('../mocks/media-stream-stub'); @@ -25,9 +27,7 @@ describe('Device Management', () => { const mockStream = createMockedStream(); describe('createMicrophoneStream', () => { - jest - .spyOn(media, 'getUserMedia') - .mockReturnValue(Promise.resolve(mockStream as unknown as MediaStream)); + jest.spyOn(media, 'getUserMedia').mockResolvedValue(mockStream); it('should call getUserMedia', async () => { expect.assertions(1); @@ -76,9 +76,7 @@ describe('Device Management', () => { }); describe('createCameraStream', () => { - jest - .spyOn(media, 'getUserMedia') - .mockReturnValue(Promise.resolve(mockStream as unknown as MediaStream)); + jest.spyOn(media, 'getUserMedia').mockResolvedValue(mockStream); it('should call getUserMedia', async () => { expect.assertions(1); @@ -125,9 +123,7 @@ describe('Device Management', () => { }); describe('createCameraAndMicrophoneStreams', () => { - jest - .spyOn(media, 'getUserMedia') - .mockReturnValue(Promise.resolve(mockStream as unknown as MediaStream)); + jest.spyOn(media, 'getUserMedia').mockResolvedValue(mockStream); it('should call getUserMedia', async () => { expect.assertions(1); @@ -173,24 +169,151 @@ describe('Device Management', () => { }); }); + describe('createDisplayMedia', () => { + jest.spyOn(media, 'getDisplayMedia').mockResolvedValue(mockStream); + + it('should call getDisplayMedia with video only', async () => { + expect.assertions(1); + + await createDisplayMedia({ + video: { displayStreamConstructor: LocalDisplayStream }, + }); + expect(media.getDisplayMedia).toHaveBeenCalledWith({ video: true, audio: false }); + }); + + it('should call getDisplayMedia with both video and audio', async () => { + expect.assertions(1); + + await createDisplayMedia({ + video: { displayStreamConstructor: LocalDisplayStream }, + audio: { systemAudioStreamConstructor: LocalSystemAudioStream }, + }); + expect(media.getDisplayMedia).toHaveBeenCalledWith({ video: true, audio: true }); + }); + + it('should call getDisplayMedia with video and audio constraints', async () => { + expect.assertions(1); + + await createDisplayMedia({ + video: { displayStreamConstructor: LocalDisplayStream, constraints: { frameRate: 5 } }, + audio: { + systemAudioStreamConstructor: LocalSystemAudioStream, + constraints: { autoGainControl: false }, + }, + }); + expect(media.getDisplayMedia).toHaveBeenCalledWith({ + video: { frameRate: 5 }, + audio: { autoGainControl: false }, + }); + }); + + it('should preserve the content hint', async () => { + expect.assertions(1); + + const [localDisplayStream] = await createDisplayMedia({ + video: { displayStreamConstructor: LocalDisplayStream, videoContentHint: 'motion' }, + }); + expect(localDisplayStream.contentHint).toBe('motion'); + }); + + it('should call getDisplayMedia with the preferCurrentTab option', async () => { + expect.assertions(1); + + await createDisplayMedia({ + video: { displayStreamConstructor: LocalDisplayStream, preferCurrentTab: true }, + }); + expect(media.getDisplayMedia).toHaveBeenCalledWith({ + video: true, + audio: false, + preferCurrentTab: true, + }); + }); + + it('should call getDisplayMedia with the selfBrowserSurface option', async () => { + expect.assertions(1); + + await createDisplayMedia({ + video: { displayStreamConstructor: LocalDisplayStream, selfBrowserSurface: 'include' }, + }); + expect(media.getDisplayMedia).toHaveBeenCalledWith({ + video: true, + audio: false, + selfBrowserSurface: 'include', + }); + }); + + it('should call getDisplayMedia with the surfaceSwitching option', async () => { + expect.assertions(1); + + await createDisplayMedia({ + video: { displayStreamConstructor: LocalDisplayStream, surfaceSwitching: 'include' }, + }); + expect(media.getDisplayMedia).toHaveBeenCalledWith({ + video: true, + audio: false, + surfaceSwitching: 'include', + }); + }); + + it('should call getDisplayMedia with the monitorTypeSurfaces option', async () => { + expect.assertions(1); + + await createDisplayMedia({ + video: { displayStreamConstructor: LocalDisplayStream, monitorTypeSurfaces: 'exclude' }, + }); + expect(media.getDisplayMedia).toHaveBeenCalledWith({ + video: true, + audio: false, + monitorTypeSurfaces: 'exclude', + }); + }); + + it('should call getDisplayMedia with the systemAudio option', async () => { + expect.assertions(1); + + await createDisplayMedia({ + video: { displayStreamConstructor: LocalDisplayStream }, + audio: { systemAudioStreamConstructor: LocalSystemAudioStream, systemAudio: 'exclude' }, + }); + expect(media.getDisplayMedia).toHaveBeenCalledWith({ + video: true, + audio: true, + systemAudio: 'exclude', + }); + }); + + it('should call getDisplayMedia with the controller option', async () => { + expect.assertions(1); + + const fakeController: CaptureController = {} as CaptureController; + + await createDisplayMedia({ + video: { displayStreamConstructor: LocalDisplayStream }, + controller: fakeController, + }); + expect(media.getDisplayMedia).toHaveBeenCalledWith({ + video: true, + audio: false, + controller: fakeController, + }); + }); + }); + describe('createDisplayStream', () => { - jest - .spyOn(media, 'getDisplayMedia') - .mockReturnValue(Promise.resolve(mockStream as unknown as MediaStream)); + jest.spyOn(media, 'getDisplayMedia').mockResolvedValue(mockStream); it('should call getDisplayMedia', async () => { expect.assertions(1); await createDisplayStream(LocalDisplayStream); - expect(media.getDisplayMedia).toHaveBeenCalledWith({ video: true }); + expect(media.getDisplayMedia).toHaveBeenCalledWith({ video: true, audio: false }); }); it('should return a LocalDisplayStream instance', async () => { - expect.assertions(2); + expect.assertions(1); const localDisplayStream = await createDisplayStream(LocalDisplayStream); expect(localDisplayStream).toBeInstanceOf(LocalDisplayStream); - expect(localDisplayStream.contentHint).toBeUndefined(); }); it('should preserve the content hint', async () => { @@ -202,9 +325,7 @@ describe('Device Management', () => { }); describe('createDisplayStreamWithAudio', () => { - jest - .spyOn(media, 'getDisplayMedia') - .mockReturnValue(Promise.resolve(mockStream as unknown as MediaStream)); + jest.spyOn(media, 'getDisplayMedia').mockResolvedValue(mockStream); // This mock implementation is needed because createDisplayStreamWithAudio will create a new // MediaStream from the video track of the mocked stream, so we need to make sure this new @@ -233,9 +354,7 @@ describe('Device Management', () => { expect.assertions(2); const mockStreamWithAudio = createMockedStreamWithAudio(); - jest - .spyOn(media, 'getDisplayMedia') - .mockReturnValueOnce(Promise.resolve(mockStreamWithAudio as unknown as MediaStream)); + jest.spyOn(media, 'getDisplayMedia').mockResolvedValueOnce(mockStreamWithAudio); const [localDisplayStream, localSystemAudioStream] = await createDisplayStreamWithAudio( LocalDisplayStream, diff --git a/src/device/device-management.ts b/src/device/device-management.ts index e700310..60af284 100644 --- a/src/device/device-management.ts +++ b/src/device/device-management.ts @@ -1,5 +1,6 @@ import { WebrtcCoreError, WebrtcCoreErrorType } from '../errors'; import * as media from '../media'; +import { CaptureController } from '../media'; import { LocalCameraStream } from '../media/local-camera-stream'; import { LocalDisplayStream } from '../media/local-display-stream'; import { LocalMicrophoneStream } from '../media/local-microphone-stream'; @@ -31,12 +32,12 @@ 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 cameraStreamConstructor - Constructor for the local camera stream. * @param constraints - Video device constraints. * @returns A LocalCameraStream object or an error. */ export async function createCameraStream( - constructor: Constructor, + cameraStreamConstructor: Constructor, constraints?: VideoDeviceConstraints ): Promise { let stream: MediaStream; @@ -48,18 +49,19 @@ export async function createCameraStream( `Failed to create camera stream: ${error}` ); } - return new constructor(stream); + // eslint-disable-next-line new-cap + return new cameraStreamConstructor(stream); } /** * Creates a LocalMicrophoneStream with the given constraints. * - * @param constructor - Constructor for the local microphone stream. + * @param microphoneStreamConstructor - Constructor for the local microphone stream. * @param constraints - Audio device constraints. * @returns A LocalMicrophoneStream object or an error. */ export async function createMicrophoneStream( - constructor: Constructor, + microphoneStreamConstructor: Constructor, constraints?: AudioDeviceConstraints ): Promise { let stream: MediaStream; @@ -71,7 +73,8 @@ export async function createMicrophoneStream( `Failed to create microphone stream: ${error}` ); } - return new constructor(stream); + // eslint-disable-next-line new-cap + return new microphoneStreamConstructor(stream); } /** @@ -114,29 +117,102 @@ export async function createCameraAndMicrophoneStreams< } /** - * Creates a LocalDisplayStream with the given parameters. + * Creates a LocalDisplayStream and a LocalSystemAudioStream 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 or an error. + * This is a more advanced version of createDisplayStreamWithAudio that allows the user to specify + * additional display media options and constraints. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia#options. + * + * @param options - An object containing the options for creating the display and system audio streams. + * @param options.video - An object containing the video stream options. + * @param options.video.displayStreamConstructor - Constructor for the local display stream. + * @param options.video.constraints - Video device constraints. + * @param options.video.videoContentHint - A hint for the content of the stream. + * @param options.video.preferCurrentTab - Whether to offer the current tab as the most prominent capture source. + * @param options.video.selfBrowserSurface - Whether to allow the user to select the current tab for capture. + * @param options.video.surfaceSwitching - Whether to allow the user to dynamically switch the shared tab during screen-sharing. + * @param options.video.monitorTypeSurfaces - Whether to offer the user the option to choose display surfaces whose type is monitor. + * @param options.audio - An object containing the audio stream options. If present, a system audio stream will be created. + * @param options.audio.systemAudioStreamConstructor - Constructor for the local system audio stream. + * @param options.audio.constraints - Audio device constraints. + * @param options.audio.systemAudio - Whether to include the system audio among the possible audio sources offered to the user. + * @param options.controller - CaptureController to further manipulate the capture session. + * @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 createDisplayStream( - constructor: Constructor, - videoContentHint?: VideoContentHint -): Promise { +export async function createDisplayMedia< + T extends LocalDisplayStream, + U extends LocalSystemAudioStream +>(options: { + video: { + displayStreamConstructor: Constructor; + constraints?: VideoDeviceConstraints; + videoContentHint?: VideoContentHint; + preferCurrentTab?: boolean; + selfBrowserSurface?: 'include' | 'exclude'; + surfaceSwitching?: 'include' | 'exclude'; + monitorTypeSurfaces?: 'include' | 'exclude'; + }; + audio?: { + systemAudioStreamConstructor: Constructor; + constraints?: AudioDeviceConstraints; + systemAudio?: 'include' | 'exclude'; + }; + controller?: CaptureController; +}): Promise<[T, U | null]> { let stream; + const videoConstraints = options.video.constraints || true; + const audioConstraints = options.audio?.constraints || !!options.audio; try { - stream = await media.getDisplayMedia({ video: true }); + stream = await media.getDisplayMedia({ + video: videoConstraints, + audio: audioConstraints, + controller: options.controller, + preferCurrentTab: options.video.preferCurrentTab, + selfBrowserSurface: options.video.selfBrowserSurface, + surfaceSwitching: options.video.surfaceSwitching, + systemAudio: options.audio?.systemAudio, + monitorTypeSurfaces: options.video.monitorTypeSurfaces, + }); } catch (error) { throw new WebrtcCoreError( WebrtcCoreErrorType.CREATE_STREAM_FAILED, - `Failed to create display stream: ${error}` + `Failed to create display and/or system audio streams: ${error}` ); } - const localDisplayStream = new constructor(stream); - if (videoContentHint) { - localDisplayStream.contentHint = videoContentHint; + // eslint-disable-next-line new-cap + const localDisplayStream = new options.video.displayStreamConstructor( + new MediaStream(stream.getVideoTracks()) + ); + if (options.video.videoContentHint) { + localDisplayStream.contentHint = options.video.videoContentHint; + } + let localSystemAudioStream = null; + if (options.audio && stream.getAudioTracks().length > 0) { + // eslint-disable-next-line new-cap + localSystemAudioStream = new options.audio.systemAudioStreamConstructor( + new MediaStream(stream.getAudioTracks()) + ); } + return [localDisplayStream, localSystemAudioStream]; +} + +/** + * Creates a LocalDisplayStream with the given parameters. + * + * @param displayStreamConstructor - 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 or an error. + */ +export async function createDisplayStream( + displayStreamConstructor: Constructor, + videoContentHint?: VideoContentHint +): Promise { + const [localDisplayStream] = await createDisplayMedia({ + video: { displayStreamConstructor, videoContentHint }, + }); return localDisplayStream; } @@ -158,28 +234,10 @@ export async function createDisplayStreamWithAudio< systemAudioStreamConstructor: Constructor, videoContentHint?: VideoContentHint ): Promise<[T, U | null]> { - let stream; - try { - stream = await media.getDisplayMedia({ video: true, audio: true }); - } catch (error) { - throw new WebrtcCoreError( - WebrtcCoreErrorType.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) { - // eslint-disable-next-line new-cap - localSystemAudioStream = new systemAudioStreamConstructor( - new MediaStream(stream.getAudioTracks()) - ); - } - return [localDisplayStream, localSystemAudioStream]; + return createDisplayMedia({ + video: { displayStreamConstructor, videoContentHint }, + audio: { systemAudioStreamConstructor }, + }); } /** diff --git a/src/media/index.ts b/src/media/index.ts index ebec565..c615142 100644 --- a/src/media/index.ts +++ b/src/media/index.ts @@ -6,6 +6,15 @@ export enum DeviceKind { VideoInput = 'videoinput', } +// CaptureController is experimental so TypeScript doesn't have a type for it yet. +// So we define the interface ourselves here. +// See https://developer.mozilla.org/en-US/docs/Web/API/CaptureController. +export interface CaptureController { + setFocusBehavior( + behavior: 'focus-capturing-application' | 'focus-captured-surface' | 'no-focus-change' + ): Promise; +} + /** * Prompts the user for permission to use a media input which produces a MediaStream with tracks * containing the requested types of media. @@ -21,14 +30,23 @@ export async function getUserMedia(constraints: MediaStreamConstraints): Promise /** * Prompts the user for permission to use a user's display media and audio. If a video track is - * absent from the constraints argument, one will still be provided. + * absent from the constraints argument, one will still be provided. Includes experimental options + * found in https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia#options. * * @param constraints - A MediaStreamConstraints object specifying the types of media to request, - * along with any requirements for each type. + * along with any requirements for each type, as well as experimental options. * @returns A Promise whose fulfillment handler receives a MediaStream object when the requested * media has successfully been obtained. */ -export function getDisplayMedia(constraints: MediaStreamConstraints): Promise { +export function getDisplayMedia( + constraints: MediaStreamConstraints & { + controller?: CaptureController; + selfBrowserSurface?: 'include' | 'exclude'; + surfaceSwitching?: 'include' | 'exclude'; + systemAudio?: 'include' | 'exclude'; + monitorTypeSurfaces?: 'include' | 'exclude'; + } +): Promise { return navigator.mediaDevices.getDisplayMedia(constraints); }