blob: fdb6935f461564a49183d11a0be2285ae4571d5c [file] [log] [blame]
simonf929a362022-11-18 16:53:45 -05001/*
2 * Copyright (C) 2022 Savoir-faire Linux Inc.
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU Affero General Public License as
6 * published by the Free Software Foundation; either version 3 of the
7 * License, or (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU Affero General Public License for more details.
13 *
14 * You should have received a copy of the GNU Affero General Public
15 * License along with this program. If not, see
16 * <https://www.gnu.org/licenses/>.
17 */
MichelleSS55164202022-11-25 18:36:14 -050018import { CallAction, CallBegin, WebSocketMessageType } from 'jami-web-common';
simon492e8402022-11-29 16:48:37 -050019import { createContext, MutableRefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
simonaccd8022022-11-24 15:04:53 -050020import { Navigate, useNavigate } from 'react-router-dom';
simonf929a362022-11-18 16:53:45 -050021
simonf353ef42022-11-28 23:14:53 -050022import LoadingPage from '../components/Loading';
simonf929a362022-11-18 16:53:45 -050023import { useUrlParams } from '../hooks/useUrlParams';
simon9076a9a2022-11-29 17:13:01 -050024import CallPermissionDenied from '../pages/CallPermissionDenied';
simonf929a362022-11-18 16:53:45 -050025import { CallRouteParams } from '../router';
MichelleSS55164202022-11-25 18:36:14 -050026import { callTimeoutMs } from '../utils/constants';
simonf9d78f22022-11-25 15:47:15 -050027import { SetState, WithChildren } from '../utils/utils';
simonf929a362022-11-18 16:53:45 -050028import { ConversationContext } from './ConversationProvider';
simon492e8402022-11-29 16:48:37 -050029import { MediaDevicesInfo, MediaInputKind, WebRtcContext } from './WebRtcProvider';
simonf353ef42022-11-28 23:14:53 -050030import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
simonf929a362022-11-18 16:53:45 -050031
32export type CallRole = 'caller' | 'receiver';
33
34export enum CallStatus {
simonff1cb352022-11-24 15:15:26 -050035 Default,
simon9076a9a2022-11-29 17:13:01 -050036 Loading,
simonf929a362022-11-18 16:53:45 -050037 Ringing,
38 Connecting,
39 InCall,
simon9076a9a2022-11-29 17:13:01 -050040 PermissionsDenied,
simonf929a362022-11-18 16:53:45 -050041}
42
simon492e8402022-11-29 16:48:37 -050043type MediaDeviceIdState = {
44 id: string | undefined;
45 setId: (id: string | undefined) => void | Promise<void>;
46};
47type CurrentMediaDeviceIds = Record<MediaDeviceKind, MediaDeviceIdState>;
48
49/**
50 * HTMLVideoElement with the `sinkId` and `setSinkId` optional properties.
51 *
52 * These properties are defined only on supported browsers
53 * https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId#browser_compatibility
54 */
55interface VideoElementWithSinkId extends HTMLVideoElement {
56 sinkId?: string;
57 setSinkId?: (deviceId: string) => void;
58}
59
simonf929a362022-11-18 16:53:45 -050060export interface ICallContext {
simon492e8402022-11-29 16:48:37 -050061 mediaDevices: MediaDevicesInfo;
62 currentMediaDeviceIds: CurrentMediaDeviceIds;
63
64 localVideoRef: MutableRefObject<VideoElementWithSinkId | null>;
65 remoteVideoRef: MutableRefObject<VideoElementWithSinkId | null>;
66
simonf929a362022-11-18 16:53:45 -050067 isAudioOn: boolean;
simon9076a9a2022-11-29 17:13:01 -050068 setIsAudioOn: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050069 isVideoOn: boolean;
simon9076a9a2022-11-29 17:13:01 -050070 setIsVideoOn: SetState<boolean>;
simonf9d78f22022-11-25 15:47:15 -050071 isChatShown: boolean;
72 setIsChatShown: SetState<boolean>;
simon2a5cf142022-11-25 15:47:35 -050073 isFullscreen: boolean;
74 setIsFullscreen: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050075 callRole: CallRole;
76 callStatus: CallStatus;
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -050077 callStartTime: number | undefined;
simonf929a362022-11-18 16:53:45 -050078
MichelleSS55164202022-11-25 18:36:14 -050079 acceptCall: (withVideoOn: boolean) => void;
simonaccd8022022-11-24 15:04:53 -050080 endCall: () => void;
simonf929a362022-11-18 16:53:45 -050081}
82
83const defaultCallContext: ICallContext = {
simon492e8402022-11-29 16:48:37 -050084 mediaDevices: {
85 audioinput: [],
86 audiooutput: [],
87 videoinput: [],
88 },
89 currentMediaDeviceIds: {
90 audioinput: {
91 id: undefined,
92 setId: async () => {},
93 },
94 audiooutput: {
95 id: undefined,
96 setId: async () => {},
97 },
98 videoinput: {
99 id: undefined,
100 setId: async () => {},
101 },
102 },
103
104 localVideoRef: { current: null },
105 remoteVideoRef: { current: null },
106
simonf929a362022-11-18 16:53:45 -0500107 isAudioOn: false,
simon9076a9a2022-11-29 17:13:01 -0500108 setIsAudioOn: () => {},
simonf929a362022-11-18 16:53:45 -0500109 isVideoOn: false,
simon9076a9a2022-11-29 17:13:01 -0500110 setIsVideoOn: () => {},
simonf9d78f22022-11-25 15:47:15 -0500111 isChatShown: false,
112 setIsChatShown: () => {},
simon2a5cf142022-11-25 15:47:35 -0500113 isFullscreen: false,
114 setIsFullscreen: () => {},
simonf929a362022-11-18 16:53:45 -0500115 callRole: 'caller',
simonff1cb352022-11-24 15:15:26 -0500116 callStatus: CallStatus.Default,
Gabriel Rochone382a302022-11-23 12:37:04 -0500117 callStartTime: undefined,
simonf929a362022-11-18 16:53:45 -0500118
MichelleSS55164202022-11-25 18:36:14 -0500119 acceptCall: (_: boolean) => {},
simonaccd8022022-11-24 15:04:53 -0500120 endCall: () => {},
simonf929a362022-11-18 16:53:45 -0500121};
122
123export const CallContext = createContext<ICallContext>(defaultCallContext);
124
125export default ({ children }: WithChildren) => {
simonf353ef42022-11-28 23:14:53 -0500126 const webSocket = useContext(WebSocketContext);
simonf353ef42022-11-28 23:14:53 -0500127
simon9076a9a2022-11-29 17:13:01 -0500128 if (!webSocket) {
simonf353ef42022-11-28 23:14:53 -0500129 return <LoadingPage />;
130 }
131
simon9076a9a2022-11-29 17:13:01 -0500132 return <CallProvider webSocket={webSocket}>{children}</CallProvider>;
simonf353ef42022-11-28 23:14:53 -0500133};
134
135const CallProvider = ({
136 children,
137 webSocket,
simonf353ef42022-11-28 23:14:53 -0500138}: WithChildren & {
139 webSocket: IWebSocketContext;
simonf353ef42022-11-28 23:14:53 -0500140}) => {
Charlieb837e8f2022-11-28 19:18:46 -0500141 const { state: routeState } = useUrlParams<CallRouteParams>();
simon492e8402022-11-29 16:48:37 -0500142 const { localStream, sendWebRtcOffer, iceConnectionState, closeConnection, getMediaDevices, updateLocalStream } =
143 useContext(WebRtcContext);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500144 const { conversationId, conversation } = useContext(ConversationContext);
simonaccd8022022-11-24 15:04:53 -0500145 const navigate = useNavigate();
simonf929a362022-11-18 16:53:45 -0500146
simon492e8402022-11-29 16:48:37 -0500147 const localVideoRef = useRef<HTMLVideoElement | null>(null);
148 const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
149
150 const [mediaDevices, setMediaDevices] = useState(defaultCallContext.mediaDevices);
151 const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
152 const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
153 const [videoDeviceId, setVideoDeviceId] = useState<string>();
154
simonf929a362022-11-18 16:53:45 -0500155 const [isAudioOn, setIsAudioOn] = useState(false);
156 const [isVideoOn, setIsVideoOn] = useState(false);
simonf9d78f22022-11-25 15:47:15 -0500157 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500158 const [isFullscreen, setIsFullscreen] = useState(false);
simonff1cb352022-11-24 15:15:26 -0500159 const [callStatus, setCallStatus] = useState(routeState?.callStatus);
Charlieb837e8f2022-11-28 19:18:46 -0500160 const [callRole] = useState(routeState?.role);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500161 const [callStartTime, setCallStartTime] = useState<number | undefined>(undefined);
simonf929a362022-11-18 16:53:45 -0500162
simonff1cb352022-11-24 15:15:26 -0500163 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
164 // The client could make a single request with the conversationId, and the server would be tasked with sending
165 // all the individual requests to the members of the conversation.
simonf929a362022-11-18 16:53:45 -0500166 const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
167
168 useEffect(() => {
simon492e8402022-11-29 16:48:37 -0500169 if (callStatus !== CallStatus.InCall) {
170 return;
171 }
172
173 const updateMediaDevices = async () => {
174 try {
175 const newMediaDevices = await getMediaDevices();
176
177 if (newMediaDevices.audiooutput.length !== 0 && !audioOutputDeviceId) {
178 setAudioOutputDeviceId(newMediaDevices.audiooutput[0].deviceId);
179 }
180
181 setMediaDevices(newMediaDevices);
182 } catch (e) {
183 console.error('Could not update media devices:', e);
184 }
185 };
186
187 navigator.mediaDevices.addEventListener('devicechange', updateMediaDevices);
188 updateMediaDevices();
189
190 return () => {
191 navigator.mediaDevices.removeEventListener('devicechange', updateMediaDevices);
192 };
193 }, [callStatus, getMediaDevices, audioOutputDeviceId]);
194
195 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500196 if (localStream) {
197 for (const track of localStream.getAudioTracks()) {
198 track.enabled = isAudioOn;
simon492e8402022-11-29 16:48:37 -0500199 const deviceId = track.getSettings().deviceId;
200 if (deviceId) {
201 setAudioInputDeviceId(deviceId);
202 }
simon9076a9a2022-11-29 17:13:01 -0500203 }
simonfeaa1db2022-11-26 20:13:18 -0500204 }
simon9076a9a2022-11-29 17:13:01 -0500205 }, [isAudioOn, localStream]);
simonf929a362022-11-18 16:53:45 -0500206
207 useEffect(() => {
simonf353ef42022-11-28 23:14:53 -0500208 if (localStream) {
MichelleSS55164202022-11-25 18:36:14 -0500209 for (const track of localStream.getVideoTracks()) {
simon9076a9a2022-11-29 17:13:01 -0500210 track.enabled = isVideoOn;
simon492e8402022-11-29 16:48:37 -0500211 const deviceId = track.getSettings().deviceId;
212 if (deviceId) {
213 setVideoDeviceId(deviceId);
214 }
MichelleSS55164202022-11-25 18:36:14 -0500215 }
simonff1cb352022-11-24 15:15:26 -0500216 }
simon9076a9a2022-11-29 17:13:01 -0500217 }, [isVideoOn, localStream]);
simonf929a362022-11-18 16:53:45 -0500218
219 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500220 const onFullscreenChange = () => {
221 setIsFullscreen(document.fullscreenElement !== null);
222 };
223
224 document.addEventListener('fullscreenchange', onFullscreenChange);
225 return () => {
226 document.removeEventListener('fullscreenchange', onFullscreenChange);
227 };
228 }, []);
229
230 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500231 if (callRole === 'caller' && callStatus === CallStatus.Default) {
simon492e8402022-11-29 16:48:37 -0500232 const withVideoOn = routeState?.isVideoOn ?? false;
simon9076a9a2022-11-29 17:13:01 -0500233 setCallStatus(CallStatus.Loading);
simon492e8402022-11-29 16:48:37 -0500234 updateLocalStream()
simon9076a9a2022-11-29 17:13:01 -0500235 .then(() => {
236 const callBegin: CallBegin = {
237 contactId: contactUri,
238 conversationId,
simon492e8402022-11-29 16:48:37 -0500239 withVideoOn,
simon9076a9a2022-11-29 17:13:01 -0500240 };
241
242 setCallStatus(CallStatus.Ringing);
simon492e8402022-11-29 16:48:37 -0500243 setIsVideoOn(withVideoOn);
simon9076a9a2022-11-29 17:13:01 -0500244 console.info('Sending CallBegin', callBegin);
245 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
246 })
247 .catch((e) => {
248 console.error(e);
249 setCallStatus(CallStatus.PermissionsDenied);
250 });
251 }
simon492e8402022-11-29 16:48:37 -0500252 }, [webSocket, updateLocalStream, callRole, callStatus, contactUri, conversationId, routeState]);
simon9076a9a2022-11-29 17:13:01 -0500253
254 const acceptCall = useCallback(
255 (withVideoOn: boolean) => {
256 setCallStatus(CallStatus.Loading);
simon492e8402022-11-29 16:48:37 -0500257 updateLocalStream()
simon9076a9a2022-11-29 17:13:01 -0500258 .then(() => {
259 const callAccept: CallAction = {
260 contactId: contactUri,
261 conversationId,
262 };
263
264 setIsVideoOn(withVideoOn);
265 setCallStatus(CallStatus.Connecting);
266 console.info('Sending CallAccept', callAccept);
267 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
268 })
269 .catch((e) => {
270 console.error(e);
271 setCallStatus(CallStatus.PermissionsDenied);
272 });
273 },
simon492e8402022-11-29 16:48:37 -0500274 [webSocket, updateLocalStream, contactUri, conversationId]
simon9076a9a2022-11-29 17:13:01 -0500275 );
276
277 useEffect(() => {
simonf929a362022-11-18 16:53:45 -0500278 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
simonaccd8022022-11-24 15:04:53 -0500279 const callAcceptListener = (data: CallAction) => {
280 console.info('Received event on CallAccept', data);
Charliec18d6402022-11-27 13:01:04 -0500281 if (data.conversationId !== conversationId) {
282 console.warn('Wrong incoming conversationId, ignoring action');
283 return;
284 }
285
simonf929a362022-11-18 16:53:45 -0500286 setCallStatus(CallStatus.Connecting);
287
simon9076a9a2022-11-29 17:13:01 -0500288 sendWebRtcOffer();
simonf929a362022-11-18 16:53:45 -0500289 };
290
291 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
292
293 return () => {
294 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
295 };
296 }
simon9076a9a2022-11-29 17:13:01 -0500297 }, [callRole, webSocket, sendWebRtcOffer, callStatus, conversationId]);
simonf929a362022-11-18 16:53:45 -0500298
simon9076a9a2022-11-29 17:13:01 -0500299 const endCall = useCallback(() => {
300 const callEnd: CallAction = {
301 contactId: contactUri,
302 conversationId,
303 };
MichelleSS55164202022-11-25 18:36:14 -0500304
simon9076a9a2022-11-29 17:13:01 -0500305 console.info('Sending CallEnd', callEnd);
306 closeConnection();
307 webSocket.send(WebSocketMessageType.CallEnd, callEnd);
simonaccd8022022-11-24 15:04:53 -0500308 navigate(`/conversation/${conversationId}`);
simon9076a9a2022-11-29 17:13:01 -0500309 // TODO: write in chat that the call ended
310 }, [webSocket, contactUri, conversationId, closeConnection, navigate]);
simonaccd8022022-11-24 15:04:53 -0500311
312 useEffect(() => {
simonaccd8022022-11-24 15:04:53 -0500313 const callEndListener = (data: CallAction) => {
314 console.info('Received event on CallEnd', data);
Charliec18d6402022-11-27 13:01:04 -0500315 if (data.conversationId !== conversationId) {
316 console.warn('Wrong incoming conversationId, ignoring action');
317 return;
318 }
319
simon9076a9a2022-11-29 17:13:01 -0500320 closeConnection();
321 navigate(`/conversation/${conversationId}`);
simonaccd8022022-11-24 15:04:53 -0500322 // TODO: write in chat that the call ended
323 };
324
325 webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
326 return () => {
327 webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
328 };
simon9076a9a2022-11-29 17:13:01 -0500329 }, [webSocket, navigate, conversationId, closeConnection]);
simonaccd8022022-11-24 15:04:53 -0500330
simonf929a362022-11-18 16:53:45 -0500331 useEffect(() => {
Charlieb837e8f2022-11-28 19:18:46 -0500332 if (
333 callStatus === CallStatus.Connecting &&
334 (iceConnectionState === 'connected' || iceConnectionState === 'completed')
335 ) {
simonf929a362022-11-18 16:53:45 -0500336 console.info('Changing call status to InCall');
337 setCallStatus(CallStatus.InCall);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500338 setCallStartTime(Date.now());
simonf929a362022-11-18 16:53:45 -0500339 }
simon9076a9a2022-11-29 17:13:01 -0500340 }, [iceConnectionState, callStatus]);
simonaccd8022022-11-24 15:04:53 -0500341
MichelleSS55164202022-11-25 18:36:14 -0500342 useEffect(() => {
Charlie380dc5e2022-11-29 16:51:42 -0500343 if (iceConnectionState === 'disconnected' || iceConnectionState === 'failed') {
344 console.info('ICE connection disconnected or failed, ending call');
Charlieb837e8f2022-11-28 19:18:46 -0500345 endCall();
346 }
simon9076a9a2022-11-29 17:13:01 -0500347 }, [iceConnectionState, callStatus, isVideoOn, endCall]);
Charlieb837e8f2022-11-28 19:18:46 -0500348
349 useEffect(() => {
MichelleSS55164202022-11-25 18:36:14 -0500350 const checkStatusTimeout = () => {
351 if (callStatus !== CallStatus.InCall) {
352 endCall();
simonff1cb352022-11-24 15:15:26 -0500353 }
MichelleSS55164202022-11-25 18:36:14 -0500354 };
355 const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);
simonff1cb352022-11-24 15:15:26 -0500356
MichelleSS55164202022-11-25 18:36:14 -0500357 return () => {
358 clearTimeout(timeoutId);
359 };
360 }, [callStatus, endCall]);
simonff1cb352022-11-24 15:15:26 -0500361
simon492e8402022-11-29 16:48:37 -0500362 const currentMediaDeviceIds: CurrentMediaDeviceIds = useMemo(() => {
363 const createSetIdForDeviceKind = (mediaInputKind: MediaInputKind) => async (id: string | undefined) => {
364 const mediaDeviceIds = {
365 audio: audioInputDeviceId,
366 video: videoDeviceId,
367 };
368
369 mediaDeviceIds[mediaInputKind] = id;
370
371 await updateLocalStream(mediaDeviceIds);
372 };
373
374 return {
375 audioinput: {
376 id: audioInputDeviceId,
377 setId: createSetIdForDeviceKind('audio'),
378 },
379 audiooutput: {
380 id: audioOutputDeviceId,
381 setId: setAudioOutputDeviceId,
382 },
383 videoinput: {
384 id: videoDeviceId,
385 setId: createSetIdForDeviceKind('video'),
386 },
387 };
388 }, [updateLocalStream, audioInputDeviceId, audioOutputDeviceId, videoDeviceId]);
389
Charlieb837e8f2022-11-28 19:18:46 -0500390 useEffect(() => {
391 navigate('.', {
392 replace: true,
393 state: {},
394 });
395 }, [navigate]);
396
simonff1cb352022-11-24 15:15:26 -0500397 if (!callRole || callStatus === undefined) {
398 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500399 return <Navigate to={'/'} />;
400 }
401
402 return (
403 <CallContext.Provider
404 value={{
simon492e8402022-11-29 16:48:37 -0500405 mediaDevices,
406 currentMediaDeviceIds,
407 localVideoRef,
408 remoteVideoRef,
simonf929a362022-11-18 16:53:45 -0500409 isAudioOn,
simon9076a9a2022-11-29 17:13:01 -0500410 setIsAudioOn,
simonf929a362022-11-18 16:53:45 -0500411 isVideoOn,
simon9076a9a2022-11-29 17:13:01 -0500412 setIsVideoOn,
simonf9d78f22022-11-25 15:47:15 -0500413 isChatShown,
414 setIsChatShown,
simon2a5cf142022-11-25 15:47:35 -0500415 isFullscreen,
416 setIsFullscreen,
simonf929a362022-11-18 16:53:45 -0500417 callRole,
418 callStatus,
Gabriel Rochone382a302022-11-23 12:37:04 -0500419 callStartTime,
simonf929a362022-11-18 16:53:45 -0500420 acceptCall,
simonaccd8022022-11-24 15:04:53 -0500421 endCall,
simonf929a362022-11-18 16:53:45 -0500422 }}
423 >
simon9076a9a2022-11-29 17:13:01 -0500424 {callStatus === CallStatus.PermissionsDenied ? <CallPermissionDenied /> : children}
simonf929a362022-11-18 16:53:45 -0500425 </CallContext.Provider>
426 );
427};