Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 138 additions & 20 deletions src/device/device-management.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { WebrtcCoreError, WebrtcCoreErrorType } from '../errors';
import * as media from '../media';
import { LocalCameraStream } from '../media/local-camera-stream';
import { LocalDisplayStream } from '../media/local-display-stream';
Expand All @@ -7,14 +8,15 @@ import { createBrowserMock } from '../mocks/create-browser-mock';
import MediaStreamStub from '../mocks/media-stream-stub';
import { createMockedStream, createMockedStreamWithAudio } from '../util/test-utils';
import {
CaptureController,
createCameraAndMicrophoneStreams,
createCameraStream,
createDisplayMedia,
createDisplayStream,
createDisplayStreamWithAudio,
createMicrophoneStream,
getDevices,
} from './device-management';
import { WebrtcCoreError, WebrtcCoreErrorType } from '../errors';

jest.mock('../mocks/media-stream-stub');

Expand All @@ -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').mockReturnValue(Promise.resolve(mockStream));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://jestjs.io/docs/mock-function-api#mockfnmockresolvedvaluevalue

mockFn.mockResolvedValue(value)

Shorthand for:

jest.fn().mockImplementation(() => Promise.resolve(value));

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


it('should call getUserMedia', async () => {
expect.assertions(1);
Expand Down Expand Up @@ -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').mockReturnValue(Promise.resolve(mockStream));

it('should call getUserMedia', async () => {
expect.assertions(1);
Expand Down Expand Up @@ -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').mockReturnValue(Promise.resolve(mockStream));

it('should call getUserMedia', async () => {
expect.assertions(1);
Expand Down Expand Up @@ -173,24 +169,148 @@ describe('Device Management', () => {
});
});

describe('createDisplayMedia', () => {
jest.spyOn(media, 'getDisplayMedia').mockReturnValue(Promise.resolve(mockStream));

it('should call getDisplayMedia with video only', async () => {
expect.assertions(1);

await createDisplayMedia({
video: { constructor: 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: { constructor: LocalDisplayStream },
audio: { constructor: 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: { constructor: LocalDisplayStream, constraints: { frameRate: 5 } },
audio: { constructor: 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: { constructor: LocalDisplayStream, videoContentHint: 'motion' },
});
expect(localDisplayStream.contentHint).toBe('motion');
});

it('should call getDisplayMedia with the preferCurrentTab option', async () => {
expect.assertions(1);

await createDisplayMedia({
video: { constructor: 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: { constructor: 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: { constructor: 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: { constructor: 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: { constructor: LocalDisplayStream },
audio: { constructor: 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: { constructor: 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').mockReturnValue(Promise.resolve(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 () => {
Expand All @@ -202,9 +322,7 @@ describe('Device Management', () => {
});

describe('createDisplayStreamWithAudio', () => {
jest
.spyOn(media, 'getDisplayMedia')
.mockReturnValue(Promise.resolve(mockStream as unknown as MediaStream));
jest.spyOn(media, 'getDisplayMedia').mockReturnValue(Promise.resolve(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
Expand Down Expand Up @@ -235,7 +353,7 @@ describe('Device Management', () => {
const mockStreamWithAudio = createMockedStreamWithAudio();
jest
.spyOn(media, 'getDisplayMedia')
.mockReturnValueOnce(Promise.resolve(mockStreamWithAudio as unknown as MediaStream));
.mockReturnValueOnce(Promise.resolve(mockStreamWithAudio));

const [localDisplayStream, localSystemAudioStream] = await createDisplayStreamWithAudio(
LocalDisplayStream,
Expand Down
132 changes: 97 additions & 35 deletions src/device/device-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export type VideoDeviceConstraints = Pick<
'aspectRatio' | 'deviceId' | 'facingMode' | 'frameRate' | 'height' | 'width'
>;

// 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: 'auto' | 'always' | 'never'): Promise<void>;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question about these behavior states should we keep wording from the spec?

enum CaptureStartFocusBehavior {
"focus-capturing-application",
"focus-captured-surface",
"no-focus-change"
};

https://www.w3.org/TR/screen-capture/#dom-capturestartfocusbehavior:~:text=5.4.2%20CaptureStartFocusBehavior

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, thanks for catching that. Admittedly, I think I tried using copilot here but didn't verify that the types were correct. Lesson learned 😅

}

/**
* Creates a camera stream. Please note that the constraint params in second getUserMedia call would NOT take effect when:
*
Expand Down Expand Up @@ -114,29 +121,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.constructor - 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.constructor - 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<T extends LocalDisplayStream>(
constructor: Constructor<T>,
videoContentHint?: VideoContentHint
): Promise<T> {
export async function createDisplayMedia<
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly I'm a little iffy about the name of this API. Nothing about createDisplayMedia tells me it's a more advanced alternative to createDisplayStreamWithAudio but I'm not sure I have any better ideas.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this name for sure. It's not telling you that it's a more advanced alternative, but as an option, if we need to explain this difference to someone, we can just add an API description in README.

T extends LocalDisplayStream,
U extends LocalSystemAudioStream
>(options: {
video: {
constructor: Constructor<T>;
constraints?: VideoDeviceConstraints;
videoContentHint?: VideoContentHint;
preferCurrentTab?: boolean;
selfBrowserSurface?: 'include' | 'exclude';
surfaceSwitching?: 'include' | 'exclude';
monitorTypeSurfaces?: 'include' | 'exclude';
};
audio?: {
constructor: Constructor<U>;
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,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of these additional options are experimental so TypeScript does not have the proper types yet. Casting as any is necessary here to prevent TypeScript from yelling at us.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brycetham Could we consider having global.d.ts with the right types api getDisplayMedia for our needs instead of casting to any? I feel for this purpose having own d.ts feels right

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good call. Actually, I noticed that we use our own getDisplayMedia definition from media/index.ts, so I think I can just redefine the parameters there. That way, I don't have to mess with the global scope and it is still well defined somewhere in our repo.

} 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.constructor(
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.constructor(
new MediaStream(stream.getAudioTracks())
);
}
return [localDisplayStream, localSystemAudioStream];
}

/**
* 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 or an error.
*/
export async function createDisplayStream<T extends LocalDisplayStream>(
constructor: Constructor<T>,
videoContentHint?: VideoContentHint
): Promise<T> {
const [localDisplayStream] = await createDisplayMedia({
video: { constructor, videoContentHint },
});
return localDisplayStream;
}

Expand All @@ -158,28 +238,10 @@ export async function createDisplayStreamWithAudio<
systemAudioStreamConstructor: Constructor<U>,
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: { constructor: displayStreamConstructor, videoContentHint },
audio: { constructor: systemAudioStreamConstructor },
});
}

/**
Expand Down