Skip to content

Commit a8fc0ea

Browse files
authored
fix: cover some device selection edge cases (#1604)
This PR fixes some edge cases around device selection: ### Selecting the `default` device Chrome allows requesting user media with device id `default`. For the purposes of the SDK, we treat this device as if it didn't existed, and filter it out of device lists. However, it was still possible to select it manually. Now device id `default` is filtered out of media requests, so even if it is selected manually, it is replaced with an actual device id when device is unmuted. ### Selecting non-existing device id Previously, selecting non-existing device (e.g. `call.camera.select('nonsense')`) resulted in the default device being selected. That could be a nasty surprise for a user. Now, either `select` method will throw (if device is already unmuted) or enabling the device will throw later. This takes us to the final change: ### Error handler for device selectors `onError` callback added to audio and video device selectors, since `select` method can now throw in a rare case that the device list is stale.
1 parent 36181d5 commit a8fc0ea

File tree

6 files changed

+76
-54
lines changed

6 files changed

+76
-54
lines changed

packages/client/src/devices/InputMediaDeviceManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export abstract class InputMediaDeviceManager<
215215
await this.applySettingsToStream();
216216
} catch (error) {
217217
this.state.setDevice(prevDeviceId);
218+
await this.applySettingsToStream();
218219
throw error;
219220
}
220221
}

