diff --git a/package-lock.json b/package-lock.json index 23af133e4..eeb711f33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21169,9 +21169,9 @@ "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=" }, "querystringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.1.1.tgz", - "integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" }, "quick-lru": { "version": "5.1.1", @@ -25938,8 +25938,7 @@ }, "ssri": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", - "integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==", + "resolved": "", "requires": { "figgy-pudding": "^3.5.1" } diff --git a/src/__mocks__/twilio-video.ts b/src/__mocks__/twilio-video.ts index a8c47db16..e0eaaa628 100644 --- a/src/__mocks__/twilio-video.ts +++ b/src/__mocks__/twilio-video.ts @@ -14,6 +14,7 @@ const mockRoom = new MockRoom(); class MockTrack extends EventEmitter { kind = ''; stop = jest.fn(); + mediaStreamTrack = { getCapabilities: () => ({ deviceId: 'mockDeviceId' }) }; constructor(kind: string) { super(); @@ -23,8 +24,11 @@ class MockTrack extends EventEmitter { const twilioVideo = { connect: jest.fn(() => Promise.resolve(mockRoom)), - createLocalTracks: jest.fn(() => Promise.resolve([new MockTrack('video'), new MockTrack('audio')])), - createLocalVideoTrack: jest.fn(() => Promise.resolve(new MockTrack('video'))), + createLocalTracks: jest.fn( + // Here we use setTimeout so we can control when this function resolves with jest.runAllTimers() + () => new Promise(resolve => setTimeout(() => resolve([new MockTrack('video'), new MockTrack('audio')]))) + ), + createLocalVideoTrack: jest.fn(() => new Promise(resolve => setTimeout(() => resolve(new MockTrack('video'))))), }; export { mockRoom }; diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx index ee43141b5..f3656eb3d 100644 --- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx +++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.test.tsx @@ -1,32 +1,33 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { getDeviceInfo } from '../../../utils'; import { SELECTED_AUDIO_INPUT_KEY, SELECTED_VIDEO_INPUT_KEY, DEFAULT_VIDEO_CONSTRAINTS } from '../../../constants'; import useLocalTracks from './useLocalTracks'; import Video from 'twilio-video'; -import useDevices from '../../../hooks/useDevices/useDevices'; -jest.mock('../../../hooks/useDevices/useDevices'); -const mockUseDevices = useDevices as jest.Mock; +jest.mock('../../../utils'); +const mockGetDeviceInfo = getDeviceInfo as jest.Mock; describe('the useLocalTracks hook', () => { beforeEach(() => { Date.now = () => 123456; - mockUseDevices.mockImplementation(() => ({ - audioInputDevices: [{ deviceId: 'mockAudioDeviceId', kind: 'audioinput' }], - videoInputDevices: [{ deviceId: 'mockVideoDeviceId', kind: 'videoinput' }], - hasAudioInputDevices: true, - hasVideoInputDevices: true, - })); + mockGetDeviceInfo.mockImplementation(() => + Promise.resolve({ + audioInputDevices: [{ deviceId: 'mockAudioDeviceId', kind: 'audioinput' }], + videoInputDevices: [{ deviceId: 'mockVideoDeviceId', kind: 'videoinput' }], + hasAudioInputDevices: true, + hasVideoInputDevices: true, + }) + ); }); afterEach(jest.clearAllMocks); afterEach(() => window.localStorage.clear()); describe('the getAudioAndVideoTracks function', () => { it('should create local audio and video tracks', async () => { - const { result, waitForNextUpdate } = renderHook(useLocalTracks); + const { result } = renderHook(useLocalTracks); await act(async () => { - result.current.getAudioAndVideoTracks(); - await waitForNextUpdate(); + await result.current.getAudioAndVideoTracks(); }); expect(Video.createLocalTracks).toHaveBeenCalledWith({ @@ -43,11 +44,10 @@ describe('the useLocalTracks hook', () => { 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); + const { result } = renderHook(useLocalTracks); await act(async () => { - result.current.getAudioAndVideoTracks(); - await waitForNextUpdate(); + await result.current.getAudioAndVideoTracks(); }); expect(Video.createLocalTracks).toHaveBeenCalledWith({ @@ -71,11 +71,10 @@ describe('the useLocalTracks hook', () => { 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); + const { result } = renderHook(useLocalTracks); await act(async () => { - result.current.getAudioAndVideoTracks(); - await waitForNextUpdate(); + await result.current.getAudioAndVideoTracks(); }); expect(Video.createLocalTracks).toHaveBeenCalledWith({ @@ -90,18 +89,19 @@ describe('the useLocalTracks hook', () => { }); it('should create a local audio track when no video devices are present', async () => { - mockUseDevices.mockImplementation(() => ({ - audioInputDevices: [{ deviceId: 'mockAudioDeviceId', kind: 'audioinput' }], - videoInputDevices: [], - hasAudioInputDevices: true, - hasVideoInputDevices: false, - })); + mockGetDeviceInfo.mockImplementation(() => + Promise.resolve({ + audioInputDevices: [{ deviceId: 'mockAudioDeviceId', kind: 'audioinput' }], + videoInputDevices: [], + hasAudioInputDevices: true, + hasVideoInputDevices: false, + }) + ); - const { result, waitForNextUpdate } = renderHook(useLocalTracks); + const { result } = renderHook(useLocalTracks); await act(async () => { - result.current.getAudioAndVideoTracks(); - await waitForNextUpdate(); + await result.current.getAudioAndVideoTracks(); }); expect(Video.createLocalTracks).toHaveBeenCalledWith({ @@ -111,18 +111,19 @@ describe('the useLocalTracks hook', () => { }); it('should create a local video track when no audio devices are present', async () => { - mockUseDevices.mockImplementation(() => ({ - audioInputDevices: [], - videoInputDevices: [{ deviceId: 'mockVideoDeviceId', kind: 'videoinput' }], - hasAudioInputDevices: false, - hasVideoInputDevices: true, - })); + mockGetDeviceInfo.mockImplementation(() => + Promise.resolve({ + audioInputDevices: [], + videoInputDevices: [{ deviceId: 'mockVideoDeviceId', kind: 'videoinput' }], + hasAudioInputDevices: false, + hasVideoInputDevices: true, + }) + ); - const { result, waitForNextUpdate } = renderHook(useLocalTracks); + const { result } = renderHook(useLocalTracks); await act(async () => { - result.current.getAudioAndVideoTracks(); - await waitForNextUpdate(); + await result.current.getAudioAndVideoTracks(); }); expect(Video.createLocalTracks).toHaveBeenCalledWith({ @@ -137,43 +138,60 @@ describe('the useLocalTracks hook', () => { }); it('should set isAcquiringLocalTracks to true while acquiring tracks', async () => { + jest.useFakeTimers(); const { result, waitForNextUpdate } = renderHook(useLocalTracks); expect(result.current.isAcquiringLocalTracks).toBe(false); - act(() => { + await act(async () => { result.current.getAudioAndVideoTracks(); + await waitForNextUpdate(); }); expect(result.current.isAcquiringLocalTracks).toBe(true); await act(async () => { + jest.runAllTimers(); await waitForNextUpdate(); }); expect(result.current.isAcquiringLocalTracks).toBe(false); + jest.useRealTimers(); + }); + + it('should save the deviceId of the video track to localStorage after it is acquired', async () => { + const { result } = renderHook(useLocalTracks); + + await act(async () => { + await result.current.getAudioAndVideoTracks(); + }); + + expect(window.localStorage.getItem(SELECTED_VIDEO_INPUT_KEY)).toBe('mockDeviceId'); }); it('should ignore calls to getAudioAndVideoTracks while isAcquiringLocalTracks is true', async () => { + jest.useFakeTimers(); const { result, waitForNextUpdate } = renderHook(useLocalTracks); - act(() => { - expect(result.current.isAcquiringLocalTracks).toBe(false); + await act(async () => { result.current.getAudioAndVideoTracks(); // This call is not ignored + await waitForNextUpdate(); }); expect(result.current.isAcquiringLocalTracks).toBe(true); result.current.getAudioAndVideoTracks(); // This call is ignored await act(async () => { + jest.runAllTimers(); await waitForNextUpdate(); }); expect(Video.createLocalTracks).toHaveBeenCalledTimes(1); + jest.useRealTimers(); }); it('should not create any tracks when no input devices are present', async () => { - mockUseDevices.mockImplementation(() => []); + mockGetDeviceInfo.mockImplementation(() => Promise.resolve([])); const { result } = renderHook(useLocalTracks); @@ -196,12 +214,12 @@ describe('the useLocalTracks hook', () => { describe('the removeLocalVideoTrack function', () => { it('should call videoTrack.stop() and remove the videoTrack from state', async () => { - const { result, waitForNextUpdate } = renderHook(useLocalTracks); + const { result, waitForValueToChange } = renderHook(useLocalTracks); // First, get tracks await act(async () => { result.current.getAudioAndVideoTracks(); - await waitForNextUpdate(); + await waitForValueToChange(() => result.current.localTracks.length); }); const initialVideoTrack = result.current.localTracks.find(track => track.kind === 'video'); @@ -219,12 +237,12 @@ describe('the useLocalTracks hook', () => { describe('the removeLocalAudioTrack function', () => { it('should call audioTrack.stop() and remove the audioTrack from state', async () => { - const { result, waitForNextUpdate } = renderHook(useLocalTracks); + const { result, waitForValueToChange } = renderHook(useLocalTracks); // First, get tracks await act(async () => { result.current.getAudioAndVideoTracks(); - await waitForNextUpdate(); + await waitForValueToChange(() => result.current.localTracks.length); }); const initialAudioTrack = result.current.localTracks.find(track => track.kind === 'audio'); diff --git a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts index 0bd4b9a65..8ba688d45 100644 --- a/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts +++ b/src/components/VideoProvider/useLocalTracks/useLocalTracks.ts @@ -1,13 +1,12 @@ import { DEFAULT_VIDEO_CONSTRAINTS, SELECTED_AUDIO_INPUT_KEY, SELECTED_VIDEO_INPUT_KEY } from '../../../constants'; +import { getDeviceInfo } from '../../../utils'; import { useCallback, useState } from 'react'; import Video, { LocalVideoTrack, LocalAudioTrack, CreateLocalTrackOptions } from 'twilio-video'; -import useDevices from '../../../hooks/useDevices/useDevices'; export default function useLocalTracks() { const [audioTrack, setAudioTrack] = useState(); const [videoTrack, setVideoTrack] = useState(); const [isAcquiringLocalTracks, setIsAcquiringLocalTracks] = useState(false); - const { audioInputDevices, videoInputDevices, hasAudioInputDevices, hasVideoInputDevices } = useDevices(); const getLocalAudioTrack = useCallback((deviceId?: string) => { const options: CreateLocalTrackOptions = {}; @@ -22,9 +21,11 @@ export default function useLocalTracks() { }); }, []); - const getLocalVideoTrack = useCallback(() => { + const getLocalVideoTrack = useCallback(async () => { const selectedVideoDeviceId = window.localStorage.getItem(SELECTED_VIDEO_INPUT_KEY); + const { videoInputDevices } = await getDeviceInfo(); + const hasSelectedVideoDevice = videoInputDevices.some( device => selectedVideoDeviceId && device.deviceId === selectedVideoDeviceId ); @@ -39,7 +40,7 @@ export default function useLocalTracks() { setVideoTrack(newTrack); return newTrack; }); - }, [videoInputDevices]); + }, []); const removeLocalAudioTrack = useCallback(() => { if (audioTrack) { @@ -55,7 +56,9 @@ export default function useLocalTracks() { } }, [videoTrack]); - const getAudioAndVideoTracks = useCallback(() => { + const getAudioAndVideoTracks = useCallback(async () => { + const { audioInputDevices, videoInputDevices, hasAudioInputDevices, hasVideoInputDevices } = await getDeviceInfo(); + if (!hasAudioInputDevices && !hasVideoInputDevices) return Promise.resolve(); if (isAcquiringLocalTracks || audioTrack || videoTrack) return Promise.resolve(); @@ -82,25 +85,23 @@ export default function useLocalTracks() { return Video.createLocalTracks(localTrackConstraints) .then(tracks => { - const newVideoTrack = tracks.find(track => track.kind === 'video'); - const newAudioTrack = tracks.find(track => track.kind === 'audio'); + const newVideoTrack = tracks.find(track => track.kind === 'video') as LocalVideoTrack; + const newAudioTrack = tracks.find(track => track.kind === 'audio') as LocalAudioTrack; if (newVideoTrack) { - setVideoTrack(newVideoTrack as LocalVideoTrack); + setVideoTrack(newVideoTrack); + // Save the deviceId so it can be picked up by the VideoInputList component. This only matters + // in cases where the user's video is disabled. + window.localStorage.setItem( + SELECTED_VIDEO_INPUT_KEY, + newVideoTrack.mediaStreamTrack.getCapabilities().deviceId ?? '' + ); } if (newAudioTrack) { - setAudioTrack(newAudioTrack as LocalAudioTrack); + setAudioTrack(newAudioTrack); } }) .finally(() => setIsAcquiringLocalTracks(false)); - }, [ - hasAudioInputDevices, - hasVideoInputDevices, - audioTrack, - videoTrack, - audioInputDevices, - videoInputDevices, - isAcquiringLocalTracks, - ]); + }, [audioTrack, videoTrack, isAcquiringLocalTracks]); const localTracks = [audioTrack, videoTrack].filter(track => track !== undefined) as ( | LocalAudioTrack diff --git a/src/hooks/useDevices/useDevices.test.tsx b/src/hooks/useDevices/useDevices.test.tsx index e45d67e32..d4ebef5c1 100644 --- a/src/hooks/useDevices/useDevices.test.tsx +++ b/src/hooks/useDevices/useDevices.test.tsx @@ -1,11 +1,9 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { getDeviceInfo } from '../../utils'; import useDevices from './useDevices'; -let mockDevices = [ - { deviceId: 1, label: '1', kind: 'audioinput' }, - { deviceId: 2, label: '2', kind: 'videoinput' }, - { deviceId: 3, label: '3', kind: 'audiooutput' }, -]; +jest.mock('../../utils', () => ({ getDeviceInfo: jest.fn(() => Promise.resolve()) })); + let mockAddEventListener = jest.fn(); let mockRemoveEventListener = jest.fn(); @@ -18,77 +16,38 @@ navigator.mediaDevices = { 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); + it('should return the correct default values', async () => { 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", - }, - ], + "audioInputDevices": Array [], + "audioOutputDevices": Array [], + "hasAudioInputDevices": false, + "hasVideoInputDevices": false, + "videoInputDevices": Array [], } `); - }); - - 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(); + const { waitForNextUpdate } = renderHook(useDevices); + expect(getDeviceInfo).toHaveBeenCalledTimes(1); + 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(getDeviceInfo).toHaveBeenCalledTimes(2); + }); + + it('should remove "devicechange" listener on component unmount', async () => { + const { waitForNextUpdate, unmount } = renderHook(useDevices); await waitForNextUpdate(); - expect(result.current.audioInputDevices).toEqual([{ deviceId: 2, label: '2', kind: 'audioinput' }]); + unmount(); + expect(mockRemoveEventListener).toHaveBeenCalledWith('devicechange', expect.any(Function)); }); }); diff --git a/src/hooks/useDevices/useDevices.tsx b/src/hooks/useDevices/useDevices.tsx index ef5be13f5..9ea8347bf 100644 --- a/src/hooks/useDevices/useDevices.tsx +++ b/src/hooks/useDevices/useDevices.tsx @@ -1,10 +1,20 @@ import { useState, useEffect } from 'react'; +import { getDeviceInfo } from '../../utils'; + +// This returns the type of the value that is returned by a promise resolution +type ThenArg = T extends PromiseLike ? U : never; export default function useDevices() { - const [devices, setDevices] = useState([]); + const [deviceInfo, setDeviceInfo] = useState>>({ + audioInputDevices: [], + videoInputDevices: [], + audioOutputDevices: [], + hasAudioInputDevices: false, + hasVideoInputDevices: false, + }); useEffect(() => { - const getDevices = () => navigator.mediaDevices.enumerateDevices().then(newDevices => setDevices(newDevices)); + const getDevices = () => getDeviceInfo().then(devices => setDeviceInfo(devices)); navigator.mediaDevices.addEventListener('devicechange', getDevices); getDevices(); @@ -13,11 +23,5 @@ export default function useDevices() { }; }, []); - 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, - }; + return deviceInfo; } diff --git a/src/utils/index.test.ts b/src/utils/index.test.ts index 030e95774..32ec6da17 100644 --- a/src/utils/index.test.ts +++ b/src/utils/index.test.ts @@ -1,4 +1,4 @@ -import { removeUndefineds } from '.'; +import { getDeviceInfo, removeUndefineds } from '.'; describe('the removeUndefineds function', () => { it('should recursively remove any object keys with a value of undefined', () => { @@ -29,3 +29,69 @@ describe('the removeUndefineds function', () => { expect(removeUndefineds(data)).toEqual(result); }); }); + +describe('the getDeviceInfo function', () => { + // @ts-ignore + navigator.mediaDevices = {}; + + let mockDevices = [ + { deviceId: 1, label: '1', kind: 'audioinput' }, + { deviceId: 2, label: '2', kind: 'videoinput' }, + { deviceId: 3, label: '3', kind: 'audiooutput' }, + ]; + + it('should correctly return a list of audio input devices', async () => { + // @ts-ignore + navigator.mediaDevices.enumerateDevices = () => Promise.resolve(mockDevices); + const result = await getDeviceInfo(); + expect(result).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 = await getDeviceInfo(); + expect(result.hasAudioInputDevices).toBe(false); + }); + + it('should return hasVideoInputDevices: false when there are no video input devices', async () => { + navigator.mediaDevices.enumerateDevices = () => + // @ts-ignore + Promise.resolve([ + { deviceId: 1, label: '1', kind: 'audioinput' }, + { deviceId: 3, label: '3', kind: 'audiooutput' }, + ]); + const result = await getDeviceInfo(); + expect(result.hasVideoInputDevices).toBe(false); + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index 1490dafc9..4bfc826c3 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -22,3 +22,15 @@ export function removeUndefineds(obj: T): T { return target as T; } + +export async function getDeviceInfo() { + const devices = await navigator.mediaDevices.enumerateDevices(); + + return { + audioInputDevices: devices.filter(device => device.kind === 'audioinput'), + videoInputDevices: devices.filter(device => device.kind === 'videoinput'), + audioOutputDevices: devices.filter(device => device.kind === 'audiooutput'), + hasAudioInputDevices: devices.some(device => device.kind === 'audioinput'), + hasVideoInputDevices: devices.some(device => device.kind === 'videoinput'), + }; +}