diff --git a/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.test.tsx b/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.test.tsx
index ef7af9af1..f1bce28e1 100644
--- a/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.test.tsx
+++ b/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.test.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import AudioInputList from './AudioInputList';
+import { DEFAULT_VIDEO_CONSTRAINTS, SELECTED_AUDIO_INPUT_KEY } from '../../../constants';
import { Select, Typography } from '@material-ui/core';
import { shallow } from 'enzyme';
import { useAudioInputDevices } from '../../../hooks/deviceHooks/deviceHooks';
@@ -23,6 +24,7 @@ const mockLocalTrack = {
label: 'mock local audio track',
getSettings: () => ({ deviceId: '234' }),
},
+ restart: jest.fn(),
};
mockUseVideoContext.mockImplementation(() => ({
@@ -71,4 +73,21 @@ describe('the AudioInputList component', () => {
.exists()
).toBe(false);
});
+
+ it('should save the deviceId in localStorage when the audio input device is changed', () => {
+ mockUseAudioInputDevices.mockImplementation(() => [mockDevice, mockDevice]);
+ const wrapper = shallow();
+ expect(window.localStorage.getItem(SELECTED_AUDIO_INPUT_KEY)).toBe(undefined);
+ wrapper.find(Select).simulate('change', { target: { value: 'mockDeviceID' } });
+ expect(window.localStorage.getItem(SELECTED_AUDIO_INPUT_KEY)).toBe('mockDeviceID');
+ });
+
+ it('should call track.restart with the new deviceId when the audio input device is changed', () => {
+ mockUseAudioInputDevices.mockImplementation(() => [mockDevice, mockDevice]);
+ const wrapper = shallow();
+ wrapper.find(Select).simulate('change', { target: { value: 'mockDeviceID' } });
+ expect(mockLocalTrack.restart).toHaveBeenCalledWith({
+ deviceId: { exact: 'mockDeviceID' },
+ });
+ });
});
diff --git a/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.tsx b/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.tsx
index 1a68932a4..ac1cb5f02 100644
--- a/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.tsx
+++ b/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import AudioLevelIndicator from '../../AudioLevelIndicator/AudioLevelIndicator';
import { LocalAudioTrack } from 'twilio-video';
import { FormControl, MenuItem, Typography, Select, Grid } from '@material-ui/core';
+import { SELECTED_AUDIO_INPUT_KEY } from '../../../constants';
import { useAudioInputDevices } from '../../../hooks/deviceHooks/deviceHooks';
import useMediaStreamTrack from '../../../hooks/useMediaStreamTrack/useMediaStreamTrack';
import useVideoContext from '../../../hooks/useVideoContext/useVideoContext';
@@ -15,6 +16,7 @@ export default function AudioInputList() {
const localAudioInputDeviceId = mediaStreamTrack?.getSettings().deviceId;
function replaceTrack(newDeviceId: string) {
+ window.localStorage.setItem(SELECTED_AUDIO_INPUT_KEY, newDeviceId);
localAudioTrack?.restart({ deviceId: { exact: newDeviceId } });
}
diff --git a/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.test.tsx b/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.test.tsx
index d35817f00..6387316da 100644
--- a/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.test.tsx
+++ b/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.test.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { DEFAULT_VIDEO_CONSTRAINTS, SELECTED_VIDEO_INPUT_KEY } from '../../../constants';
import { Select, Typography } from '@material-ui/core';
import { shallow } from 'enzyme';
import useVideoContext from '../../../hooks/useVideoContext/useVideoContext';
@@ -23,6 +24,7 @@ const mockLocalTrack = {
label: 'mock local video track',
getSettings: () => ({ deviceId: '234' }),
},
+ restart: jest.fn(),
};
mockUseVideoContext.mockImplementation(() => ({
@@ -32,6 +34,11 @@ mockUseVideoContext.mockImplementation(() => ({
}));
describe('the VideoInputList component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ window.localStorage.clear();
+ });
+
describe('with only one video input device', () => {
it('should not display a Select menu and instead display the name of the local video track', () => {
mockUseVideoInputDevices.mockImplementation(() => [mockDevice]);
@@ -73,4 +80,22 @@ describe('the VideoInputList component', () => {
.exists()
).toBe(false);
});
+
+ it('should save the deviceId in localStorage when the video input device is changed', () => {
+ mockUseVideoInputDevices.mockImplementation(() => [mockDevice, mockDevice]);
+ const wrapper = shallow();
+ expect(window.localStorage.getItem(SELECTED_VIDEO_INPUT_KEY)).toBe(undefined);
+ wrapper.find(Select).simulate('change', { target: { value: 'mockDeviceID' } });
+ expect(window.localStorage.getItem(SELECTED_VIDEO_INPUT_KEY)).toBe('mockDeviceID');
+ });
+
+ it('should call track.restart with the new deviceId when the video input device is changed', () => {
+ mockUseVideoInputDevices.mockImplementation(() => [mockDevice, mockDevice]);
+ const wrapper = shallow();
+ wrapper.find(Select).simulate('change', { target: { value: 'mockDeviceID' } });
+ expect(mockLocalTrack.restart).toHaveBeenCalledWith({
+ ...(DEFAULT_VIDEO_CONSTRAINTS as {}),
+ deviceId: { exact: 'mockDeviceID' },
+ });
+ });
});
diff --git a/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.tsx b/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.tsx
index 405158551..aaed5ee88 100644
--- a/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.tsx
+++ b/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { DEFAULT_VIDEO_CONSTRAINTS } from '../../../constants';
+import { DEFAULT_VIDEO_CONSTRAINTS, SELECTED_VIDEO_INPUT_KEY } from '../../../constants';
import { FormControl, MenuItem, Typography, Select } from '@material-ui/core';
import { LocalVideoTrack } from 'twilio-video';
import { makeStyles } from '@material-ui/core/styles';
@@ -29,6 +29,7 @@ export default function VideoInputList() {
const localVideoInputDeviceId = mediaStreamTrack?.getSettings().deviceId;
function replaceTrack(newDeviceId: string) {
+ window.localStorage.setItem(SELECTED_VIDEO_INPUT_KEY, newDeviceId);
localVideoTrack.restart({
...(DEFAULT_VIDEO_CONSTRAINTS as {}),
deviceId: { exact: newDeviceId },
diff --git a/src/components/PreJoinScreens/DeviceSelectionScreen/SettingsMenu/SettingsMenu.tsx b/src/components/PreJoinScreens/DeviceSelectionScreen/SettingsMenu/SettingsMenu.tsx
index 04bc783f2..24c0210d6 100644
--- a/src/components/PreJoinScreens/DeviceSelectionScreen/SettingsMenu/SettingsMenu.tsx
+++ b/src/components/PreJoinScreens/DeviceSelectionScreen/SettingsMenu/SettingsMenu.tsx
@@ -54,6 +54,7 @@ export default function SettingsMenu({ mobileButtonClass }: { mobileButtonClass?
open={menuOpen}
onClose={() => setMenuOpen(isOpen => !isOpen)}
anchorEl={anchorRef.current}
+ getContentAnchorEl={null}
anchorOrigin={{
vertical: 'bottom',
horizontal: isMobile ? 'left' : 'right',
diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx
index bc376dff3..ae1fd8267 100644
--- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx
+++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx
@@ -1,21 +1,73 @@
import { act, renderHook } from '@testing-library/react-hooks';
+import { SELECTED_AUDIO_INPUT_KEY, SELECTED_VIDEO_INPUT_KEY } from '../../../constants';
import useLocalTracks from './useLocalTracks';
import Video from 'twilio-video';
-import { useHasAudioInputDevices, useHasVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks';
+import { useAudioInputDevices, useVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks';
jest.mock('../../../hooks/deviceHooks/deviceHooks');
-const mockUseHasVideoInputDevices = useHasVideoInputDevices as jest.Mock;
-const mockUseHasAudioInputDevices = useHasAudioInputDevices as jest.Mock;
-
-mockUseHasAudioInputDevices.mockImplementation(() => true);
-mockUseHasVideoInputDevices.mockImplementation(() => true);
+const mockUseAudioInputDevices = useAudioInputDevices as jest.Mock;
+const mockUseVideoInputDevices = useVideoInputDevices as jest.Mock;
describe('the useLocalTracks hook', () => {
+ beforeEach(() => {
+ Date.now = () => 123456;
+ mockUseAudioInputDevices.mockImplementation(() => [{ deviceId: 'mockAudioDeviceId' }]);
+ mockUseVideoInputDevices.mockImplementation(() => [{ deviceId: 'mockVideoDeviceId' }]);
+ });
afterEach(jest.clearAllMocks);
+ afterEach(() => window.localStorage.clear());
describe('the getAudioAndVideoTracks function', () => {
it('should create local audio and video tracks', async () => {
- Date.now = () => 123456;
+ const { result, waitForNextUpdate } = renderHook(useLocalTracks);
+
+ await act(async () => {
+ result.current.getAudioAndVideoTracks();
+ await waitForNextUpdate();
+ });
+
+ expect(Video.createLocalTracks).toHaveBeenCalledWith({
+ audio: true,
+ video: {
+ frameRate: 24,
+ width: 1280,
+ height: 720,
+ name: 'camera-123456',
+ },
+ });
+ });
+
+ it('should correctly create local audio and video tracks when selected device IDs are available in localStorage', async () => {
+ window.localStorage.setItem(SELECTED_VIDEO_INPUT_KEY, 'mockVideoDeviceId');
+ window.localStorage.setItem(SELECTED_AUDIO_INPUT_KEY, 'mockAudioDeviceId');
+ const { result, waitForNextUpdate } = renderHook(useLocalTracks);
+
+ await act(async () => {
+ result.current.getAudioAndVideoTracks();
+ await waitForNextUpdate();
+ });
+
+ expect(Video.createLocalTracks).toHaveBeenCalledWith({
+ audio: {
+ deviceId: {
+ exact: 'mockAudioDeviceId',
+ },
+ },
+ video: {
+ frameRate: 24,
+ width: 1280,
+ height: 720,
+ name: 'camera-123456',
+ deviceId: {
+ exact: 'mockVideoDeviceId',
+ },
+ },
+ });
+ });
+
+ it('should correctly create local audio and video tracks when selected devices IDs are available in localStorage, but do not correspond to actual devices', async () => {
+ window.localStorage.setItem(SELECTED_VIDEO_INPUT_KEY, 'otherMockVideoDeviceId');
+ window.localStorage.setItem(SELECTED_AUDIO_INPUT_KEY, 'otherMockAudioDeviceId');
const { result, waitForNextUpdate } = renderHook(useLocalTracks);
await act(async () => {
@@ -35,7 +87,7 @@ describe('the useLocalTracks hook', () => {
});
it('should create a local audio track when no video devices are present', async () => {
- mockUseHasVideoInputDevices.mockImplementationOnce(() => false);
+ mockUseVideoInputDevices.mockImplementation(() => []);
const { result, waitForNextUpdate } = renderHook(useLocalTracks);
@@ -51,7 +103,7 @@ describe('the useLocalTracks hook', () => {
});
it('should create a local video track when no audio devices are present', async () => {
- mockUseHasAudioInputDevices.mockImplementationOnce(() => false);
+ mockUseAudioInputDevices.mockImplementation(() => []);
const { result, waitForNextUpdate } = renderHook(useLocalTracks);
@@ -72,8 +124,8 @@ describe('the useLocalTracks hook', () => {
});
it('should not create any tracks when no input devices are present', async () => {
- mockUseHasAudioInputDevices.mockImplementationOnce(() => false);
- mockUseHasVideoInputDevices.mockImplementationOnce(() => false);
+ mockUseAudioInputDevices.mockImplementation(() => []);
+ mockUseVideoInputDevices.mockImplementation(() => []);
const { result } = renderHook(useLocalTracks);
diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts
index 58d71dcd0..b93052f5e 100644
--- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts
+++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts
@@ -1,15 +1,18 @@
-import { DEFAULT_VIDEO_CONSTRAINTS } from '../../../constants';
+import { DEFAULT_VIDEO_CONSTRAINTS, SELECTED_AUDIO_INPUT_KEY, SELECTED_VIDEO_INPUT_KEY } from '../../../constants';
import { useCallback, useState } from 'react';
import Video, { LocalVideoTrack, LocalAudioTrack, CreateLocalTrackOptions } from 'twilio-video';
-import { useHasAudioInputDevices, useHasVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks';
+import { useAudioInputDevices, useVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks';
export default function useLocalTracks() {
const [audioTrack, setAudioTrack] = useState();
const [videoTrack, setVideoTrack] = useState();
const [isAcquiringLocalTracks, setIsAcquiringLocalTracks] = useState(false);
- const hasAudio = useHasAudioInputDevices();
- const hasVideo = useHasVideoInputDevices();
+ const localAudioDevices = useAudioInputDevices();
+ const localVideoDevices = useVideoInputDevices();
+
+ const hasAudio = localAudioDevices.length > 0;
+ const hasVideo = localVideoDevices.length > 0;
const getLocalAudioTrack = useCallback((deviceId?: string) => {
const options: CreateLocalTrackOptions = {};
@@ -49,13 +52,27 @@ export default function useLocalTracks() {
if (audioTrack || videoTrack) return Promise.resolve();
setIsAcquiringLocalTracks(true);
- return Video.createLocalTracks({
+
+ const selectedAudioDeviceId = window.localStorage.getItem(SELECTED_AUDIO_INPUT_KEY);
+ const selectedVideoDeviceId = window.localStorage.getItem(SELECTED_VIDEO_INPUT_KEY);
+
+ const hasSelectedAudioDevice = localAudioDevices.some(
+ device => selectedAudioDeviceId && device.deviceId === selectedAudioDeviceId
+ );
+ const hasSelectedVideoDevice = localVideoDevices.some(
+ device => selectedVideoDeviceId && device.deviceId === selectedVideoDeviceId
+ );
+
+ const localTrackConstraints = {
video: hasVideo && {
...(DEFAULT_VIDEO_CONSTRAINTS as {}),
name: `camera-${Date.now()}`,
+ ...(hasSelectedVideoDevice && { deviceId: { exact: selectedVideoDeviceId! } }),
},
- audio: hasAudio,
- })
+ audio: hasSelectedAudioDevice ? { deviceId: { exact: selectedAudioDeviceId! } } : hasAudio,
+ };
+
+ return Video.createLocalTracks(localTrackConstraints)
.then(tracks => {
const videoTrack = tracks.find(track => track.kind === 'video');
const audioTrack = tracks.find(track => track.kind === 'audio');
@@ -67,7 +84,7 @@ export default function useLocalTracks() {
}
})
.finally(() => setIsAcquiringLocalTracks(false));
- }, [hasAudio, hasVideo, audioTrack, videoTrack]);
+ }, [hasAudio, hasVideo, audioTrack, videoTrack, localAudioDevices, localVideoDevices]);
const localTracks = [audioTrack, videoTrack].filter(track => track !== undefined) as (
| LocalAudioTrack
diff --git a/src/constants.ts b/src/constants.ts
index bed16a176..6aa0199f5 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -3,3 +3,8 @@ export const DEFAULT_VIDEO_CONSTRAINTS: MediaStreamConstraints['video'] = {
height: 720,
frameRate: 24,
};
+
+// These are used to store the selected media devices in localStorage
+export const SELECTED_AUDIO_INPUT_KEY = 'TwilioVideoApp-selectedAudioInput';
+export const SELECTED_AUDIO_OUTPUT_KEY = 'TwilioVideoApp-selectedAudioOutput';
+export const SELECTED_VIDEO_INPUT_KEY = 'TwilioVideoApp-selectedVideoInput';
diff --git a/src/setupTests.ts b/src/setupTests.ts
index 627020952..63fa43b4e 100644
--- a/src/setupTests.ts
+++ b/src/setupTests.ts
@@ -6,3 +6,21 @@ configure({ adapter: new Adapter() });
// Mocks the Fullscreen API. This is needed for ToggleFullScreenButton.test.tsx.
Object.defineProperty(document, 'fullscreenEnabled', { value: true, writable: true });
+
+class LocalStorage {
+ store = {} as { [key: string]: string };
+
+ getItem(key: string) {
+ return this.store[key];
+ }
+
+ setItem(key: string, value: string) {
+ this.store[key] = value;
+ }
+
+ clear() {
+ this.store = {} as { [key: string]: string };
+ }
+}
+
+Object.defineProperty(window, 'localStorage', { value: new LocalStorage() });
diff --git a/src/state/index.test.tsx b/src/state/index.test.tsx
index ede519a74..fa7577c4d 100644
--- a/src/state/index.test.tsx
+++ b/src/state/index.test.tsx
@@ -8,6 +8,7 @@ import usePasscodeAuth from './usePasscodeAuth/usePasscodeAuth';
jest.mock('./useFirebaseAuth/useFirebaseAuth', () => jest.fn(() => ({ user: 'firebaseUser' })));
jest.mock('./usePasscodeAuth/usePasscodeAuth', () => jest.fn(() => ({ user: 'passcodeUser' })));
+jest.mock('./useActiveSinkId/useActiveSinkId.ts', () => () => ['default', () => {}]);
const mockUsePasscodeAuth = usePasscodeAuth as jest.Mock;
diff --git a/src/state/index.tsx b/src/state/index.tsx
index 6fc7bc679..244dd9ef6 100644
--- a/src/state/index.tsx
+++ b/src/state/index.tsx
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useReducer, useState } from 'react';
import { RoomType } from '../types';
import { TwilioError } from 'twilio-video';
import { settingsReducer, initialSettings, Settings, SettingsAction } from './settings/settingsReducer';
+import useActiveSinkId from './useActiveSinkId/useActiveSinkId';
import useFirebaseAuth from './useFirebaseAuth/useFirebaseAuth';
import usePasscodeAuth from './usePasscodeAuth/usePasscodeAuth';
import { User } from 'firebase';
@@ -36,7 +37,7 @@ export const StateContext = createContext(null!);
export default function AppStateProvider(props: React.PropsWithChildren<{}>) {
const [error, setError] = useState(null);
const [isFetching, setIsFetching] = useState(false);
- const [activeSinkId, setActiveSinkId] = useState('default');
+ const [activeSinkId, setActiveSinkId] = useActiveSinkId();
const [settings, dispatchSetting] = useReducer(settingsReducer, initialSettings);
let contextValue = {
diff --git a/src/state/useActiveSinkId/useActiveSinkId.test.ts b/src/state/useActiveSinkId/useActiveSinkId.test.ts
new file mode 100644
index 000000000..6653e3a7f
--- /dev/null
+++ b/src/state/useActiveSinkId/useActiveSinkId.test.ts
@@ -0,0 +1,46 @@
+import { act, renderHook } from '@testing-library/react-hooks';
+import { SELECTED_AUDIO_OUTPUT_KEY } from '../../constants';
+import useActiveSinkId from './useActiveSinkId';
+import { useAudioOutputDevices } from '../../hooks/deviceHooks/deviceHooks';
+
+jest.mock('../../hooks/deviceHooks/deviceHooks');
+const mockUseAudioOutputDevices = useAudioOutputDevices as jest.Mock;
+
+mockUseAudioOutputDevices.mockImplementation(() => []);
+
+describe('the useActiveSinkId hook', () => {
+ beforeEach(() => window.localStorage.clear());
+
+ it('should return "default" by default', () => {
+ const { result } = renderHook(useActiveSinkId);
+ expect(result.current[0]).toBe('default');
+ });
+
+ it('should return the saved device ID when a corresponding device exists', () => {
+ window.localStorage.setItem(SELECTED_AUDIO_OUTPUT_KEY, 'mockAudioOutputDeviceID');
+ const { result, rerender } = renderHook(useActiveSinkId);
+
+ mockUseAudioOutputDevices.mockImplementationOnce(() => [{ deviceId: 'mockAudioOutputDeviceID' }]);
+ rerender();
+
+ expect(result.current[0]).toBe('mockAudioOutputDeviceID');
+ });
+
+ it('should return "default" when there is a saved device ID but a corresponding device does not exist', () => {
+ window.localStorage.setItem(SELECTED_AUDIO_OUTPUT_KEY, 'anotherMockAudioOutputDeviceID');
+ const { result, rerender } = renderHook(useActiveSinkId);
+
+ mockUseAudioOutputDevices.mockImplementationOnce(() => [{ deviceId: 'mockAudioOutputDeviceID' }]);
+ rerender();
+
+ expect(result.current[0]).toBe('default');
+ });
+
+ it('should save the device ID in localStorage when it is set', () => {
+ const { result } = renderHook(useActiveSinkId);
+ act(() => {
+ result.current[1]('newMockAudioOutputDeviceID');
+ });
+ expect(window.localStorage.getItem(SELECTED_AUDIO_OUTPUT_KEY)).toBe('newMockAudioOutputDeviceID');
+ });
+});
diff --git a/src/state/useActiveSinkId/useActiveSinkId.ts b/src/state/useActiveSinkId/useActiveSinkId.ts
new file mode 100644
index 000000000..ed87c60ad
--- /dev/null
+++ b/src/state/useActiveSinkId/useActiveSinkId.ts
@@ -0,0 +1,28 @@
+import { useCallback, useEffect, useState } from 'react';
+import { SELECTED_AUDIO_OUTPUT_KEY } from '../../constants';
+import { useAudioOutputDevices } from '../../hooks/deviceHooks/deviceHooks';
+
+export default function useActiveSinkId() {
+ const audioOutputDevices = useAudioOutputDevices();
+ const [activeSinkId, _setActiveSinkId] = useState('default');
+
+ const setActiveSinkId = useCallback(
+ (sinkId: string) => {
+ window.localStorage.setItem(SELECTED_AUDIO_OUTPUT_KEY, sinkId);
+ _setActiveSinkId(sinkId);
+ },
+ [_setActiveSinkId]
+ );
+
+ useEffect(() => {
+ const selectedSinkId = window.localStorage.getItem(SELECTED_AUDIO_OUTPUT_KEY);
+ const hasSelectedAudioOutputDevice = audioOutputDevices.some(
+ device => selectedSinkId && device.deviceId === selectedSinkId
+ );
+ if (hasSelectedAudioOutputDevice) {
+ _setActiveSinkId(selectedSinkId!);
+ }
+ }, [audioOutputDevices]);
+
+ return [activeSinkId, setActiveSinkId] as const;
+}