Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
86 changes: 80 additions & 6 deletions spec/unit/webrtc/mediaHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
Expand All @@ -77,7 +77,7 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
}),
);
Expand Down Expand Up @@ -109,7 +109,7 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
Expand All @@ -122,10 +122,10 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
Expand Down Expand Up @@ -331,6 +331,80 @@ describe("Media Handler", function () {

expect(stream.getVideoTracks().length).toEqual(0);
});

it("falls back to ideal deviceId when exact deviceId fails", async () => {
// First call with exact should fail
mockMediaDevices.getUserMedia
.mockRejectedValueOnce(new Error("OverconstrainedError"))
.mockImplementation((constraints: MediaStreamConstraints) => {
const stream = new MockMediaStream("local_stream");
if (constraints.audio) {
const track = new MockMediaStreamTrack("audio_track", "audio");
track.settings = { deviceId: FAKE_AUDIO_INPUT_ID };
stream.addTrack(track);
}
return Promise.resolve(stream.typed());
});

const stream = await mediaHandler.getUserMediaStream(true, false);

// Should have been called twice: once with exact, once with ideal
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
}),
);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
}),
}),
);
expect(stream).toBeTruthy();
});

it("falls back to ideal deviceId for video when exact fails", async () => {
// First call with exact should fail
mockMediaDevices.getUserMedia
.mockRejectedValueOnce(new Error("OverconstrainedError"))
.mockImplementation((constraints: MediaStreamConstraints) => {
const stream = new MockMediaStream("local_stream");
if (constraints.video) {
const track = new MockMediaStreamTrack("video_track", "video");
track.settings = { deviceId: FAKE_VIDEO_INPUT_ID };
stream.addTrack(track);
}
return Promise.resolve(stream.typed());
});

const stream = await mediaHandler.getUserMediaStream(false, true);

// Should have been called twice: once with exact, once with ideal
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
video: expect.objectContaining({
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
}),
}),
);
expect(stream).toBeTruthy();
});
});

describe("getScreensharingStream", () => {
Expand Down
63 changes: 40 additions & 23 deletions src/webrtc/mediaHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,19 @@ export class MediaHandler extends TypedEventEmitter<
}

if (!canReuseStream) {
const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo);
stream = await navigator.mediaDevices.getUserMedia(constraints);
let constraints: MediaStreamConstraints;
try {
// Not specifying exact for deviceId means switching devices does not always work,
// try with exact and fallback to ideal if it fails
constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, true);
Copy link
Member

Choose a reason for hiding this comment

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

I don't understand, as per doc
ideal: A string or an array of strings, specifying ideal values for the property. If possible, one of the listed values will be used, but if it's not possible, the user agent will use the closest possible match.

Why doing exact then ideal would work differently than just ideal? what am I missing?

Copy link
Contributor Author

@langleyd langleyd Nov 7, 2025

Choose a reason for hiding this comment

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

on macOS, Using "ideal" at the moment is not working when switching camera and providing a specific deviceId. I don't know why maybe a system bug.

By the sounds of the comments further down in the code, this api is not very consistent cross browser either.

stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (e) {
logger.warn(
`MediaHandler getUserMediaStreamInternal() error (e=${e}), retrying without exact deviceId`,
);
constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo, false);
stream = await navigator.mediaDevices.getUserMedia(constraints);
}
logger.log(
`MediaHandler getUserMediaStreamInternal() calling getUserMediaStream (streamId=${
stream.id
Expand Down Expand Up @@ -435,30 +446,36 @@ export class MediaHandler extends TypedEventEmitter<
this.emit(MediaHandlerEvent.LocalStreamsChanged);
}

private getUserMediaContraints(audio: boolean, video: boolean): MediaStreamConstraints {
private getUserMediaContraints(audio: boolean, video: boolean, exactDeviceId?: boolean): MediaStreamConstraints {
const isWebkit = !!navigator.webkitGetUserMedia;
const deviceIdKey = exactDeviceId ? "exact" : "ideal";

const audioConstraints: MediaTrackConstraints = {};
if (this.audioInput) {
audioConstraints.deviceId = { [deviceIdKey]: this.audioInput };
}
if (this.audioSettings) {
audioConstraints.autoGainControl = { ideal: this.audioSettings.autoGainControl };
audioConstraints.echoCancellation = { ideal: this.audioSettings.echoCancellation };
audioConstraints.noiseSuppression = { ideal: this.audioSettings.noiseSuppression };
}

const videoConstraints: MediaTrackConstraints = {
/* We want 640x360. Chrome will give it only if we ask exactly,
FF refuses entirely if we ask exactly, so have to ask for ideal
instead
XXX: Is this still true?
*/
width: isWebkit ? { exact: 640 } : { ideal: 640 },
height: isWebkit ? { exact: 360 } : { ideal: 360 },
};
if (this.videoInput) {
videoConstraints.deviceId = { [deviceIdKey]: this.videoInput };
}

return {
audio: audio
? {
deviceId: this.audioInput ? { ideal: this.audioInput } : undefined,
autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined,
echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined,
noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined,
}
: false,
video: video
? {
deviceId: this.videoInput ? { ideal: this.videoInput } : undefined,
/* We want 640x360. Chrome will give it only if we ask exactly,
FF refuses entirely if we ask exactly, so have to ask for ideal
instead
XXX: Is this still true?
*/
width: isWebkit ? { exact: 640 } : { ideal: 640 },
height: isWebkit ? { exact: 360 } : { ideal: 360 },
}
: false,
audio: audio ? audioConstraints : false,
video: video ? videoConstraints : false,
};
}

Expand Down