Move "user media" logic into its own provider.
- This new provider is meant to be reused by the settings in the future
Change-Id: I513c07f2390445fb4802091b316244665218f948
diff --git a/client/src/components/CallButtons.tsx b/client/src/components/CallButtons.tsx
index 0b179bd..d853cc1 100644
--- a/client/src/components/CallButtons.tsx
+++ b/client/src/components/CallButtons.tsx
@@ -21,6 +21,7 @@
import { ChangeEvent, useMemo } from 'react';
import { CallStatus, useCallContext, VideoStatus } from '../contexts/CallProvider';
+import { useUserMediaContext } from '../contexts/UserMediaProvider';
import {
ColoredRoundButton,
ExpandableButton,
@@ -138,7 +139,7 @@
};
const useMediaDeviceExpandMenuOptions = (kind: MediaDeviceKind): ExpandMenuRadioOption[] | undefined => {
- const { currentMediaDeviceIds, mediaDevices } = useCallContext();
+ const { currentMediaDeviceIds, mediaDevices } = useUserMediaContext();
const options = useMemo(
() =>
diff --git a/client/src/components/VideoOverlay.tsx b/client/src/components/VideoOverlay.tsx
index 195f5f9..9307f9b 100644
--- a/client/src/components/VideoOverlay.tsx
+++ b/client/src/components/VideoOverlay.tsx
@@ -21,6 +21,7 @@
import { useNavigate } from 'react-router-dom';
import { useCallContext } from '../contexts/CallProvider';
+import { useUserMediaContext } from '../contexts/UserMediaProvider';
import { VideoElementWithSinkId } from '../utils/utils';
import VideoStream, { VideoStreamProps } from './VideoStream';
@@ -79,7 +80,7 @@
currentMediaDeviceIds: {
audiooutput: { id: audioOutDeviceId },
},
- } = useCallContext();
+ } = useUserMediaContext();
const navigate = useNavigate();
// TODO: For now, `remoteStream` is the first remote stream in the array.
diff --git a/client/src/contexts/CallProvider.tsx b/client/src/contexts/CallProvider.tsx
index 400140c..1a8c5b0 100644
--- a/client/src/contexts/CallProvider.tsx
+++ b/client/src/contexts/CallProvider.tsx
@@ -26,6 +26,7 @@
import { useAuthContext } from './AuthProvider';
import { CallData, CallManagerContext } from './CallManagerProvider';
import ConditionalContextProvider from './ConditionalContextProvider';
+import { useUserMediaContext } from './UserMediaProvider';
import { IWebSocketContext, useWebSocketContext } from './WebSocketProvider';
export type CallRole = 'caller' | 'receiver';
@@ -45,24 +46,9 @@
ScreenShare,
}
-type MediaDeviceIdState = {
- id: string | undefined;
- setId: (id: string | undefined) => void | Promise<void>;
-};
-type CurrentMediaDeviceIds = Record<MediaDeviceKind, MediaDeviceIdState>;
-
-export type MediaDevicesInfo = Record<MediaDeviceKind, MediaDeviceInfo[]>;
-export type MediaInputKind = 'audio' | 'video';
-export type MediaInputIds = Record<MediaInputKind, string | false | undefined>;
-
export interface ICallContext {
- localStream: MediaStream | undefined;
- screenShareLocalStream: MediaStream | undefined;
remoteStreams: readonly MediaStream[];
- mediaDevices: MediaDevicesInfo;
- currentMediaDeviceIds: CurrentMediaDeviceIds;
-
isAudioOn: boolean;
setIsAudioOn: SetState<boolean>;
videoStatus: VideoStatus;
@@ -122,8 +108,15 @@
exitCall: () => void;
conversationId: string;
}): ICallContext => {
- const [localStream, setLocalStream] = useState<MediaStream>();
- const [screenShareLocalStream, setScreenShareLocalStream] = useState<MediaStream>();
+ const {
+ localStream,
+ updateLocalStream,
+ screenShareLocalStream,
+ updateScreenShare,
+ setAudioInputDeviceId,
+ setVideoDeviceId,
+ stopMedias,
+ } = useUserMediaContext();
const { account } = useAuthContext();
const webRtcManager = useWebRtcManager();
@@ -135,15 +128,6 @@
const remoteStreams = connectionInfos?.remoteStreams;
const iceConnectionState = connectionInfos?.iceConnectionState;
- const [mediaDevices, setMediaDevices] = useState<MediaDevicesInfo>({
- audioinput: [],
- audiooutput: [],
- videoinput: [],
- });
- const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
- const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
- const [videoDeviceId, setVideoDeviceId] = useState<string>();
-
const [isAudioOn, setIsAudioOn] = useState(false);
const [videoStatus, setVideoStatus] = useState(VideoStatus.Off);
const [isChatShown, setIsChatShown] = useState(false);
@@ -159,92 +143,6 @@
}
}, [account, callData, contactUri, localStream, screenShareLocalStream, webRtcManager, webSocket]);
- const getMediaDevices = useCallback(async (): Promise<MediaDevicesInfo> => {
- try {
- const devices = await navigator.mediaDevices.enumerateDevices();
-
- // TODO: On Firefox, some devices can sometime be duplicated (2 devices can share the same deviceId). Using a map
- // and then converting it to an array makes it so that there is no duplicate. If we find a way to prevent
- // Firefox from listing 2 devices with the same deviceId, we can remove this logic.
- const newMediaDevices: Record<MediaDeviceKind, Record<string, MediaDeviceInfo>> = {
- audioinput: {},
- audiooutput: {},
- videoinput: {},
- };
-
- for (const device of devices) {
- newMediaDevices[device.kind][device.deviceId] = device;
- }
-
- return {
- audioinput: Object.values(newMediaDevices.audioinput),
- audiooutput: Object.values(newMediaDevices.audiooutput),
- videoinput: Object.values(newMediaDevices.videoinput),
- };
- } catch (e) {
- throw new Error('Could not get media devices', { cause: e });
- }
- }, []);
-
- const updateLocalStream = useCallback(
- async (mediaDeviceIds?: MediaInputIds) => {
- const devices = await getMediaDevices();
-
- let audioConstraint: MediaTrackConstraints | boolean = devices.audioinput.length !== 0;
- let videoConstraint: MediaTrackConstraints | boolean = devices.videoinput.length !== 0;
-
- if (!audioConstraint && !videoConstraint) {
- return;
- }
-
- if (mediaDeviceIds?.audio !== undefined) {
- audioConstraint = mediaDeviceIds.audio !== false ? { deviceId: mediaDeviceIds.audio } : false;
- }
- if (mediaDeviceIds?.video !== undefined) {
- videoConstraint = mediaDeviceIds.video !== false ? { deviceId: mediaDeviceIds.video } : false;
- }
-
- try {
- const stream = await navigator.mediaDevices.getUserMedia({
- audio: audioConstraint,
- video: videoConstraint,
- });
-
- for (const track of stream.getTracks()) {
- track.enabled = false;
- }
-
- setLocalStream(stream);
- } catch (e) {
- throw new Error('Could not get media devices', { cause: e });
- }
- },
- [getMediaDevices]
- );
-
- const updateScreenShare = useCallback(
- async (isOn: boolean) => {
- if (isOn) {
- const stream = await navigator.mediaDevices.getDisplayMedia({
- video: true,
- audio: false,
- });
-
- setScreenShareLocalStream(stream);
- return stream;
- } else {
- if (screenShareLocalStream) {
- for (const track of screenShareLocalStream.getTracks()) {
- track.stop();
- }
- }
-
- setScreenShareLocalStream(undefined);
- }
- },
- [screenShareLocalStream]
- );
-
// TODO: Transform the effect into a callback
const updateLocalStreams = webRtcManager.updateLocalStreams;
useEffect(() => {
@@ -262,52 +160,11 @@
}, [account, callData, contactUri, localStream, screenShareLocalStream, webRtcManager, webSocket]);
const closeConnection = useCallback(() => {
- const stopStream = (stream: MediaStream) => {
- const localTracks = stream.getTracks();
- if (localTracks) {
- for (const track of localTracks) {
- track.stop();
- }
- }
- };
-
- if (localStream) {
- stopStream(localStream);
- }
- if (screenShareLocalStream) {
- stopStream(screenShareLocalStream);
- }
-
+ stopMedias();
webRtcManager.clean();
- }, [localStream, screenShareLocalStream, webRtcManager]);
+ }, [stopMedias, webRtcManager]);
- 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]);
-
+ // Tracks logic should be moved into UserMediaProvider
useEffect(() => {
if (localStream) {
for (const track of localStream.getAudioTracks()) {
@@ -318,8 +175,9 @@
}
}
}
- }, [isAudioOn, localStream]);
+ }, [isAudioOn, localStream, setAudioInputDeviceId]);
+ // Tracks logic should be moved into UserMediaProvider
useEffect(() => {
if (localStream) {
for (const track of localStream.getVideoTracks()) {
@@ -330,8 +188,9 @@
}
}
}
- }, [videoStatus, localStream]);
+ }, [videoStatus, localStream, setVideoDeviceId]);
+ // Track logic should be moved into UserMediaProvider
const updateVideoStatus = useCallback(
async (newStatus: ((prevState: VideoStatus) => VideoStatus) | VideoStatus) => {
if (typeof newStatus === 'function') {
@@ -496,41 +355,9 @@
};
}, [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]);
-
return useMemo(
() => ({
- localStream,
- screenShareLocalStream,
remoteStreams,
- mediaDevices,
- currentMediaDeviceIds,
isAudioOn,
setIsAudioOn,
videoStatus,
@@ -546,16 +373,15 @@
endCall,
}),
[
- localStream,
- screenShareLocalStream,
remoteStreams,
- mediaDevices,
- currentMediaDeviceIds,
isAudioOn,
videoStatus,
+ setIsAudioOn,
updateVideoStatus,
isChatShown,
+ setIsChatShown,
isFullscreen,
+ setIsFullscreen,
callRole,
callStatus,
callStartTime,
diff --git a/client/src/contexts/UserMediaProvider.tsx b/client/src/contexts/UserMediaProvider.tsx
new file mode 100644
index 0000000..9903072
--- /dev/null
+++ b/client/src/contexts/UserMediaProvider.tsx
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+import { createOptionalContext } from '../hooks/createOptionalContext';
+import { WithChildren } from '../utils/utils';
+
+type MediaDeviceIdState = {
+ id: string | undefined;
+ setId: (id: string | undefined) => void | Promise<void>;
+};
+type CurrentMediaDeviceIds = Record<MediaDeviceKind, MediaDeviceIdState>;
+
+export type MediaDevicesInfo = Record<MediaDeviceKind, MediaDeviceInfo[]>;
+export type MediaInputKind = 'audio' | 'video';
+export type MediaInputIds = Record<MediaInputKind, string | false | undefined>;
+
+type IUserMediaContext = {
+ localStream: MediaStream | undefined;
+ updateLocalStream: (mediaDeviceIds?: MediaInputIds) => Promise<MediaStream | undefined>;
+ screenShareLocalStream: MediaStream | undefined;
+ updateScreenShare: (isOn: boolean) => Promise<MediaStream | undefined>;
+
+ mediaDevices: MediaDevicesInfo;
+ currentMediaDeviceIds: CurrentMediaDeviceIds;
+
+ setAudioInputDeviceId: (id: string) => void;
+ setAudioOutputDeviceId: (id: string) => void;
+ setVideoDeviceId: (id: string) => void;
+
+ stopMedias: () => void;
+};
+
+const optionalUserMediaContext = createOptionalContext<IUserMediaContext>('UserMediaContext');
+export const useUserMediaContext = optionalUserMediaContext.useOptionalContext;
+
+export default ({ children }: WithChildren) => {
+ const [localStream, setLocalStream] = useState<MediaStream>();
+ const [screenShareLocalStream, setScreenShareLocalStream] = useState<MediaStream>();
+
+ const [mediaDevices, setMediaDevices] = useState<MediaDevicesInfo>({
+ audioinput: [],
+ audiooutput: [],
+ videoinput: [],
+ });
+ const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
+ const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
+ const [videoDeviceId, setVideoDeviceId] = useState<string>();
+
+ const getMediaDevices = useCallback(async (): Promise<MediaDevicesInfo> => {
+ try {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+
+ // TODO: On Firefox, some devices can sometime be duplicated (2 devices can share the same deviceId). Using a map
+ // and then converting it to an array makes it so that there is no duplicate. If we find a way to prevent
+ // Firefox from listing 2 devices with the same deviceId, we can remove this logic.
+ const newMediaDevices: Record<MediaDeviceKind, Record<string, MediaDeviceInfo>> = {
+ audioinput: {},
+ audiooutput: {},
+ videoinput: {},
+ };
+
+ for (const device of devices) {
+ newMediaDevices[device.kind][device.deviceId] = device;
+ }
+
+ return {
+ audioinput: Object.values(newMediaDevices.audioinput),
+ audiooutput: Object.values(newMediaDevices.audiooutput),
+ videoinput: Object.values(newMediaDevices.videoinput),
+ };
+ } catch (e) {
+ throw new Error('Could not get media devices', { cause: e });
+ }
+ }, []);
+
+ const updateLocalStream = useCallback(
+ async (mediaDeviceIds?: MediaInputIds) => {
+ const devices = await getMediaDevices();
+
+ let audioConstraint: MediaTrackConstraints | boolean = devices.audioinput.length !== 0;
+ let videoConstraint: MediaTrackConstraints | boolean = devices.videoinput.length !== 0;
+
+ if (!audioConstraint && !videoConstraint) {
+ return;
+ }
+
+ if (mediaDeviceIds?.audio !== undefined) {
+ audioConstraint = mediaDeviceIds.audio !== false ? { deviceId: mediaDeviceIds.audio } : false;
+ }
+ if (mediaDeviceIds?.video !== undefined) {
+ videoConstraint = mediaDeviceIds.video !== false ? { deviceId: mediaDeviceIds.video } : false;
+ }
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: audioConstraint,
+ video: videoConstraint,
+ });
+
+ for (const track of stream.getTracks()) {
+ track.enabled = false;
+ }
+
+ setLocalStream(stream);
+ return stream;
+ } catch (e) {
+ throw new Error('Could not get media devices', { cause: e });
+ }
+ },
+ [getMediaDevices]
+ );
+
+ 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]);
+
+ const updateScreenShare = useCallback(
+ async (isOn: boolean) => {
+ if (isOn) {
+ const stream = await navigator.mediaDevices.getDisplayMedia({
+ video: true,
+ audio: false,
+ });
+
+ setScreenShareLocalStream(stream);
+ return stream;
+ } else {
+ if (screenShareLocalStream) {
+ for (const track of screenShareLocalStream.getTracks()) {
+ track.stop();
+ }
+ }
+
+ setScreenShareLocalStream(undefined);
+ }
+ },
+ [screenShareLocalStream]
+ );
+
+ useEffect(() => {
+ 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);
+ };
+ }, [getMediaDevices, audioOutputDeviceId]);
+
+ const stopStream = useCallback((stream: MediaStream) => {
+ const localTracks = stream.getTracks();
+ if (localTracks) {
+ for (const track of localTracks) {
+ track.stop();
+ }
+ }
+ }, []);
+
+ const stopMedias = useCallback(() => {
+ if (localStream) {
+ stopStream(localStream);
+ }
+ if (screenShareLocalStream) {
+ stopStream(screenShareLocalStream);
+ }
+ }, [localStream, screenShareLocalStream, stopStream]);
+
+ const value = useMemo(
+ () => ({
+ localStream,
+ updateLocalStream,
+ screenShareLocalStream,
+ updateScreenShare,
+ mediaDevices,
+ currentMediaDeviceIds,
+ setAudioInputDeviceId,
+ setAudioOutputDeviceId,
+ setVideoDeviceId,
+ stopMedias,
+ }),
+ [
+ localStream,
+ updateLocalStream,
+ screenShareLocalStream,
+ updateScreenShare,
+ mediaDevices,
+ currentMediaDeviceIds,
+ setAudioInputDeviceId,
+ setAudioOutputDeviceId,
+ setVideoDeviceId,
+ stopMedias,
+ ]
+ );
+
+ return (
+ <optionalUserMediaContext.Context.Provider value={value}>{children}</optionalUserMediaContext.Context.Provider>
+ );
+};
diff --git a/client/src/pages/CallInterface.tsx b/client/src/pages/CallInterface.tsx
index 1cfd1e5..f66a180 100644
--- a/client/src/pages/CallInterface.tsx
+++ b/client/src/pages/CallInterface.tsx
@@ -50,6 +50,7 @@
import VideoStream from '../components/VideoStream';
import { CallStatus, useCallContext, VideoStatus } from '../contexts/CallProvider';
import { useConversationContext } from '../contexts/ConversationProvider';
+import { useUserMediaContext } from '../contexts/UserMediaProvider';
import { formatCallDuration } from '../utils/dates×';
import { VideoElementWithSinkId } from '../utils/utils';
import { CallPending } from './CallPending';
@@ -91,13 +92,14 @@
}
const CallInterface = () => {
- const { localStream, screenShareLocalStream, remoteStreams } = useCallContext();
const {
+ localStream,
+ screenShareLocalStream,
currentMediaDeviceIds: {
audiooutput: { id: audioOutDeviceId },
},
- videoStatus,
- } = useCallContext();
+ } = useUserMediaContext();
+ const { remoteStreams, videoStatus } = useCallContext();
const remoteVideoRef = useRef<VideoElementWithSinkId | null>(null);
const gridItemRef = useRef<HTMLDivElement | null>(null);
const [isLocalVideoZoomed, setIsLocalVideoZoomed] = useState(false);
diff --git a/client/src/pages/CallPending.tsx b/client/src/pages/CallPending.tsx
index 7ed5aad..c965d45 100644
--- a/client/src/pages/CallPending.tsx
+++ b/client/src/pages/CallPending.tsx
@@ -30,11 +30,13 @@
import ConversationAvatar from '../components/ConversationAvatar';
import { CallStatus, useCallContext } from '../contexts/CallProvider';
import { useConversationContext } from '../contexts/ConversationProvider';
+import { useUserMediaContext } from '../contexts/UserMediaProvider';
import { VideoElementWithSinkId } from '../utils/utils';
export const CallPending = () => {
const { conversationDisplayName } = useConversationContext();
- const { callRole, localStream } = useCallContext();
+ const { localStream } = useUserMediaContext();
+ const { callRole } = useCallContext();
const localVideoRef = useRef<VideoElementWithSinkId | null>(null);
useEffect(() => {
diff --git a/client/src/router.tsx b/client/src/router.tsx
index 331d1e6..680aa06 100644
--- a/client/src/router.tsx
+++ b/client/src/router.tsx
@@ -24,6 +24,7 @@
import CallManagerProvider from './contexts/CallManagerProvider';
import ConversationProvider from './contexts/ConversationProvider';
import MessengerProvider from './contexts/MessengerProvider';
+import UserMediaProvider from './contexts/UserMediaProvider';
import WebSocketProvider from './contexts/WebSocketProvider';
import { RouteParams } from './hooks/useUrlParams';
import AccountSettings from './pages/AccountSettings';
@@ -46,9 +47,11 @@
element={
<AuthProvider>
<WebSocketProvider>
- <CallManagerProvider>
- <Outlet />
- </CallManagerProvider>
+ <UserMediaProvider>
+ <CallManagerProvider>
+ <Outlet />
+ </CallManagerProvider>
+ </UserMediaProvider>
</WebSocketProvider>
</AuthProvider>
}