packages/client/src/devices/devices.ts

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export const getAudioStream = async (
184184
const constraints: MediaStreamConstraints = {
185185
audio: {
186186
...audioDeviceConstraints.audio,
187-
...trackConstraints,
187+
...normalizeContraints(trackConstraints),
188188
},
189189
};
190190

@@ -195,16 +195,6 @@ export const getAudioStream = async (
195195
});
196196
return await getStream(constraints);
197197
} catch (error) {
198-
if (error instanceof OverconstrainedError && trackConstraints?.deviceId) {
199-
const { deviceId, ...relaxedContraints } = trackConstraints;
200-
getLogger(['devices'])(
201-
'warn',
202-
'Failed to get audio stream, will try again with relaxed contraints',
203-
{ error, constraints, relaxedContraints },
204-
);
205-
return getAudioStream(relaxedContraints);
206-
}
207-
208198
getLogger(['devices'])('error', 'Failed to get audio stream', {
209199
error,
210200
constraints,
@@ -227,7 +217,7 @@ export const getVideoStream = async (
227217
const constraints: MediaStreamConstraints = {
228218
video: {
229219
...videoDeviceConstraints.video,
230-
...trackConstraints,
220+
...normalizeContraints(trackConstraints),
231221
},
232222
};
233223
try {
@@ -237,16 +227,6 @@ export const getVideoStream = async (
237227
});
238228
return await getStream(constraints);
239229
} catch (error) {
240-
if (error instanceof OverconstrainedError && trackConstraints?.deviceId) {
241-
const { deviceId, ...relaxedContraints } = trackConstraints;
242-
getLogger(['devices'])(
243-
'warn',
244-
'Failed to get video stream, will try again with relaxed contraints',
245-
{ error, constraints, relaxedContraints },
246-
);
247-
return getVideoStream(relaxedContraints);
248-
}
249-
250230
getLogger(['devices'])('error', 'Failed to get video stream', {
251231
error,
252232
constraints,
@@ -255,6 +235,20 @@ export const getVideoStream = async (
255235
}
256236
};
257237

238+
function normalizeContraints(constraints: MediaTrackConstraints | undefined) {
239+
if (
240+
constraints?.deviceId === 'default' ||
241+
(typeof constraints?.deviceId === 'object' &&
242+
'exact' in constraints.deviceId &&
243+
constraints.deviceId.exact === 'default')
244+
) {
245+
const { deviceId, ...contraintsWithoutDeviceId } = constraints;
246+
return contraintsWithoutDeviceId;
247+
}
248+
249+
return constraints;
250+
}
251+
258252
/**
259253
* Prompts the user for a permission to share a screen.
260254
* If the user grants the permission, a screen sharing stream is returned. Throws otherwise.

packages/react-sdk/src/components/DeviceSettings/DeviceSelectorAudio.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,35 @@
11
import { useCallStateHooks } from '@stream-io/video-react-bindings';
22
import { DeviceSelector } from './DeviceSelector';
3+
import {
4+
createCallControlHandler,
5+
PropsWithErrorHandler,
6+
} from '../../utilities/callControlHandler';
37

4-
export type DeviceSelectorAudioInputProps = {
8+
export type DeviceSelectorAudioInputProps = PropsWithErrorHandler<{
59
title?: string;
610
visualType?: 'list' | 'dropdown';
7-
};
11+
}>;
812

9-
export const DeviceSelectorAudioInput = ({
10-
title,
11-
visualType,
12-
}: DeviceSelectorAudioInputProps) => {
13+
export const DeviceSelectorAudioInput = (
14+
props: DeviceSelectorAudioInputProps,
15+
) => {
1316
const { useMicrophoneState } = useCallStateHooks();
1417
const { microphone, selectedDevice, devices } = useMicrophoneState();
18+
const handleChange = createCallControlHandler(
19+
props,
20+
async (deviceId: string) => {
21+
await microphone.select(deviceId);
22+
},
23+
);
1524

1625
return (
1726
<DeviceSelector
1827
devices={devices || []}
1928
selectedDeviceId={selectedDevice}
2029
type="audioinput"
21-
onChange={async (deviceId) => {
22-
await microphone.select(deviceId);
23-
}}
24-
title={title}
25-
visualType={visualType}
30+
onChange={handleChange}
31+
title={props.title}
32+
visualType={props.visualType}
2633
icon="mic"
2734
/>
2835
);

packages/react-sdk/src/components/DeviceSettings/DeviceSelectorVideo.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,33 @@
1+
import {
2+
createCallControlHandler,
3+
PropsWithErrorHandler,
4+
} from '../../utilities/callControlHandler';
15
import { DeviceSelector } from './DeviceSelector';
26
import { useCallStateHooks } from '@stream-io/video-react-bindings';
37

4-
export type DeviceSelectorVideoProps = {
8+
export type DeviceSelectorVideoProps = PropsWithErrorHandler<{
59
title?: string;
610
visualType?: 'list' | 'dropdown';
7-
};
11+
}>;
812

9-
export const DeviceSelectorVideo = ({
10-
title,
11-
visualType,
12-
}: DeviceSelectorVideoProps) => {
13+
export const DeviceSelectorVideo = (props: DeviceSelectorVideoProps) => {
1314
const { useCameraState } = useCallStateHooks();
1415
const { camera, devices, selectedDevice } = useCameraState();
16+
const handleChange = createCallControlHandler(
17+
props,
18+
async (deviceId: string) => {
19+
await camera.select(deviceId);
20+
},
21+
);
1522

1623
return (
1724
<DeviceSelector
1825
devices={devices || []}
1926
type="videoinput"
2027
selectedDeviceId={selectedDevice}
21-
onChange={async (deviceId) => {
22-
await camera.select(deviceId);
23-
}}
24-
title={title}
25-
visualType={visualType}
28+
onChange={handleChange}
29+
title={props.title}
30+
visualType={props.visualType}
2631
icon="camera"
2732
/>
2833
);

packages/react-sdk/src/hooks/usePersistedDevicePreferences.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,14 @@ const usePersistDevicePreferences = (
6868
*
6969
* @param key the key to use for local storage.
7070
*/
71-
const useApplyDevicePreferences = (key: string, onApplied: () => void) => {
71+
const useApplyDevicePreferences = (
72+
key: string,
73+
onWillApply: () => void,
74+
onApplied: () => void,
75+
) => {
7276
const call = useCall();
77+
const onWillApplyRef = useRef(onWillApply);
78+
onWillApplyRef.current = onWillApply;
7379
const onAppliedRef = useRef(onApplied);
7480
onAppliedRef.current = onApplied;
7581
useEffect(() => {
@@ -115,17 +121,22 @@ const useApplyDevicePreferences = (key: string, onApplied: () => void) => {
115121
console.warn('Failed to load device preferences', err);
116122
}
117123
if (preferences) {
118-
await initMic(preferences.mic);
119-
await initCamera(preferences.camera);
124+
await initMic(preferences.mic).catch((err) => {
125+
console.warn('Failed to apply microphone preferences', err);
126+
});
127+
await initCamera(preferences.camera).catch((err) => {
128+
console.warn('Failed to apply camera preferences', err);
129+
});
120130
initSpeaker(preferences.speaker);
121131
}
122132
};
123133

134+
onWillApplyRef.current();
124135
apply()
125-
.then(() => onAppliedRef.current())
126136
.catch((err) => {
127137
console.warn('Failed to apply device preferences', err);
128-
});
138+
})
139+
.then(() => onAppliedRef.current());
129140

130141
return () => {
131142
cancel = true;
@@ -142,7 +153,11 @@ export const usePersistedDevicePreferences = (
142153
key: string = '@stream-io/device-preferences',
143154
) => {
144155
const shouldPersistRef = useRef(false);
145-
useApplyDevicePreferences(key, () => (shouldPersistRef.current = true));
156+
useApplyDevicePreferences(
157+
key,
158+
() => (shouldPersistRef.current = false),
159+
() => (shouldPersistRef.current = true),
160+
);
146161
usePersistDevicePreferences(key, shouldPersistRef);
147162
};
148163

packages/react-sdk/src/utilities/callControlHandler.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ export type PropsWithErrorHandler<T = unknown> = T & {
1616
* @param props component props, including the onError callback
1717
* @param handler event handler to wrap
1818
*/
19-
export const createCallControlHandler = (
19+
export const createCallControlHandler = <P extends unknown[]>(
2020
props: PropsWithErrorHandler,
21-
handler: () => Promise<void>,
21+
handler: (...args: P) => Promise<void>,
2222
): (() => Promise<void>) => {
2323
const logger = getLogger(['react-sdk']);
2424

25-
return async () => {
25+
return async (...args: P) => {
2626
try {
27-
await handler();
27+
await handler(...args);
2828
} catch (error) {
2929
if (props.onError) {
3030
props.onError(error);

0 commit comments

Comments
 (0)