Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,6 +24,7 @@ const mockLocalTrack = {
label: 'mock local audio track',
getSettings: () => ({ deviceId: '234' }),
},
restart: jest.fn(),
};

mockUseVideoContext.mockImplementation(() => ({
Expand Down Expand Up @@ -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(<AudioInputList />);
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(<AudioInputList />);
wrapper.find(Select).simulate('change', { target: { value: 'mockDeviceID' } });
expect(mockLocalTrack.restart).toHaveBeenCalledWith({
deviceId: { exact: 'mockDeviceID' },
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 } });
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,6 +24,7 @@ const mockLocalTrack = {
label: 'mock local video track',
getSettings: () => ({ deviceId: '234' }),
},
restart: jest.fn(),
};

mockUseVideoContext.mockImplementation(() => ({
Expand All @@ -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]);
Expand Down Expand Up @@ -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(<VideoInputList />);
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(<VideoInputList />);
wrapper.find(Select).simulate('change', { target: { value: 'mockDeviceID' } });
expect(mockLocalTrack.restart).toHaveBeenCalledWith({
...(DEFAULT_VIDEO_CONSTRAINTS as {}),
deviceId: { exact: 'mockDeviceID' },
});
});
});
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
74 changes: 63 additions & 11 deletions src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx
Original file line number Diff line number Diff line change
@@ -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<any>;
const mockUseHasAudioInputDevices = useHasAudioInputDevices as jest.Mock<any>;

mockUseHasAudioInputDevices.mockImplementation(() => true);
mockUseHasVideoInputDevices.mockImplementation(() => true);
const mockUseAudioInputDevices = useAudioInputDevices as jest.Mock<any>;
const mockUseVideoInputDevices = useVideoInputDevices as jest.Mock<any>;

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 () => {
Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand Down
33 changes: 25 additions & 8 deletions src/components/VideoProvider/useLocalTracks/useLocalTracks.ts
Original file line number Diff line number Diff line change
@@ -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<LocalAudioTrack>();
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack>();
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 = {};
Expand Down Expand Up @@ -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');
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
18 changes: 18 additions & 0 deletions src/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() });
1 change: 1 addition & 0 deletions src/state/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;

Expand Down
3 changes: 2 additions & 1 deletion src/state/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -36,7 +37,7 @@ export const StateContext = createContext<StateContextType>(null!);
export default function AppStateProvider(props: React.PropsWithChildren<{}>) {
const [error, setError] = useState<TwilioError | null>(null);
const [isFetching, setIsFetching] = useState(false);
const [activeSinkId, setActiveSinkId] = useState('default');
const [activeSinkId, setActiveSinkId] = useActiveSinkId();
const [settings, dispatchSetting] = useReducer(settingsReducer, initialSettings);

let contextValue = {
Expand Down
Loading