diff --git a/src/components/VideoProvider/index.test.tsx b/src/components/VideoProvider/index.test.tsx index 1706452df..96e78d7b6 100644 --- a/src/components/VideoProvider/index.test.tsx +++ b/src/components/VideoProvider/index.test.tsx @@ -4,6 +4,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { Room, TwilioError } from 'twilio-video'; import { VideoProvider } from './index'; import useLocalTracks from './useLocalTracks/useLocalTracks'; +import useRestartAudioTrackOnDeviceChange from './useRestartAudioTrackOnDeviceChange/useRestartAudioTrackOnDeviceChange'; import useRoom from './useRoom/useRoom'; import useHandleRoomDisconnection from './useHandleRoomDisconnection/useHandleRoomDisconnection'; import useHandleTrackPublicationFailed from './useHandleTrackPublicationFailed/useHandleTrackPublicationFailed'; @@ -23,6 +24,7 @@ jest.mock('./useLocalTracks/useLocalTracks', () => ); jest.mock('./useHandleRoomDisconnection/useHandleRoomDisconnection'); jest.mock('./useHandleTrackPublicationFailed/useHandleTrackPublicationFailed'); +jest.mock('./useRestartAudioTrackOnDeviceChange/useRestartAudioTrackOnDeviceChange'); describe('the VideoProvider component', () => { it('should correctly return the Video Context object', () => { @@ -57,6 +59,7 @@ describe('the VideoProvider component', () => { expect.any(Function) ); expect(useHandleTrackPublicationFailed).toHaveBeenCalledWith(mockRoom, expect.any(Function)); + expect(useRestartAudioTrackOnDeviceChange).toHaveBeenCalledWith(result.current.localTracks); }); it('should call the onError function when there is an error', () => { diff --git a/src/components/VideoProvider/index.tsx b/src/components/VideoProvider/index.tsx index ded586b29..0489ae2d1 100644 --- a/src/components/VideoProvider/index.tsx +++ b/src/components/VideoProvider/index.tsx @@ -7,6 +7,7 @@ import AttachVisibilityHandler from './AttachVisibilityHandler/AttachVisibilityH import useHandleRoomDisconnection from './useHandleRoomDisconnection/useHandleRoomDisconnection'; import useHandleTrackPublicationFailed from './useHandleTrackPublicationFailed/useHandleTrackPublicationFailed'; import useLocalTracks from './useLocalTracks/useLocalTracks'; +import useRestartAudioTrackOnDeviceChange from './useRestartAudioTrackOnDeviceChange/useRestartAudioTrackOnDeviceChange'; import useRoom from './useRoom/useRoom'; import useScreenShareToggle from './useScreenShareToggle/useScreenShareToggle'; @@ -72,6 +73,7 @@ export function VideoProvider({ options, children, onError = () => {} }: VideoPr toggleScreenShare ); useHandleTrackPublicationFailed(room, onError); + useRestartAudioTrackOnDeviceChange(localTracks); return ( { + afterEach(jest.clearAllMocks); + + it('should not restart the audio track if mediaStreamTrack readyState has not ended', () => { + const localTrack = [{ kind: 'audio', mediaStreamTrack: { readyState: 'live' }, restart: jest.fn() }]; + renderHook(() => useRestartAudioTrackOnDeviceChange(localTrack as any)); + + // call handleDeviceChange function: + mockAddEventListener.mock.calls[0][1](); + + expect(localTrack[0].restart).not.toHaveBeenCalled(); + }); + + it('should restart the audio track if mediaStreamTrack readyState has ended', () => { + const localTrack = [{ kind: 'audio', mediaStreamTrack: { readyState: 'ended' }, restart: jest.fn() }]; + renderHook(() => useRestartAudioTrackOnDeviceChange(localTrack as any)); + + // call handleDeviceChange function: + mockAddEventListener.mock.calls[0][1](); + + expect(localTrack[0].restart).toHaveBeenCalled(); + }); + + it('should remove the event handler when component unmounts', () => { + const { unmount } = renderHook(() => useRestartAudioTrackOnDeviceChange([])); + unmount(); + + expect(mockRemoveEventListener).toHaveBeenCalledWith('devicechange', expect.any(Function)); + }); +}); diff --git a/src/components/VideoProvider/useRestartAudioTrackOnDeviceChange/useRestartAudioTrackOnDeviceChange.ts b/src/components/VideoProvider/useRestartAudioTrackOnDeviceChange/useRestartAudioTrackOnDeviceChange.ts new file mode 100644 index 000000000..b9a60de71 --- /dev/null +++ b/src/components/VideoProvider/useRestartAudioTrackOnDeviceChange/useRestartAudioTrackOnDeviceChange.ts @@ -0,0 +1,29 @@ +import { LocalAudioTrack, LocalVideoTrack } from 'twilio-video'; +import { useEffect } from 'react'; + +/* + * If a user has published an audio track from an external audio input device and + * disconnects the device, the published audio track will be stopped and the user + * will no longer be heard by other participants. + * + * To prevent this issue, this hook will re-acquire a mediaStreamTrack from the system's + * default audio device when it detects that the published audio device has been disconnected. + */ + +export default function useRestartAudioTrackOnDeviceChange(localTracks: (LocalAudioTrack | LocalVideoTrack)[]) { + const audioTrack = localTracks.find(track => track.kind === 'audio'); + + useEffect(() => { + const handleDeviceChange = () => { + if (audioTrack?.mediaStreamTrack.readyState === 'ended') { + audioTrack.restart(); + } + }; + + navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange); + + return () => { + navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange); + }; + }, [audioTrack]); +}