Skip to content

Commit

Permalink
fix: various device selector issues (#1541)
Browse files Browse the repository at this point in the history
This PR fixes a bunch of device selector issues.

## Invalid default device indicator

In some of our device selector components, we assumed that the first
device in the list was the default. This isn't always true. So now, if
we don't know what the default device is, we don't mark any device as
selected, which is more "honest" behavior.


![image](https://github.com/user-attachments/assets/ae815782-5950-4845-a338-95cabc9d35d5)

This PR also ensures that device selectors always fall back to the
"Default" option if the current device is not found in the device list
(e.g. because of stale persisted device preferences) or the device list
is empty (e.g. if there's no browser permission).


![image](https://github.com/user-attachments/assets/05c132fa-d581-4ca8-964d-476c22d60b0c)


![image](https://github.com/user-attachments/assets/ca36a120-e178-4531-bb5e-a487be7bb1ba)

## Older Firefox cannot query permissions

Up until version 131 (incl.) there was no way to query camera and
microphone permissions with `navigator.permissions.query`. We now assume
that permission status is `prompt` in this case, and just try getting
user media. If it works, permission is granted, if not - it’s denied.

## Firefox doesn't show device labels without `getUserMedia`

When enumerating devices, Firefox will hide device labels unless there’s
been an active user media stream on the page. So even when camera and
microphone permissions are `granted`, device labels are still hidden
until there’s been a successful call to `getUserMedia`. Now we force
device list updates after every successful `getUserMedia` call.

## Safari doesn't fire permission change events reliably

Safari doesn't fire camera and microphone permission change events for
reliably. For example, the event doesn't fire if the user denies
camera/microphone access in the initial prompt. As a result, we didn't
reliably display missing permission indicators in Safari.

We now update browser permission state not only from permission change
events, but also based on `NotAllowedError` and successful attempts to
obtain the user's media stream.
  • Loading branch information
myandrienko authored Oct 30, 2024
1 parent 84d49b3 commit f23618b
Show file tree
Hide file tree
Showing 4 changed files with 38 additions and 56 deletions.
4 changes: 3 additions & 1 deletion packages/client/src/devices/BrowserPermission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export class BrowserPermission {

this.ready = (async () => {
const assumeGranted = (error?: unknown) => {
this.setState('granted');
this.setState('prompt');
};

if (!canQueryPermissions()) {
Expand Down Expand Up @@ -88,12 +88,14 @@ export class BrowserPermission {
this.permission.constraints,
);
disposeOfMediaStream(stream);
this.setState('granted');
return true;
} catch (e) {
if (e instanceof DOMException && e.name === 'NotAllowedError') {
this.logger('info', 'Browser permission was not granted', {
permission: this.permission,
});
this.setState('denied');

if (throwOnNotAllowed) {
throw e;
Expand Down
25 changes: 19 additions & 6 deletions packages/client/src/devices/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { getLogger } from '../logger';
import { BrowserPermission } from './BrowserPermission';
import { lazy } from '../helpers/lazy';
import { isFirefox } from '../helpers/browsers';

/**
* Returns an Observable that emits the list of available devices
Expand All @@ -32,7 +33,12 @@ const getDevices = (permission: BrowserPermission, kind: MediaDeviceKind) => {
await permission.prompt({ throwOnNotAllowed: true });
devices = await navigator.mediaDevices.enumerateDevices();
}
return devices.filter((d) => d.kind === kind);
return devices.filter(
(device) =>
device.kind === kind &&
device.label !== '' &&
device.deviceId !== 'default',
);
})(),
);
};
Expand Down Expand Up @@ -125,7 +131,7 @@ export const getAudioDevices = lazy(() => {
* if devices are added/removed the list is updated, and if the permission is revoked,
* the observable errors.
*/
export const getVideoDevices = () => {
export const getVideoDevices = lazy(() => {
return merge(
getDeviceChangeObserver(),
getVideoBrowserPermission().asObservable(),
Expand All @@ -134,15 +140,15 @@ export const getVideoDevices = () => {
concatMap(() => getDevices(getVideoBrowserPermission(), 'videoinput')),
shareReplay(1),
);
};
});

/**
* Prompts the user for a permission to use video devices (if not already granted
* and was not prompted before) and lists the available 'audiooutput' devices,
* if devices are added/removed the list is updated, and if the permission is revoked,
* the observable errors.
*/
export const getAudioOutputDevices = () => {
export const getAudioOutputDevices = lazy(() => {
return merge(
getDeviceChangeObserver(),
getAudioBrowserPermission().asObservable(),
Expand All @@ -151,10 +157,17 @@ export const getAudioOutputDevices = () => {
concatMap(() => getDevices(getAudioBrowserPermission(), 'audiooutput')),
shareReplay(1),
);
};
});

const getStream = async (constraints: MediaStreamConstraints) => {
return await navigator.mediaDevices.getUserMedia(constraints);
const stream = await navigator.mediaDevices.getUserMedia(constraints);
if (isFirefox()) {
// When enumerating devices, Firefox will hide device labels unless there's been
// an active user media stream on the page. So we force device list updates after
// every successful getUserMedia call.
navigator.mediaDevices.dispatchEvent(new Event('devicechange'));
}
return stream;
};

/**
Expand Down
64 changes: 15 additions & 49 deletions packages/react-sdk/src/components/DeviceSettings/DeviceSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ChangeEventHandler, useCallback } from 'react';

import { DropDownSelect, DropDownSelectOption } from '../DropdownSelect';
import { useMenuContext } from '../Menu';
import { useI18n } from '@stream-io/video-react-bindings';

type DeviceSelectorOptionProps = {
id: string;
Expand Down Expand Up @@ -57,26 +58,9 @@ const DeviceSelectorList = (props: {
title?: string;
onChange?: (deviceId: string) => void;
}) => {
const {
devices = [],
selectedDeviceId: selectedDeviceFromProps,
title,
type,
onChange,
} = props;

const { devices = [], selectedDeviceId, title, type, onChange } = props;
const { close } = useMenuContext();

// sometimes the browser (Chrome) will report the system-default device
// with an id of 'default'. In case when it doesn't, we'll select the first
// available device.
let selectedDeviceId = selectedDeviceFromProps;
if (
devices.length > 0 &&
!devices.find((d) => d.deviceId === selectedDeviceId)
) {
selectedDeviceId = devices[0].deviceId;
}
const { t } = useI18n();

return (
<div className="str-video__device-settings__device-kind">
Expand All @@ -85,10 +69,10 @@ const DeviceSelectorList = (props: {
{title}
</div>
)}
{!devices.length ? (
{devices.length === 0 ? (
<DeviceSelectorOption
id={`${type}--default`}
label="Default"
label={t('Default')}
name={type}
defaultChecked
value="default"
Expand Down Expand Up @@ -122,28 +106,10 @@ const DeviceSelectorDropdown = (props: {
selectedDeviceId?: string;
title?: string;
onChange?: (deviceId: string) => void;
visualType?: 'list' | 'dropdown';
icon: string;
placeholder?: string;
}) => {
const {
devices = [],
selectedDeviceId: selectedDeviceFromProps,
title,
onChange,
icon,
} = props;

// sometimes the browser (Chrome) will report the system-default device
// with an id of 'default'. In case when it doesn't, we'll select the first
// available device.
let selectedDeviceId = selectedDeviceFromProps;
if (
devices.length > 0 &&
!devices.find((d) => d.deviceId === selectedDeviceId)
) {
selectedDeviceId = devices[0].deviceId;
}
const { devices = [], selectedDeviceId, title, onChange, icon } = props;
const { t } = useI18n();

const selectedIndex = devices.findIndex(
(d) => d.deviceId === selectedDeviceId,
Expand All @@ -164,11 +130,13 @@ const DeviceSelectorDropdown = (props: {
<DropDownSelect
icon={icon}
defaultSelectedIndex={selectedIndex}
defaultSelectedLabel={devices[selectedIndex]?.label}
defaultSelectedLabel={devices[selectedIndex]?.label ?? t('Default')}
handleSelect={handleSelect}
>
{devices.map((device) => {
return (
{devices.length === 0 ? (
<DropDownSelectOption icon={icon} label={t('Default')} selected />
) : (
devices.map((device) => (
<DropDownSelectOption
key={device.deviceId}
icon={icon}
Expand All @@ -177,8 +145,8 @@ const DeviceSelectorDropdown = (props: {
device.deviceId === selectedDeviceId || devices.length === 1
}
/>
);
})}
))
)}
</DropDownSelect>
</div>
);
Expand All @@ -199,7 +167,5 @@ export const DeviceSelector = (props: {
if (visualType === 'list') {
return <DeviceSelectorList {...rest} />;
}
return (
<DeviceSelectorDropdown {...rest} icon={icon} placeholder={placeholder} />
);
return <DeviceSelectorDropdown {...rest} icon={icon} />;
};
1 change: 1 addition & 0 deletions packages/react-sdk/src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"Me": "Me",
"Unknown": "Unknown",
"Toggle device menu": "Toggle device menu",
"Default": "Default",
"Call Recordings": "Call Recordings",
"Refresh": "Refresh",
"Check your browser video permissions": "Check your browser video permissions",
Expand Down

0 comments on commit f23618b

Please sign in to comment.