From d4807028dcddda410a2350c48400521c65883804 Mon Sep 17 00:00:00 2001 From: Tim Mendoza Date: Tue, 19 Jan 2021 11:48:39 -0700 Subject: [PATCH 1/4] Add removeLocalAudioTrack function to useLocalTracks --- src/components/VideoProvider/index.tsx | 3 +++ .../useLocalTracks/useLocalTracks.ts | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/VideoProvider/index.tsx b/src/components/VideoProvider/index.tsx index fc54a94f1..6c4708cff 100644 --- a/src/components/VideoProvider/index.tsx +++ b/src/components/VideoProvider/index.tsx @@ -35,6 +35,7 @@ export interface IVideoContext { getLocalVideoTrack: (newOptions?: CreateLocalTrackOptions) => Promise; getLocalAudioTrack: (deviceId?: string) => Promise; isAcquiringLocalTracks: boolean; + removeLocalAudioTrack: () => void; removeLocalVideoTrack: () => void; isSharingScreen: boolean; toggleScreenShare: () => void; @@ -61,6 +62,7 @@ export function VideoProvider({ options, children, onError = () => {}, onDisconn getLocalVideoTrack, getLocalAudioTrack, isAcquiringLocalTracks, + removeLocalAudioTrack, removeLocalVideoTrack, getAudioAndVideoTracks, } = useLocalTracks(); @@ -84,6 +86,7 @@ export function VideoProvider({ options, children, onError = () => {}, onDisconn getLocalAudioTrack, connect, isAcquiringLocalTracks, + removeLocalAudioTrack, removeLocalVideoTrack, isSharingScreen, toggleScreenShare, diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts index fc70580bd..ec5412b22 100644 --- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts +++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts @@ -1,15 +1,16 @@ 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 { useAudioInputDevices, useVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks'; +import { useDevices } from '../../../hooks/deviceHooks/deviceHooks'; export default function useLocalTracks() { const [audioTrack, setAudioTrack] = useState(); const [videoTrack, setVideoTrack] = useState(); const [isAcquiringLocalTracks, setIsAcquiringLocalTracks] = useState(false); - const localAudioDevices = useAudioInputDevices(); - const localVideoDevices = useVideoInputDevices(); + const localDevices = useDevices(); + const localAudioDevices = localDevices.filter(device => device.kind === 'audioinput'); + const localVideoDevices = localDevices.filter(device => device.kind === 'videoinput'); const hasAudio = localAudioDevices.length > 0; const hasVideo = localVideoDevices.length > 0; @@ -40,6 +41,13 @@ export default function useLocalTracks() { }); }, []); + const removeLocalAudioTrack = useCallback(() => { + if (audioTrack) { + audioTrack.stop(); + setAudioTrack(undefined); + } + }, [audioTrack]); + const removeLocalVideoTrack = useCallback(() => { if (videoTrack) { videoTrack.stop(); @@ -96,6 +104,7 @@ export default function useLocalTracks() { getLocalVideoTrack, getLocalAudioTrack, isAcquiringLocalTracks, + removeLocalAudioTrack, removeLocalVideoTrack, getAudioAndVideoTracks, }; From 034018b844dce869af3a0f0d0dd06431589b9697 Mon Sep 17 00:00:00 2001 From: Tim Mendoza Date: Tue, 19 Jan 2021 11:52:36 -0700 Subject: [PATCH 2/4] Update EndCallButton to stop all tracks on disconnect --- .../EndCallButton/EndCallButton.test.tsx | 59 +++++++++++++++++-- .../Buttons/EndCallButton/EndCallButton.tsx | 13 +++- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/components/Buttons/EndCallButton/EndCallButton.test.tsx b/src/components/Buttons/EndCallButton/EndCallButton.test.tsx index 9291a5bae..faf7769e0 100644 --- a/src/components/Buttons/EndCallButton/EndCallButton.test.tsx +++ b/src/components/Buttons/EndCallButton/EndCallButton.test.tsx @@ -3,13 +3,60 @@ import { shallow } from 'enzyme'; import EndCallButton from './EndCallButton'; -const mockRoom: any = { disconnect: jest.fn() }; -jest.mock('../../../hooks/useVideoContext/useVideoContext', () => () => ({ room: mockRoom })); +const mockVideoContext = { + room: { + disconnect: jest.fn(), + }, + isSharingScreen: false, + toggleScreenShare: jest.fn(), + removeLocalAudioTrack: jest.fn(), + removeLocalVideoTrack: jest.fn(), +}; + +jest.mock('../../../hooks/useVideoContext/useVideoContext', () => () => mockVideoContext); describe('End Call button', () => { - it('should disconnect from the room when clicked', () => { - const wrapper = shallow(); - wrapper.simulate('click'); - expect(mockRoom.disconnect).toHaveBeenCalled(); + describe('when it is clicked', () => { + describe('while sharing screen', () => { + let wrapper; + + beforeAll(() => { + jest.clearAllMocks(); + mockVideoContext.isSharingScreen = true; + wrapper = shallow(); + wrapper.simulate('click'); + }); + + it('should stop local audio tracks', () => { + expect(mockVideoContext.removeLocalAudioTrack).toHaveBeenCalled(); + }); + + it('should stop local video tracks', () => { + expect(mockVideoContext.removeLocalVideoTrack).toHaveBeenCalled(); + }); + + it('should toggle screen sharing off', () => { + expect(mockVideoContext.toggleScreenShare).toHaveBeenCalled(); + }); + + it('should disconnect from the room ', () => { + expect(mockVideoContext.room.disconnect).toHaveBeenCalled(); + }); + }); + + describe('while not sharing screen', () => { + let wrapper; + + beforeAll(() => { + jest.clearAllMocks(); + mockVideoContext.isSharingScreen = false; + wrapper = shallow(); + wrapper.simulate('click'); + }); + + it('should not toggle screen sharing', () => { + expect(mockVideoContext.toggleScreenShare).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/src/components/Buttons/EndCallButton/EndCallButton.tsx b/src/components/Buttons/EndCallButton/EndCallButton.tsx index 77dd0c2e9..df80b7b25 100644 --- a/src/components/Buttons/EndCallButton/EndCallButton.tsx +++ b/src/components/Buttons/EndCallButton/EndCallButton.tsx @@ -20,10 +20,19 @@ const useStyles = makeStyles((theme: Theme) => export default function EndCallButton(props: { className?: string }) { const classes = useStyles(); - const { room } = useVideoContext(); + const { room, isSharingScreen, toggleScreenShare, removeLocalAudioTrack, removeLocalVideoTrack } = useVideoContext(); + + const handleClick = () => { + if (isSharingScreen) { + toggleScreenShare(); + } + removeLocalAudioTrack(); + removeLocalVideoTrack(); + room.disconnect(); + }; return ( - ); From efef1b972bac708120c4707cba4ea2a17b7fafb1 Mon Sep 17 00:00:00 2001 From: Tim Mendoza Date: Tue, 19 Jan 2021 13:49:51 -0700 Subject: [PATCH 3/4] Update tests for useLocalAudioTracks --- .../useLocalTracks/useLocalTracks.test.tsx | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx index 9555f9eee..896ca8aa1 100644 --- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx +++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx @@ -2,17 +2,18 @@ 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 { useAudioInputDevices, useVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks'; +import { useDevices } from '../../../hooks/deviceHooks/deviceHooks'; jest.mock('../../../hooks/deviceHooks/deviceHooks'); -const mockUseAudioInputDevices = useAudioInputDevices as jest.Mock; -const mockUseVideoInputDevices = useVideoInputDevices as jest.Mock; +const mockUseDevices = useDevices as jest.Mock; describe('the useLocalTracks hook', () => { beforeEach(() => { Date.now = () => 123456; - mockUseAudioInputDevices.mockImplementation(() => [{ deviceId: 'mockAudioDeviceId' }]); - mockUseVideoInputDevices.mockImplementation(() => [{ deviceId: 'mockVideoDeviceId' }]); + mockUseDevices.mockImplementation(() => [ + { deviceId: 'mockAudioDeviceId', kind: 'audioinput' }, + { deviceId: 'mockVideoDeviceId', kind: 'videoinput' }, + ]); }); afterEach(jest.clearAllMocks); afterEach(() => window.localStorage.clear()); @@ -87,7 +88,7 @@ describe('the useLocalTracks hook', () => { }); it('should create a local audio track when no video devices are present', async () => { - mockUseVideoInputDevices.mockImplementation(() => []); + mockUseDevices.mockImplementation(() => [{ deviceId: 'mockAudioDeviceId', kind: 'audioinput' }]); const { result, waitForNextUpdate } = renderHook(useLocalTracks); @@ -103,7 +104,7 @@ describe('the useLocalTracks hook', () => { }); it('should create a local video track when no audio devices are present', async () => { - mockUseAudioInputDevices.mockImplementation(() => []); + mockUseDevices.mockImplementation(() => [{ deviceId: 'mockVideoDeviceId', kind: 'videoinput' }]); const { result, waitForNextUpdate } = renderHook(useLocalTracks); @@ -160,8 +161,7 @@ describe('the useLocalTracks hook', () => { }); it('should not create any tracks when no input devices are present', async () => { - mockUseAudioInputDevices.mockImplementation(() => []); - mockUseVideoInputDevices.mockImplementation(() => []); + mockUseDevices.mockImplementation(() => []); const { result } = renderHook(useLocalTracks); @@ -204,4 +204,27 @@ describe('the useLocalTracks hook', () => { expect(initialVideoTrack!.stop).toHaveBeenCalled(); }); }); + + describe('the removeLocalAudioTrack function', () => { + it('should call audioTrack.stop() and remove the audioTrack from state', async () => { + const { result, waitForNextUpdate } = renderHook(useLocalTracks); + + // First, get tracks + await act(async () => { + result.current.getAudioAndVideoTracks(); + await waitForNextUpdate(); + }); + + const initialAudioTrack = result.current.localTracks.find(track => track.kind === 'audio'); + expect(initialAudioTrack!.stop).not.toHaveBeenCalled(); + expect(initialAudioTrack).toBeTruthy(); + + act(() => { + result.current.removeLocalAudioTrack(); + }); + + expect(result.current.localTracks.some(track => track.kind === 'audio')).toBe(false); + expect(initialAudioTrack!.stop).toHaveBeenCalled(); + }); + }); }); From 7e08e181e0ca96ebcb6836939b99696a92bae3da Mon Sep 17 00:00:00 2001 From: Tim Mendoza Date: Wed, 27 Jan 2021 16:36:55 -0700 Subject: [PATCH 4/4] Redo deviceHooks file Consolidate six hooks into one, and rename file to useHooks.ts --- .../ToggleVideoButton.test.tsx | 10 +- .../ToggleVideoButton/ToggleVideoButton.tsx | 8 +- .../AudioInputList/AudioInputList.test.tsx | 18 +-- .../AudioInputList/AudioInputList.tsx | 4 +- .../AudioOutputList/AudioOutputList.test.tsx | 12 +- .../AudioOutputList/AudioOutputList.tsx | 4 +- .../VideoInputList/VideoInputList.test.tsx | 16 +-- .../VideoInputList/VideoInputList.tsx | 4 +- .../FlipCameraButton.test.tsx | 22 ++-- .../FlipCameraButton/FlipCameraButton.tsx | 6 +- .../MediaErrorSnackbar.test.tsx | 14 +-- .../MediaErrorSnackbar/MediaErrorSnackbar.tsx | 12 +- .../useLocalTracks/useLocalTracks.test.tsx | 28 +++-- .../useLocalTracks/useLocalTracks.ts | 30 ++--- src/hooks/deviceHooks/deviceHooks.test.tsx | 109 ------------------ src/hooks/deviceHooks/deviceHooks.tsx | 42 ------- src/hooks/useDevices/useDevices.test.tsx | 94 +++++++++++++++ src/hooks/useDevices/useDevices.tsx | 23 ++++ .../useActiveSinkId/useActiveSinkId.test.ts | 12 +- src/state/useActiveSinkId/useActiveSinkId.ts | 4 +- 20 files changed, 226 insertions(+), 246 deletions(-) delete mode 100644 src/hooks/deviceHooks/deviceHooks.test.tsx delete mode 100644 src/hooks/deviceHooks/deviceHooks.tsx create mode 100644 src/hooks/useDevices/useDevices.test.tsx create mode 100644 src/hooks/useDevices/useDevices.tsx diff --git a/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.test.tsx b/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.test.tsx index b12452d49..295ac742a 100644 --- a/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.test.tsx +++ b/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.test.tsx @@ -5,17 +5,17 @@ import useLocalVideoToggle from '../../../hooks/useLocalVideoToggle/useLocalVide import ToggleVideoButton from './ToggleVideoButton'; import VideoOffIcon from '../../../icons/VideoOffIcon'; import VideoOnIcon from '../../../icons/VideoOnIcon'; -import { useHasVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks'; +import useDevices from '../../../hooks/useDevices/useDevices'; -jest.mock('../../../hooks/deviceHooks/deviceHooks'); +jest.mock('../../../hooks/useDevices/useDevices'); jest.mock('../../../hooks/useLocalVideoToggle/useLocalVideoToggle'); const mockUseLocalVideoToggle = useLocalVideoToggle as jest.Mock; -const mockUseHasVideoInputDevices = useHasVideoInputDevices as jest.Mock; +const mockUseDevices = useDevices as jest.Mock; describe('the ToggleVideoButton component', () => { beforeAll(() => { - mockUseHasVideoInputDevices.mockImplementation(() => true); + mockUseDevices.mockImplementation(() => ({ hasVideoInputDevices: true })); }); it('should render correctly when video is enabled', () => { @@ -34,7 +34,7 @@ describe('the ToggleVideoButton component', () => { it('should render correctly when no video devices exist', () => { mockUseLocalVideoToggle.mockImplementation(() => [true, () => {}]); - mockUseHasVideoInputDevices.mockImplementationOnce(() => false); + mockUseDevices.mockImplementationOnce(() => ({ hasVideoInputDevices: false })); const wrapper = shallow(); expect(wrapper.prop('startIcon')).toEqual(); expect(wrapper.prop('disabled')).toEqual(true); diff --git a/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.tsx b/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.tsx index bfca21aad..a630267fb 100644 --- a/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.tsx +++ b/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.tsx @@ -4,13 +4,13 @@ import Button from '@material-ui/core/Button'; import VideoOffIcon from '../../../icons/VideoOffIcon'; import VideoOnIcon from '../../../icons/VideoOnIcon'; -import { useHasVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks'; +import useDevices from '../../../hooks/useDevices/useDevices'; import useLocalVideoToggle from '../../../hooks/useLocalVideoToggle/useLocalVideoToggle'; export default function ToggleVideoButton(props: { disabled?: boolean; className?: string }) { const [isVideoEnabled, toggleVideoEnabled] = useLocalVideoToggle(); const lastClickTimeRef = useRef(0); - const hasVideoDevices = useHasVideoInputDevices(); + const { hasVideoInputDevices } = useDevices(); const toggleVideo = useCallback(() => { if (Date.now() - lastClickTimeRef.current > 500) { @@ -23,10 +23,10 @@ export default function ToggleVideoButton(props: { disabled?: boolean; className ); } diff --git a/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.test.tsx b/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.test.tsx index f1bce28e1..4fedeb824 100644 --- a/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.test.tsx +++ b/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.test.tsx @@ -1,16 +1,16 @@ import React from 'react'; import AudioInputList from './AudioInputList'; -import { DEFAULT_VIDEO_CONSTRAINTS, SELECTED_AUDIO_INPUT_KEY } from '../../../constants'; +import { SELECTED_AUDIO_INPUT_KEY } from '../../../constants'; import { Select, Typography } from '@material-ui/core'; import { shallow } from 'enzyme'; -import { useAudioInputDevices } from '../../../hooks/deviceHooks/deviceHooks'; +import useDevices from '../../../hooks/useDevices/useDevices'; import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; jest.mock('../../../hooks/useVideoContext/useVideoContext'); -jest.mock('../../../hooks/deviceHooks/deviceHooks'); +jest.mock('../../../hooks/useDevices/useDevices'); const mockUseVideoContext = useVideoContext as jest.Mock; -const mockUseAudioInputDevices = useAudioInputDevices as jest.Mock; +const mockUseDevices = useDevices as jest.Mock; const mockGetLocalAudiotrack = jest.fn(() => Promise.resolve); const mockDevice = { @@ -35,7 +35,7 @@ mockUseVideoContext.mockImplementation(() => ({ describe('the AudioInputList component', () => { it('should display the name of the local audio track when only one is avaiable', () => { - mockUseAudioInputDevices.mockImplementation(() => [mockDevice]); + mockUseDevices.mockImplementation(() => ({ audioInputDevices: [mockDevice] })); const wrapper = shallow(); expect(wrapper.find(Select).exists()).toBe(false); expect( @@ -47,7 +47,7 @@ describe('the AudioInputList component', () => { }); it('should display "No Local Audio" when there is no local audio track', () => { - mockUseAudioInputDevices.mockImplementation(() => [mockDevice]); + mockUseDevices.mockImplementation(() => ({ audioInputDevices: [mockDevice] })); mockUseVideoContext.mockImplementationOnce(() => ({ room: {}, getLocalAudioTrack: mockGetLocalAudiotrack, @@ -63,7 +63,7 @@ describe('the AudioInputList component', () => { }); it('should render a Select menu when there are multiple audio input devices', () => { - mockUseAudioInputDevices.mockImplementation(() => [mockDevice, mockDevice]); + mockUseDevices.mockImplementation(() => ({ audioInputDevices: [mockDevice, mockDevice] })); const wrapper = shallow(); expect(wrapper.find(Select).exists()).toBe(true); expect( @@ -75,7 +75,7 @@ describe('the AudioInputList component', () => { }); it('should save the deviceId in localStorage when the audio input device is changed', () => { - mockUseAudioInputDevices.mockImplementation(() => [mockDevice, mockDevice]); + mockUseDevices.mockImplementation(() => ({ audioInputDevices: [mockDevice, mockDevice] })); const wrapper = shallow(); expect(window.localStorage.getItem(SELECTED_AUDIO_INPUT_KEY)).toBe(undefined); wrapper.find(Select).simulate('change', { target: { value: 'mockDeviceID' } }); @@ -83,7 +83,7 @@ describe('the AudioInputList component', () => { }); it('should call track.restart with the new deviceId when the audio input device is changed', () => { - mockUseAudioInputDevices.mockImplementation(() => [mockDevice, mockDevice]); + mockUseDevices.mockImplementation(() => ({ audioInputDevices: [mockDevice, mockDevice] })); const wrapper = shallow(); wrapper.find(Select).simulate('change', { target: { value: 'mockDeviceID' } }); expect(mockLocalTrack.restart).toHaveBeenCalledWith({ diff --git a/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.tsx b/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.tsx index ac1cb5f02..b82b16552 100644 --- a/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.tsx +++ b/src/components/DeviceSelectionDialog/AudioInputList/AudioInputList.tsx @@ -3,12 +3,12 @@ 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 useDevices from '../../../hooks/useDevices/useDevices'; import useMediaStreamTrack from '../../../hooks/useMediaStreamTrack/useMediaStreamTrack'; import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; export default function AudioInputList() { - const audioInputDevices = useAudioInputDevices(); + const { audioInputDevices } = useDevices(); const { localTracks } = useVideoContext(); const localAudioTrack = localTracks.find(track => track.kind === 'audio') as LocalAudioTrack; diff --git a/src/components/DeviceSelectionDialog/AudioOutputList/AudioOutputList.test.tsx b/src/components/DeviceSelectionDialog/AudioOutputList/AudioOutputList.test.tsx index a201f51e6..4f779ca73 100644 --- a/src/components/DeviceSelectionDialog/AudioOutputList/AudioOutputList.test.tsx +++ b/src/components/DeviceSelectionDialog/AudioOutputList/AudioOutputList.test.tsx @@ -2,14 +2,14 @@ import React from 'react'; import AudioOutputList from './AudioOutputList'; import { Select, Typography } from '@material-ui/core'; import { shallow } from 'enzyme'; -import { useAudioOutputDevices } from '../../../hooks/deviceHooks/deviceHooks'; +import useDevices from '../../../hooks/useDevices/useDevices'; import { useAppState } from '../../../state'; jest.mock('../../../state'); -jest.mock('../../../hooks/deviceHooks/deviceHooks'); +jest.mock('../../../hooks/useDevices/useDevices'); const mockUseAppState = useAppState as jest.Mock; -const mockUseAudioOutputDevices = useAudioOutputDevices as jest.Mock; +const mockUseDevices = useDevices as jest.Mock; mockUseAppState.mockImplementation(() => ({ activeSinkId: '123' })); @@ -20,7 +20,7 @@ const mockDevice = { describe('the AudioOutputList component', () => { it('should display the name of the active output device if only one is available', () => { - mockUseAudioOutputDevices.mockImplementation(() => [mockDevice]); + mockUseDevices.mockImplementation(() => ({ audioOutputDevices: [mockDevice] })); const wrapper = shallow(); expect(wrapper.find(Select).exists()).toBe(false); expect( @@ -32,7 +32,7 @@ describe('the AudioOutputList component', () => { }); it('should display "System Default Audio Output" when no audio output devices are available', () => { - mockUseAudioOutputDevices.mockImplementation(() => []); + mockUseDevices.mockImplementation(() => ({ audioOutputDevices: [] })); const wrapper = shallow(); expect(wrapper.find(Select).exists()).toBe(false); expect( @@ -44,7 +44,7 @@ describe('the AudioOutputList component', () => { }); it('should display a Select menu when multiple audio output devices are available', () => { - mockUseAudioOutputDevices.mockImplementation(() => [mockDevice, mockDevice]); + mockUseDevices.mockImplementation(() => ({ audioOutputDevices: [mockDevice, mockDevice] })); const wrapper = shallow(); expect(wrapper.find(Select).exists()).toBe(true); }); diff --git a/src/components/DeviceSelectionDialog/AudioOutputList/AudioOutputList.tsx b/src/components/DeviceSelectionDialog/AudioOutputList/AudioOutputList.tsx index 77beb6736..2971d911d 100644 --- a/src/components/DeviceSelectionDialog/AudioOutputList/AudioOutputList.tsx +++ b/src/components/DeviceSelectionDialog/AudioOutputList/AudioOutputList.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { FormControl, MenuItem, Typography, Select } from '@material-ui/core'; import { useAppState } from '../../../state'; -import { useAudioOutputDevices } from '../../../hooks/deviceHooks/deviceHooks'; +import useDevices from '../../../hooks/useDevices/useDevices'; export default function AudioOutputList() { - const audioOutputDevices = useAudioOutputDevices(); + const { audioOutputDevices } = useDevices(); const { activeSinkId, setActiveSinkId } = useAppState(); const activeOutputLabel = audioOutputDevices.find(device => device.deviceId === activeSinkId)?.label; diff --git a/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.test.tsx b/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.test.tsx index 6387316da..b0a0aac57 100644 --- a/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.test.tsx +++ b/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.test.tsx @@ -2,15 +2,15 @@ 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 useDevices from '../../../hooks/useDevices/useDevices'; import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; -import { useVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks'; import VideoInputList from './VideoInputList'; jest.mock('../../../hooks/useVideoContext/useVideoContext'); -jest.mock('../../../hooks/deviceHooks/deviceHooks'); +jest.mock('../../../hooks/useDevices/useDevices'); const mockUseVideoContext = useVideoContext as jest.Mock; -const mockUseVideoInputDevices = useVideoInputDevices as jest.Mock; +const mockUseDevices = useDevices as jest.Mock; const mockGetLocalVideotrack = jest.fn(() => Promise.resolve); const mockDevice = { @@ -41,7 +41,7 @@ describe('the VideoInputList component', () => { 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]); + mockUseDevices.mockImplementation(() => ({ videoInputDevices: [mockDevice] })); const wrapper = shallow(); expect(wrapper.find(Select).exists()).toBe(false); expect( @@ -53,7 +53,7 @@ describe('the VideoInputList component', () => { }); it('should display "No Local Video" when there is no local video track', () => { - mockUseVideoInputDevices.mockImplementation(() => [mockDevice]); + mockUseDevices.mockImplementation(() => ({ videoInputDevices: [mockDevice] })); mockUseVideoContext.mockImplementationOnce(() => ({ room: {}, getLocalVideoTrack: mockGetLocalVideotrack, @@ -70,7 +70,7 @@ describe('the VideoInputList component', () => { }); it('should render a Select menu when there are multiple video input devices', () => { - mockUseVideoInputDevices.mockImplementation(() => [mockDevice, mockDevice]); + mockUseDevices.mockImplementation(() => ({ videoInputDevices: [mockDevice, mockDevice] })); const wrapper = shallow(); expect(wrapper.find(Select).exists()).toBe(true); expect( @@ -82,7 +82,7 @@ describe('the VideoInputList component', () => { }); it('should save the deviceId in localStorage when the video input device is changed', () => { - mockUseVideoInputDevices.mockImplementation(() => [mockDevice, mockDevice]); + mockUseDevices.mockImplementation(() => ({ videoInputDevices: [mockDevice, mockDevice] })); const wrapper = shallow(); expect(window.localStorage.getItem(SELECTED_VIDEO_INPUT_KEY)).toBe(undefined); wrapper.find(Select).simulate('change', { target: { value: 'mockDeviceID' } }); @@ -90,7 +90,7 @@ describe('the VideoInputList component', () => { }); it('should call track.restart with the new deviceId when the video input device is changed', () => { - mockUseVideoInputDevices.mockImplementation(() => [mockDevice, mockDevice]); + mockUseDevices.mockImplementation(() => ({ videoInputDevices: [mockDevice, mockDevice] })); const wrapper = shallow(); wrapper.find(Select).simulate('change', { target: { value: 'mockDeviceID' } }); expect(mockLocalTrack.restart).toHaveBeenCalledWith({ diff --git a/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.tsx b/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.tsx index aaed5ee88..37b757d98 100644 --- a/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.tsx +++ b/src/components/DeviceSelectionDialog/VideoInputList/VideoInputList.tsx @@ -4,9 +4,9 @@ import { FormControl, MenuItem, Typography, Select } from '@material-ui/core'; import { LocalVideoTrack } from 'twilio-video'; import { makeStyles } from '@material-ui/core/styles'; import VideoTrack from '../../VideoTrack/VideoTrack'; +import useDevices from '../../../hooks/useDevices/useDevices'; import useMediaStreamTrack from '../../../hooks/useMediaStreamTrack/useMediaStreamTrack'; import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; -import { useVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks'; const useStyles = makeStyles({ preview: { @@ -21,7 +21,7 @@ const useStyles = makeStyles({ export default function VideoInputList() { const classes = useStyles(); - const videoInputDevices = useVideoInputDevices(); + const { videoInputDevices } = useDevices(); const { localTracks } = useVideoContext(); const localVideoTrack = localTracks.find(track => track.kind === 'video') as LocalVideoTrack; diff --git a/src/components/MenuBar/FlipCameraButton/FlipCameraButton.test.tsx b/src/components/MenuBar/FlipCameraButton/FlipCameraButton.test.tsx index 351b72fad..da0e68fdd 100644 --- a/src/components/MenuBar/FlipCameraButton/FlipCameraButton.test.tsx +++ b/src/components/MenuBar/FlipCameraButton/FlipCameraButton.test.tsx @@ -2,14 +2,14 @@ import React from 'react'; import { DEFAULT_VIDEO_CONSTRAINTS } from '../../../constants'; import { fireEvent, render } from '@testing-library/react'; import FlipCameraButton from './FlipCameraButton'; +import useDevices from '../../../hooks/useDevices/useDevices'; import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; -import { useVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks'; jest.mock('../../../hooks/useMediaStreamTrack/useMediaStreamTrack'); jest.mock('../../../hooks/useVideoContext/useVideoContext'); -jest.mock('../../../hooks/deviceHooks/deviceHooks'); -const mockUserVideoContext = useVideoContext as jest.Mock; -const mockUseVideoInputDevices = useVideoInputDevices as jest.Mock; +jest.mock('../../../hooks/useDevices/useDevices'); +const mockUseVideoContext = useVideoContext as jest.Mock; +const mockUseDevices = useDevices as jest.Mock; const mockStreamSettings = { facingMode: 'user' }; @@ -29,17 +29,17 @@ const mockVideoContext = { describe('the FlipCameraButton', () => { beforeEach(jest.clearAllMocks); beforeEach(() => { - mockUseVideoInputDevices.mockImplementation(() => ['mockCamera1', 'mockCamera2']); + mockUseDevices.mockImplementation(() => ({ videoInputDevices: ['mockCamera1', 'mockCamera2'] })); }); it('should render a button when a video track exists and has the facingMode setting', () => { - mockUserVideoContext.mockImplementation(() => mockVideoContext); + mockUseVideoContext.mockImplementation(() => mockVideoContext); const { container } = render(); expect(container.querySelector('button')).toBeTruthy(); }); it('not render a button when the video track does not have the facingMode setting', () => { - mockUserVideoContext.mockImplementation(() => ({ + mockUseVideoContext.mockImplementation(() => ({ ...mockVideoContext, localTracks: [ { @@ -55,7 +55,7 @@ describe('the FlipCameraButton', () => { }); it('should not render a button when no video track is present', () => { - mockUserVideoContext.mockImplementation(() => ({ + mockUseVideoContext.mockImplementation(() => ({ ...mockVideoContext, localTracks: [], })); @@ -64,14 +64,14 @@ describe('the FlipCameraButton', () => { }); it('should not render a button when there are less than two video input devices', () => { - mockUserVideoContext.mockImplementation(() => mockVideoContext); - mockUseVideoInputDevices.mockImplementation(() => ['mockCamera1']); + mockUseVideoContext.mockImplementation(() => mockVideoContext); + mockUseDevices.mockImplementation(() => ({ videoInputDevices: ['mockCamera1'] })); const { container } = render(); expect(container.querySelector('button')).not.toBeTruthy(); }); it('should call track.replace() with the correct facing mode when clicked', async () => { - mockUserVideoContext.mockImplementation(() => ({ + mockUseVideoContext.mockImplementation(() => ({ ...mockVideoContext, localTracks: [ { diff --git a/src/components/MenuBar/FlipCameraButton/FlipCameraButton.tsx b/src/components/MenuBar/FlipCameraButton/FlipCameraButton.tsx index cbe7be5fd..391237c63 100644 --- a/src/components/MenuBar/FlipCameraButton/FlipCameraButton.tsx +++ b/src/components/MenuBar/FlipCameraButton/FlipCameraButton.tsx @@ -3,16 +3,16 @@ import { Button } from '@material-ui/core'; import { DEFAULT_VIDEO_CONSTRAINTS } from '../../../constants'; import FlipCameraIcon from './FlipCameraIcon'; import { LocalVideoTrack } from 'twilio-video'; +import useDevices from '../../../hooks/useDevices/useDevices'; import useMediaStreamTrack from '../../../hooks/useMediaStreamTrack/useMediaStreamTrack'; import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; -import { useVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks'; export default function FlipCameraButton() { const { localTracks } = useVideoContext(); const [supportsFacingMode, setSupportsFacingMode] = useState(null); const videoTrack = localTracks.find(track => track.name.includes('camera')) as LocalVideoTrack; const mediaStreamTrack = useMediaStreamTrack(videoTrack); - const videoDeviceList = useVideoInputDevices(); + const { videoInputDevices } = useDevices(); useEffect(() => { // The 'supportsFacingMode' variable determines if this component is rendered @@ -34,7 +34,7 @@ export default function FlipCameraButton() { }); }, [mediaStreamTrack, videoTrack]); - return supportsFacingMode && videoDeviceList.length > 1 ? ( + return supportsFacingMode && videoInputDevices.length > 1 ? ( diff --git a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx index 0ec81a979..be7b66938 100644 --- a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx +++ b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.test.tsx @@ -1,21 +1,19 @@ import React from 'react'; import MediaErrorSnackBar, { getSnackbarContent } from './MediaErrorSnackbar'; import { shallow } from 'enzyme'; -import { useHasAudioInputDevices, useHasVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks'; +import useDevices from '../../../hooks/useDevices/useDevices'; import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; jest.mock('../../../hooks/useVideoContext/useVideoContext'); -jest.mock('../../../hooks/deviceHooks/deviceHooks'); +jest.mock('../../../hooks/useDevices/useDevices'); const mockUseVideoContext = useVideoContext as jest.Mock; -const mockUseHasAudioInputDevices = useHasAudioInputDevices as jest.Mock; -const mockUseHasVideoInputDevices = useHasVideoInputDevices as jest.Mock; +const mockUseDevices = useDevices as jest.Mock; describe('the MediaErrorSnackBar', () => { beforeEach(() => { mockUseVideoContext.mockImplementation(() => ({ isAcquiringLocalTracks: false })); - mockUseHasAudioInputDevices.mockImplementation(() => true); - mockUseHasVideoInputDevices.mockImplementation(() => true); + mockUseDevices.mockImplementation(() => ({ hasAudioInputDevices: true, hasVideoInputDevices: true })); }); it('should be closed by default', () => { @@ -29,13 +27,13 @@ describe('the MediaErrorSnackBar', () => { }); it('should open when there are no audio devices', () => { - mockUseHasAudioInputDevices.mockImplementationOnce(() => false); + mockUseDevices.mockImplementation(() => ({ hasAudioInputDevices: false, hasVideoInputDevices: true })); const wrapper = shallow(); expect(wrapper.prop('open')).toBe(true); }); it('should open when there are no video devices', () => { - mockUseHasVideoInputDevices.mockImplementationOnce(() => false); + mockUseDevices.mockImplementation(() => ({ hasAudioInputDevices: true, hasVideoInputDevices: false })); const wrapper = shallow(); expect(wrapper.prop('open')).toBe(true); }); diff --git a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx index b566501c8..55e29e17b 100644 --- a/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx +++ b/src/components/PreJoinScreens/MediaErrorSnackbar/MediaErrorSnackbar.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import Snackbar from '../../Snackbar/Snackbar'; -import { useHasAudioInputDevices, useHasVideoInputDevices } from '../../../hooks/deviceHooks/deviceHooks'; +import useDevices from '../../../hooks/useDevices/useDevices'; import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; export function getSnackbarContent(hasAudio: boolean, hasVideo: boolean, error?: Error) { @@ -59,16 +59,18 @@ export function getSnackbarContent(hasAudio: boolean, hasVideo: boolean, error?: } export default function MediaErrorSnackbar({ error }: { error?: Error }) { - const hasAudio = useHasAudioInputDevices(); - const hasVideo = useHasVideoInputDevices(); + const { hasAudioInputDevices, hasVideoInputDevices } = useDevices(); const { isAcquiringLocalTracks } = useVideoContext(); const [isSnackbarDismissed, setIsSnackbarDismissed] = useState(false); - const isSnackbarOpen = !isSnackbarDismissed && !isAcquiringLocalTracks && (Boolean(error) || !hasAudio || !hasVideo); + const isSnackbarOpen = + !isSnackbarDismissed && + !isAcquiringLocalTracks && + (Boolean(error) || !hasAudioInputDevices || !hasVideoInputDevices); - const { headline, message } = getSnackbarContent(hasAudio, hasVideo, error); + const { headline, message } = getSnackbarContent(hasAudioInputDevices, hasVideoInputDevices, error); return ( ; describe('the useLocalTracks hook', () => { beforeEach(() => { Date.now = () => 123456; - mockUseDevices.mockImplementation(() => [ - { deviceId: 'mockAudioDeviceId', kind: 'audioinput' }, - { deviceId: 'mockVideoDeviceId', kind: 'videoinput' }, - ]); + mockUseDevices.mockImplementation(() => ({ + audioInputDevices: [{ deviceId: 'mockAudioDeviceId', kind: 'audioinput' }], + videoInputDevices: [{ deviceId: 'mockVideoDeviceId', kind: 'videoinput' }], + hasAudioInputDevices: true, + hasVideoInputDevices: true, + })); }); afterEach(jest.clearAllMocks); afterEach(() => window.localStorage.clear()); @@ -88,7 +90,12 @@ describe('the useLocalTracks hook', () => { }); it('should create a local audio track when no video devices are present', async () => { - mockUseDevices.mockImplementation(() => [{ deviceId: 'mockAudioDeviceId', kind: 'audioinput' }]); + mockUseDevices.mockImplementation(() => ({ + audioInputDevices: [{ deviceId: 'mockAudioDeviceId', kind: 'audioinput' }], + videoInputDevices: [], + hasAudioInputDevices: true, + hasVideoInputDevices: false, + })); const { result, waitForNextUpdate } = renderHook(useLocalTracks); @@ -104,7 +111,12 @@ describe('the useLocalTracks hook', () => { }); it('should create a local video track when no audio devices are present', async () => { - mockUseDevices.mockImplementation(() => [{ deviceId: 'mockVideoDeviceId', kind: 'videoinput' }]); + mockUseDevices.mockImplementation(() => ({ + audioInputDevices: [], + videoInputDevices: [{ deviceId: 'mockVideoDeviceId', kind: 'videoinput' }], + hasAudioInputDevices: false, + hasVideoInputDevices: true, + })); const { result, waitForNextUpdate } = renderHook(useLocalTracks); diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts index ec5412b22..9a52f8c9a 100644 --- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts +++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts @@ -1,19 +1,13 @@ 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 { useDevices } from '../../../hooks/deviceHooks/deviceHooks'; +import useDevices from '../../../hooks/useDevices/useDevices'; export default function useLocalTracks() { const [audioTrack, setAudioTrack] = useState(); const [videoTrack, setVideoTrack] = useState(); const [isAcquiringLocalTracks, setIsAcquiringLocalTracks] = useState(false); - - const localDevices = useDevices(); - const localAudioDevices = localDevices.filter(device => device.kind === 'audioinput'); - const localVideoDevices = localDevices.filter(device => device.kind === 'videoinput'); - - const hasAudio = localAudioDevices.length > 0; - const hasVideo = localVideoDevices.length > 0; + const { audioInputDevices, videoInputDevices, hasAudioInputDevices, hasVideoInputDevices } = useDevices(); const getLocalAudioTrack = useCallback((deviceId?: string) => { const options: CreateLocalTrackOptions = {}; @@ -56,7 +50,7 @@ export default function useLocalTracks() { }, [videoTrack]); const getAudioAndVideoTracks = useCallback(() => { - if (!hasAudio && !hasVideo) return Promise.resolve(); + if (!hasAudioInputDevices && !hasVideoInputDevices) return Promise.resolve(); if (isAcquiringLocalTracks || audioTrack || videoTrack) return Promise.resolve(); setIsAcquiringLocalTracks(true); @@ -64,20 +58,20 @@ export default function useLocalTracks() { const selectedAudioDeviceId = window.localStorage.getItem(SELECTED_AUDIO_INPUT_KEY); const selectedVideoDeviceId = window.localStorage.getItem(SELECTED_VIDEO_INPUT_KEY); - const hasSelectedAudioDevice = localAudioDevices.some( + const hasSelectedAudioDevice = audioInputDevices.some( device => selectedAudioDeviceId && device.deviceId === selectedAudioDeviceId ); - const hasSelectedVideoDevice = localVideoDevices.some( + const hasSelectedVideoDevice = videoInputDevices.some( device => selectedVideoDeviceId && device.deviceId === selectedVideoDeviceId ); const localTrackConstraints = { - video: hasVideo && { + video: hasVideoInputDevices && { ...(DEFAULT_VIDEO_CONSTRAINTS as {}), name: `camera-${Date.now()}`, ...(hasSelectedVideoDevice && { deviceId: { exact: selectedVideoDeviceId! } }), }, - audio: hasSelectedAudioDevice ? { deviceId: { exact: selectedAudioDeviceId! } } : hasAudio, + audio: hasSelectedAudioDevice ? { deviceId: { exact: selectedAudioDeviceId! } } : hasAudioInputDevices, }; return Video.createLocalTracks(localTrackConstraints) @@ -92,7 +86,15 @@ export default function useLocalTracks() { } }) .finally(() => setIsAcquiringLocalTracks(false)); - }, [hasAudio, hasVideo, audioTrack, videoTrack, localAudioDevices, localVideoDevices, isAcquiringLocalTracks]); + }, [ + hasAudioInputDevices, + hasVideoInputDevices, + audioTrack, + videoTrack, + audioInputDevices, + videoInputDevices, + isAcquiringLocalTracks, + ]); const localTracks = [audioTrack, videoTrack].filter(track => track !== undefined) as ( | LocalAudioTrack diff --git a/src/hooks/deviceHooks/deviceHooks.test.tsx b/src/hooks/deviceHooks/deviceHooks.test.tsx deleted file mode 100644 index a556e1af5..000000000 --- a/src/hooks/deviceHooks/deviceHooks.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { act, renderHook } from '@testing-library/react-hooks'; -import * as deviceHooks from './deviceHooks'; - -let mockDevices = [ - { deviceId: 1, label: '1', kind: 'audioinput' }, - { deviceId: 2, label: '2', kind: 'videoinput' }, - { deviceId: 3, label: '3', kind: 'audiooutput' }, -]; -let mockAddEventListener = jest.fn(); -let mockRemoveEventListener = jest.fn(); - -// @ts-ignore -navigator.mediaDevices = { - addEventListener: mockAddEventListener, - removeEventListener: mockRemoveEventListener, -}; - -describe('the useDevices hook', () => { - afterEach(jest.clearAllMocks); - - it('should correctly return a list of devices', async () => { - // @ts-ignore - navigator.mediaDevices.enumerateDevices = () => Promise.resolve(mockDevices); - const { result, waitForNextUpdate } = renderHook(deviceHooks.useDevices); - await waitForNextUpdate(); - expect(result.current).toEqual(mockDevices); - }); - - it('should respond to "devicechange" events', async () => { - // @ts-ignore - navigator.mediaDevices.enumerateDevices = () => Promise.resolve(mockDevices); - const { result, waitForNextUpdate } = renderHook(deviceHooks.useDevices); - await waitForNextUpdate(); - expect(mockAddEventListener).toHaveBeenCalledWith('devicechange', expect.any(Function)); - act(() => { - // @ts-ignore - navigator.mediaDevices.enumerateDevices = () => Promise.resolve([{ deviceId: 2, label: '2' }]); - mockAddEventListener.mock.calls[0][1](); - }); - await waitForNextUpdate(); - expect(result.current).toEqual([{ deviceId: 2, label: '2' }]); - }); -}); - -describe('the useAudioInputDevices hook', () => { - it('should return a list of audio input devices', async () => { - // @ts-ignore - navigator.mediaDevices.enumerateDevices = () => Promise.resolve(mockDevices); - const { result, waitForNextUpdate } = renderHook(deviceHooks.useAudioInputDevices); - await waitForNextUpdate(); - expect(result.current).toEqual([mockDevices[0]]); - }); -}); - -describe('the useVideoInputDevices hook', () => { - it('should return a list of video input devices', async () => { - // @ts-ignore - navigator.mediaDevices.enumerateDevices = () => Promise.resolve(mockDevices); - const { result, waitForNextUpdate } = renderHook(deviceHooks.useVideoInputDevices); - await waitForNextUpdate(); - expect(result.current).toEqual([mockDevices[1]]); - }); -}); - -describe('the useAudioOutputDevices hook', () => { - it('should return a list of audio output devices', async () => { - // @ts-ignore - navigator.mediaDevices.enumerateDevices = () => Promise.resolve(mockDevices); - const { result, waitForNextUpdate } = renderHook(deviceHooks.useAudioOutputDevices); - await waitForNextUpdate(); - expect(result.current).toEqual([mockDevices[2]]); - }); -}); - -describe('the useHasAudioInputDevices hook', () => { - it('should return true when there are audio input devices', async () => { - // @ts-ignore - navigator.mediaDevices.enumerateDevices = () => Promise.resolve([{ kind: 'audioinput' }]); - const { result, waitForNextUpdate } = renderHook(deviceHooks.useHasAudioInputDevices); - await waitForNextUpdate(); - expect(result.current).toBe(true); - }); - - it('should return false when there are no audio input devices', async () => { - // @ts-ignore - navigator.mediaDevices.enumerateDevices = () => Promise.resolve([{ kind: 'videoinput' }]); - const { result, waitForNextUpdate } = renderHook(deviceHooks.useHasAudioInputDevices); - await waitForNextUpdate(); - expect(result.current).toBe(false); - }); -}); - -describe('the useHasVideoInputDevices hook', () => { - it('should return true when there are video input devices', async () => { - // @ts-ignore - navigator.mediaDevices.enumerateDevices = () => Promise.resolve([{ kind: 'videoinput' }]); - const { result, waitForNextUpdate } = renderHook(deviceHooks.useHasVideoInputDevices); - await waitForNextUpdate(); - expect(result.current).toBe(true); - }); - - it('should return false when there are no video input devices', async () => { - // @ts-ignore - navigator.mediaDevices.enumerateDevices = () => Promise.resolve([{ kind: 'audioinput' }]); - const { result, waitForNextUpdate } = renderHook(deviceHooks.useHasVideoInputDevices); - await waitForNextUpdate(); - expect(result.current).toBe(false); - }); -}); diff --git a/src/hooks/deviceHooks/deviceHooks.tsx b/src/hooks/deviceHooks/deviceHooks.tsx deleted file mode 100644 index 82d358105..000000000 --- a/src/hooks/deviceHooks/deviceHooks.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useState, useEffect } from 'react'; - -export function useDevices() { - const [devices, setDevices] = useState([]); - - useEffect(() => { - const getDevices = () => navigator.mediaDevices.enumerateDevices().then(devices => setDevices(devices)); - navigator.mediaDevices.addEventListener('devicechange', getDevices); - getDevices(); - - return () => { - navigator.mediaDevices.removeEventListener('devicechange', getDevices); - }; - }, []); - - return devices; -} - -export function useAudioInputDevices() { - const devices = useDevices(); - return devices.filter(device => device.kind === 'audioinput'); -} - -export function useVideoInputDevices() { - const devices = useDevices(); - return devices.filter(device => device.kind === 'videoinput'); -} - -export function useAudioOutputDevices() { - const devices = useDevices(); - return devices.filter(device => device.kind === 'audiooutput'); -} - -export function useHasAudioInputDevices() { - const audioDevices = useAudioInputDevices(); - return audioDevices.length > 0; -} - -export function useHasVideoInputDevices() { - const videoDevices = useVideoInputDevices(); - return videoDevices.length > 0; -} diff --git a/src/hooks/useDevices/useDevices.test.tsx b/src/hooks/useDevices/useDevices.test.tsx new file mode 100644 index 000000000..e45d67e32 --- /dev/null +++ b/src/hooks/useDevices/useDevices.test.tsx @@ -0,0 +1,94 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import useDevices from './useDevices'; + +let mockDevices = [ + { deviceId: 1, label: '1', kind: 'audioinput' }, + { deviceId: 2, label: '2', kind: 'videoinput' }, + { deviceId: 3, label: '3', kind: 'audiooutput' }, +]; +let mockAddEventListener = jest.fn(); +let mockRemoveEventListener = jest.fn(); + +// @ts-ignore +navigator.mediaDevices = { + addEventListener: mockAddEventListener, + removeEventListener: mockRemoveEventListener, +}; + +describe('the useDevices hook', () => { + afterEach(jest.clearAllMocks); + + it('should correctly return a list of audio input devices', async () => { + // @ts-ignore + navigator.mediaDevices.enumerateDevices = () => Promise.resolve(mockDevices); + const { result, waitForNextUpdate } = renderHook(useDevices); + await waitForNextUpdate(); + expect(result.current).toMatchInlineSnapshot(` + Object { + "audioInputDevices": Array [ + Object { + "deviceId": 1, + "kind": "audioinput", + "label": "1", + }, + ], + "audioOutputDevices": Array [ + Object { + "deviceId": 3, + "kind": "audiooutput", + "label": "3", + }, + ], + "hasAudioInputDevices": true, + "hasVideoInputDevices": true, + "videoInputDevices": Array [ + Object { + "deviceId": 2, + "kind": "videoinput", + "label": "2", + }, + ], + } + `); + }); + + it('should return hasAudioInputDevices: false when there are no audio input devices', async () => { + navigator.mediaDevices.enumerateDevices = () => + // @ts-ignore + Promise.resolve([ + { deviceId: 2, label: '2', kind: 'videoinput' }, + { deviceId: 3, label: '3', kind: 'audiooutput' }, + ]); + const { result, waitForNextUpdate } = renderHook(useDevices); + await waitForNextUpdate(); + expect(result.current.hasAudioInputDevices).toBe(false); + }); + + it('should return hasAudioInputDevices: false when there are no audio input devices', async () => { + navigator.mediaDevices.enumerateDevices = () => + // @ts-ignore + Promise.resolve([ + { deviceId: 1, label: '1', kind: 'audioinput' }, + { deviceId: 3, label: '3', kind: 'audiooutput' }, + ]); + const { result, waitForNextUpdate } = renderHook(useDevices); + await waitForNextUpdate(); + expect(result.current.hasVideoInputDevices).toBe(false); + }); + + it('should respond to "devicechange" events', async () => { + // @ts-ignore + navigator.mediaDevices.enumerateDevices = () => Promise.resolve(mockDevices); + const { result, waitForNextUpdate } = renderHook(useDevices); + await waitForNextUpdate(); + expect(mockAddEventListener).toHaveBeenCalledWith('devicechange', expect.any(Function)); + act(() => { + navigator.mediaDevices.enumerateDevices = () => + // @ts-ignore + Promise.resolve([{ deviceId: 2, label: '2', kind: 'audioinput' }]); + mockAddEventListener.mock.calls[0][1](); + }); + await waitForNextUpdate(); + expect(result.current.audioInputDevices).toEqual([{ deviceId: 2, label: '2', kind: 'audioinput' }]); + }); +}); diff --git a/src/hooks/useDevices/useDevices.tsx b/src/hooks/useDevices/useDevices.tsx new file mode 100644 index 000000000..6cf0985c9 --- /dev/null +++ b/src/hooks/useDevices/useDevices.tsx @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +export default function useDevices() { + const [devices, setDevices] = useState([]); + + useEffect(() => { + const getDevices = () => navigator.mediaDevices.enumerateDevices().then(devices => setDevices(devices)); + navigator.mediaDevices.addEventListener('devicechange', getDevices); + getDevices(); + + return () => { + navigator.mediaDevices.removeEventListener('devicechange', getDevices); + }; + }, []); + + return { + audioInputDevices: devices.filter(device => device.kind === 'audioinput'), + videoInputDevices: devices.filter(device => device.kind === 'videoinput'), + audioOutputDevices: devices.filter(device => device.kind === 'audiooutput'), + hasAudioInputDevices: devices.filter(device => device.kind === 'audioinput').length > 0, + hasVideoInputDevices: devices.filter(device => device.kind === 'videoinput').length > 0, + }; +} diff --git a/src/state/useActiveSinkId/useActiveSinkId.test.ts b/src/state/useActiveSinkId/useActiveSinkId.test.ts index 6653e3a7f..87874fb34 100644 --- a/src/state/useActiveSinkId/useActiveSinkId.test.ts +++ b/src/state/useActiveSinkId/useActiveSinkId.test.ts @@ -1,12 +1,12 @@ 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'; +import useDevices from '../../hooks/useDevices/useDevices'; -jest.mock('../../hooks/deviceHooks/deviceHooks'); -const mockUseAudioOutputDevices = useAudioOutputDevices as jest.Mock; +jest.mock('../../hooks/useDevices/useDevices'); +const mockUseDevices = useDevices as jest.Mock; -mockUseAudioOutputDevices.mockImplementation(() => []); +mockUseDevices.mockImplementation(() => ({ audioOutputDevices: [] })); describe('the useActiveSinkId hook', () => { beforeEach(() => window.localStorage.clear()); @@ -20,7 +20,7 @@ describe('the useActiveSinkId hook', () => { window.localStorage.setItem(SELECTED_AUDIO_OUTPUT_KEY, 'mockAudioOutputDeviceID'); const { result, rerender } = renderHook(useActiveSinkId); - mockUseAudioOutputDevices.mockImplementationOnce(() => [{ deviceId: 'mockAudioOutputDeviceID' }]); + mockUseDevices.mockImplementationOnce(() => ({ audioOutputDevices: [{ deviceId: 'mockAudioOutputDeviceID' }] })); rerender(); expect(result.current[0]).toBe('mockAudioOutputDeviceID'); @@ -30,7 +30,7 @@ describe('the useActiveSinkId hook', () => { window.localStorage.setItem(SELECTED_AUDIO_OUTPUT_KEY, 'anotherMockAudioOutputDeviceID'); const { result, rerender } = renderHook(useActiveSinkId); - mockUseAudioOutputDevices.mockImplementationOnce(() => [{ deviceId: 'mockAudioOutputDeviceID' }]); + mockUseDevices.mockImplementationOnce(() => ({ audioOutputDevices: [{ deviceId: 'mockAudioOutputDeviceID' }] })); rerender(); expect(result.current[0]).toBe('default'); diff --git a/src/state/useActiveSinkId/useActiveSinkId.ts b/src/state/useActiveSinkId/useActiveSinkId.ts index ed87c60ad..873dce457 100644 --- a/src/state/useActiveSinkId/useActiveSinkId.ts +++ b/src/state/useActiveSinkId/useActiveSinkId.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useState } from 'react'; import { SELECTED_AUDIO_OUTPUT_KEY } from '../../constants'; -import { useAudioOutputDevices } from '../../hooks/deviceHooks/deviceHooks'; +import useDevices from '../../hooks/useDevices/useDevices'; export default function useActiveSinkId() { - const audioOutputDevices = useAudioOutputDevices(); + const { audioOutputDevices } = useDevices(); const [activeSinkId, _setActiveSinkId] = useState('default'); const setActiveSinkId = useCallback(