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; +}