Switch audio/video devices while in call
Enable the menus to switch audio/video devices.
Add connectionstatechange webRTCConnection listener to set the connected
status.
GitLab: #146
Change-Id: Ic3afbdee2b1a6bf312d3d7d902adb3c103a7d26f
diff --git a/client/src/contexts/CallProvider.tsx b/client/src/contexts/CallProvider.tsx
index c32704b..fdb6935 100644
--- a/client/src/contexts/CallProvider.tsx
+++ b/client/src/contexts/CallProvider.tsx
@@ -16,7 +16,7 @@
* <https://www.gnu.org/licenses/>.
*/
import { CallAction, CallBegin, WebSocketMessageType } from 'jami-web-common';
-import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { createContext, MutableRefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import LoadingPage from '../components/Loading';
@@ -26,7 +26,7 @@
import { callTimeoutMs } from '../utils/constants';
import { SetState, WithChildren } from '../utils/utils';
import { ConversationContext } from './ConversationProvider';
-import { WebRtcContext } from './WebRtcProvider';
+import { MediaDevicesInfo, MediaInputKind, WebRtcContext } from './WebRtcProvider';
import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
export type CallRole = 'caller' | 'receiver';
@@ -40,7 +40,30 @@
PermissionsDenied,
}
+type MediaDeviceIdState = {
+ id: string | undefined;
+ setId: (id: string | undefined) => void | Promise<void>;
+};
+type CurrentMediaDeviceIds = Record<MediaDeviceKind, MediaDeviceIdState>;
+
+/**
+ * HTMLVideoElement with the `sinkId` and `setSinkId` optional properties.
+ *
+ * These properties are defined only on supported browsers
+ * https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId#browser_compatibility
+ */
+interface VideoElementWithSinkId extends HTMLVideoElement {
+ sinkId?: string;
+ setSinkId?: (deviceId: string) => void;
+}
+
export interface ICallContext {
+ mediaDevices: MediaDevicesInfo;
+ currentMediaDeviceIds: CurrentMediaDeviceIds;
+
+ localVideoRef: MutableRefObject<VideoElementWithSinkId | null>;
+ remoteVideoRef: MutableRefObject<VideoElementWithSinkId | null>;
+
isAudioOn: boolean;
setIsAudioOn: SetState<boolean>;
isVideoOn: boolean;
@@ -58,6 +81,29 @@
}
const defaultCallContext: ICallContext = {
+ mediaDevices: {
+ audioinput: [],
+ audiooutput: [],
+ videoinput: [],
+ },
+ currentMediaDeviceIds: {
+ audioinput: {
+ id: undefined,
+ setId: async () => {},
+ },
+ audiooutput: {
+ id: undefined,
+ setId: async () => {},
+ },
+ videoinput: {
+ id: undefined,
+ setId: async () => {},
+ },
+ },
+
+ localVideoRef: { current: null },
+ remoteVideoRef: { current: null },
+
isAudioOn: false,
setIsAudioOn: () => {},
isVideoOn: false,
@@ -93,10 +139,19 @@
webSocket: IWebSocketContext;
}) => {
const { state: routeState } = useUrlParams<CallRouteParams>();
- const { localStream, sendWebRtcOffer, iceConnectionState, closeConnection, getUserMedia } = useContext(WebRtcContext);
+ const { localStream, sendWebRtcOffer, iceConnectionState, closeConnection, getMediaDevices, updateLocalStream } =
+ useContext(WebRtcContext);
const { conversationId, conversation } = useContext(ConversationContext);
const navigate = useNavigate();
+ const localVideoRef = useRef<HTMLVideoElement | null>(null);
+ const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
+
+ const [mediaDevices, setMediaDevices] = useState(defaultCallContext.mediaDevices);
+ const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
+ const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
+ const [videoDeviceId, setVideoDeviceId] = useState<string>();
+
const [isAudioOn, setIsAudioOn] = useState(false);
const [isVideoOn, setIsVideoOn] = useState(false);
const [isChatShown, setIsChatShown] = useState(false);
@@ -111,9 +166,40 @@
const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
useEffect(() => {
+ if (callStatus !== CallStatus.InCall) {
+ return;
+ }
+
+ const updateMediaDevices = async () => {
+ try {
+ const newMediaDevices = await getMediaDevices();
+
+ if (newMediaDevices.audiooutput.length !== 0 && !audioOutputDeviceId) {
+ setAudioOutputDeviceId(newMediaDevices.audiooutput[0].deviceId);
+ }
+
+ setMediaDevices(newMediaDevices);
+ } catch (e) {
+ console.error('Could not update media devices:', e);
+ }
+ };
+
+ navigator.mediaDevices.addEventListener('devicechange', updateMediaDevices);
+ updateMediaDevices();
+
+ return () => {
+ navigator.mediaDevices.removeEventListener('devicechange', updateMediaDevices);
+ };
+ }, [callStatus, getMediaDevices, audioOutputDeviceId]);
+
+ useEffect(() => {
if (localStream) {
for (const track of localStream.getAudioTracks()) {
track.enabled = isAudioOn;
+ const deviceId = track.getSettings().deviceId;
+ if (deviceId) {
+ setAudioInputDeviceId(deviceId);
+ }
}
}
}, [isAudioOn, localStream]);
@@ -122,6 +208,10 @@
if (localStream) {
for (const track of localStream.getVideoTracks()) {
track.enabled = isVideoOn;
+ const deviceId = track.getSettings().deviceId;
+ if (deviceId) {
+ setVideoDeviceId(deviceId);
+ }
}
}
}, [isVideoOn, localStream]);
@@ -139,17 +229,18 @@
useEffect(() => {
if (callRole === 'caller' && callStatus === CallStatus.Default) {
+ const withVideoOn = routeState?.isVideoOn ?? false;
setCallStatus(CallStatus.Loading);
- getUserMedia()
+ updateLocalStream()
.then(() => {
const callBegin: CallBegin = {
contactId: contactUri,
conversationId,
- withVideoOn: routeState?.isVideoOn ?? false,
+ withVideoOn,
};
setCallStatus(CallStatus.Ringing);
- setIsVideoOn(routeState?.isVideoOn ?? false);
+ setIsVideoOn(withVideoOn);
console.info('Sending CallBegin', callBegin);
webSocket.send(WebSocketMessageType.CallBegin, callBegin);
})
@@ -158,12 +249,12 @@
setCallStatus(CallStatus.PermissionsDenied);
});
}
- }, [webSocket, getUserMedia, callRole, callStatus, contactUri, conversationId, routeState]);
+ }, [webSocket, updateLocalStream, callRole, callStatus, contactUri, conversationId, routeState]);
const acceptCall = useCallback(
(withVideoOn: boolean) => {
setCallStatus(CallStatus.Loading);
- getUserMedia()
+ updateLocalStream()
.then(() => {
const callAccept: CallAction = {
contactId: contactUri,
@@ -180,7 +271,7 @@
setCallStatus(CallStatus.PermissionsDenied);
});
},
- [webSocket, getUserMedia, contactUri, conversationId]
+ [webSocket, updateLocalStream, contactUri, conversationId]
);
useEffect(() => {
@@ -268,6 +359,34 @@
};
}, [callStatus, endCall]);
+ const currentMediaDeviceIds: CurrentMediaDeviceIds = useMemo(() => {
+ const createSetIdForDeviceKind = (mediaInputKind: MediaInputKind) => async (id: string | undefined) => {
+ const mediaDeviceIds = {
+ audio: audioInputDeviceId,
+ video: videoDeviceId,
+ };
+
+ mediaDeviceIds[mediaInputKind] = id;
+
+ await updateLocalStream(mediaDeviceIds);
+ };
+
+ return {
+ audioinput: {
+ id: audioInputDeviceId,
+ setId: createSetIdForDeviceKind('audio'),
+ },
+ audiooutput: {
+ id: audioOutputDeviceId,
+ setId: setAudioOutputDeviceId,
+ },
+ videoinput: {
+ id: videoDeviceId,
+ setId: createSetIdForDeviceKind('video'),
+ },
+ };
+ }, [updateLocalStream, audioInputDeviceId, audioOutputDeviceId, videoDeviceId]);
+
useEffect(() => {
navigate('.', {
replace: true,
@@ -283,6 +402,10 @@
return (
<CallContext.Provider
value={{
+ mediaDevices,
+ currentMediaDeviceIds,
+ localVideoRef,
+ remoteVideoRef,
isAudioOn,
setIsAudioOn,
isVideoOn,