blob: 39aad07c2dce184787819ae4429c2d73477f7a7b [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';
simon571240f2022-11-29 23:59:27 -050019import { createContext, useCallback, useContext, useEffect, useMemo, 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';
simon09fe4822022-11-30 23:36:25 -050028import { useConversationContext } 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
simonf929a362022-11-18 16:53:45 -050049export interface ICallContext {
simon492e8402022-11-29 16:48:37 -050050 mediaDevices: MediaDevicesInfo;
51 currentMediaDeviceIds: CurrentMediaDeviceIds;
52
simonf929a362022-11-18 16:53:45 -050053 isAudioOn: boolean;
simon9076a9a2022-11-29 17:13:01 -050054 setIsAudioOn: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050055 isVideoOn: boolean;
simon9076a9a2022-11-29 17:13:01 -050056 setIsVideoOn: SetState<boolean>;
simonf9d78f22022-11-25 15:47:15 -050057 isChatShown: boolean;
58 setIsChatShown: SetState<boolean>;
simon2a5cf142022-11-25 15:47:35 -050059 isFullscreen: boolean;
60 setIsFullscreen: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050061 callRole: CallRole;
62 callStatus: CallStatus;
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -050063 callStartTime: number | undefined;
simonf929a362022-11-18 16:53:45 -050064
MichelleSS55164202022-11-25 18:36:14 -050065 acceptCall: (withVideoOn: boolean) => void;
simonaccd8022022-11-24 15:04:53 -050066 endCall: () => void;
simonf929a362022-11-18 16:53:45 -050067}
68
69const defaultCallContext: ICallContext = {
simon492e8402022-11-29 16:48:37 -050070 mediaDevices: {
71 audioinput: [],
72 audiooutput: [],
73 videoinput: [],
74 },
75 currentMediaDeviceIds: {
76 audioinput: {
77 id: undefined,
78 setId: async () => {},
79 },
80 audiooutput: {
81 id: undefined,
82 setId: async () => {},
83 },
84 videoinput: {
85 id: undefined,
86 setId: async () => {},
87 },
88 },
89
simonf929a362022-11-18 16:53:45 -050090 isAudioOn: false,
simon9076a9a2022-11-29 17:13:01 -050091 setIsAudioOn: () => {},
simonf929a362022-11-18 16:53:45 -050092 isVideoOn: false,
simon9076a9a2022-11-29 17:13:01 -050093 setIsVideoOn: () => {},
simonf9d78f22022-11-25 15:47:15 -050094 isChatShown: false,
95 setIsChatShown: () => {},
simon2a5cf142022-11-25 15:47:35 -050096 isFullscreen: false,
97 setIsFullscreen: () => {},
simonf929a362022-11-18 16:53:45 -050098 callRole: 'caller',
simonff1cb352022-11-24 15:15:26 -050099 callStatus: CallStatus.Default,
Gabriel Rochone382a302022-11-23 12:37:04 -0500100 callStartTime: undefined,
simonf929a362022-11-18 16:53:45 -0500101
MichelleSS55164202022-11-25 18:36:14 -0500102 acceptCall: (_: boolean) => {},
simonaccd8022022-11-24 15:04:53 -0500103 endCall: () => {},
simonf929a362022-11-18 16:53:45 -0500104};
105
106export const CallContext = createContext<ICallContext>(defaultCallContext);
107
108export default ({ children }: WithChildren) => {
simonf353ef42022-11-28 23:14:53 -0500109 const webSocket = useContext(WebSocketContext);
simonf353ef42022-11-28 23:14:53 -0500110
simon9076a9a2022-11-29 17:13:01 -0500111 if (!webSocket) {
simonf353ef42022-11-28 23:14:53 -0500112 return <LoadingPage />;
113 }
114
simon9076a9a2022-11-29 17:13:01 -0500115 return <CallProvider webSocket={webSocket}>{children}</CallProvider>;
simonf353ef42022-11-28 23:14:53 -0500116};
117
118const CallProvider = ({
119 children,
120 webSocket,
simonf353ef42022-11-28 23:14:53 -0500121}: WithChildren & {
122 webSocket: IWebSocketContext;
simonf353ef42022-11-28 23:14:53 -0500123}) => {
Charlieb837e8f2022-11-28 19:18:46 -0500124 const { state: routeState } = useUrlParams<CallRouteParams>();
simon492e8402022-11-29 16:48:37 -0500125 const { localStream, sendWebRtcOffer, iceConnectionState, closeConnection, getMediaDevices, updateLocalStream } =
126 useContext(WebRtcContext);
simon09fe4822022-11-30 23:36:25 -0500127 const { conversationId, conversation } = useConversationContext();
simonaccd8022022-11-24 15:04:53 -0500128 const navigate = useNavigate();
simonf929a362022-11-18 16:53:45 -0500129
simon492e8402022-11-29 16:48:37 -0500130 const [mediaDevices, setMediaDevices] = useState(defaultCallContext.mediaDevices);
131 const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
132 const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
133 const [videoDeviceId, setVideoDeviceId] = useState<string>();
134
simonf929a362022-11-18 16:53:45 -0500135 const [isAudioOn, setIsAudioOn] = useState(false);
136 const [isVideoOn, setIsVideoOn] = useState(false);
simonf9d78f22022-11-25 15:47:15 -0500137 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500138 const [isFullscreen, setIsFullscreen] = useState(false);
simonff1cb352022-11-24 15:15:26 -0500139 const [callStatus, setCallStatus] = useState(routeState?.callStatus);
Charlieb837e8f2022-11-28 19:18:46 -0500140 const [callRole] = useState(routeState?.role);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500141 const [callStartTime, setCallStartTime] = useState<number | undefined>(undefined);
simonf929a362022-11-18 16:53:45 -0500142
simonff1cb352022-11-24 15:15:26 -0500143 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
144 // The client could make a single request with the conversationId, and the server would be tasked with sending
145 // all the individual requests to the members of the conversation.
simonf929a362022-11-18 16:53:45 -0500146 const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
147
148 useEffect(() => {
simon492e8402022-11-29 16:48:37 -0500149 if (callStatus !== CallStatus.InCall) {
150 return;
151 }
152
153 const updateMediaDevices = async () => {
154 try {
155 const newMediaDevices = await getMediaDevices();
156
157 if (newMediaDevices.audiooutput.length !== 0 && !audioOutputDeviceId) {
158 setAudioOutputDeviceId(newMediaDevices.audiooutput[0].deviceId);
159 }
160
161 setMediaDevices(newMediaDevices);
162 } catch (e) {
163 console.error('Could not update media devices:', e);
164 }
165 };
166
167 navigator.mediaDevices.addEventListener('devicechange', updateMediaDevices);
168 updateMediaDevices();
169
170 return () => {
171 navigator.mediaDevices.removeEventListener('devicechange', updateMediaDevices);
172 };
173 }, [callStatus, getMediaDevices, audioOutputDeviceId]);
174
175 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500176 if (localStream) {
177 for (const track of localStream.getAudioTracks()) {
178 track.enabled = isAudioOn;
simon492e8402022-11-29 16:48:37 -0500179 const deviceId = track.getSettings().deviceId;
180 if (deviceId) {
181 setAudioInputDeviceId(deviceId);
182 }
simon9076a9a2022-11-29 17:13:01 -0500183 }
simonfeaa1db2022-11-26 20:13:18 -0500184 }
simon9076a9a2022-11-29 17:13:01 -0500185 }, [isAudioOn, localStream]);
simonf929a362022-11-18 16:53:45 -0500186
187 useEffect(() => {
simonf353ef42022-11-28 23:14:53 -0500188 if (localStream) {
MichelleSS55164202022-11-25 18:36:14 -0500189 for (const track of localStream.getVideoTracks()) {
simon9076a9a2022-11-29 17:13:01 -0500190 track.enabled = isVideoOn;
simon492e8402022-11-29 16:48:37 -0500191 const deviceId = track.getSettings().deviceId;
192 if (deviceId) {
193 setVideoDeviceId(deviceId);
194 }
MichelleSS55164202022-11-25 18:36:14 -0500195 }
simonff1cb352022-11-24 15:15:26 -0500196 }
simon9076a9a2022-11-29 17:13:01 -0500197 }, [isVideoOn, localStream]);
simonf929a362022-11-18 16:53:45 -0500198
199 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500200 const onFullscreenChange = () => {
201 setIsFullscreen(document.fullscreenElement !== null);
202 };
203
204 document.addEventListener('fullscreenchange', onFullscreenChange);
205 return () => {
206 document.removeEventListener('fullscreenchange', onFullscreenChange);
207 };
208 }, []);
209
210 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500211 if (callRole === 'caller' && callStatus === CallStatus.Default) {
simon492e8402022-11-29 16:48:37 -0500212 const withVideoOn = routeState?.isVideoOn ?? false;
simon9076a9a2022-11-29 17:13:01 -0500213 setCallStatus(CallStatus.Loading);
simon492e8402022-11-29 16:48:37 -0500214 updateLocalStream()
simon9076a9a2022-11-29 17:13:01 -0500215 .then(() => {
216 const callBegin: CallBegin = {
217 contactId: contactUri,
218 conversationId,
simon492e8402022-11-29 16:48:37 -0500219 withVideoOn,
simon9076a9a2022-11-29 17:13:01 -0500220 };
221
222 setCallStatus(CallStatus.Ringing);
simon492e8402022-11-29 16:48:37 -0500223 setIsVideoOn(withVideoOn);
simon9076a9a2022-11-29 17:13:01 -0500224 console.info('Sending CallBegin', callBegin);
225 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
226 })
227 .catch((e) => {
228 console.error(e);
229 setCallStatus(CallStatus.PermissionsDenied);
230 });
231 }
simon492e8402022-11-29 16:48:37 -0500232 }, [webSocket, updateLocalStream, callRole, callStatus, contactUri, conversationId, routeState]);
simon9076a9a2022-11-29 17:13:01 -0500233
234 const acceptCall = useCallback(
235 (withVideoOn: boolean) => {
236 setCallStatus(CallStatus.Loading);
simon492e8402022-11-29 16:48:37 -0500237 updateLocalStream()
simon9076a9a2022-11-29 17:13:01 -0500238 .then(() => {
239 const callAccept: CallAction = {
240 contactId: contactUri,
241 conversationId,
242 };
243
244 setIsVideoOn(withVideoOn);
245 setCallStatus(CallStatus.Connecting);
246 console.info('Sending CallAccept', callAccept);
247 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
248 })
249 .catch((e) => {
250 console.error(e);
251 setCallStatus(CallStatus.PermissionsDenied);
252 });
253 },
simon492e8402022-11-29 16:48:37 -0500254 [webSocket, updateLocalStream, contactUri, conversationId]
simon9076a9a2022-11-29 17:13:01 -0500255 );
256
257 useEffect(() => {
simonf929a362022-11-18 16:53:45 -0500258 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
simonaccd8022022-11-24 15:04:53 -0500259 const callAcceptListener = (data: CallAction) => {
260 console.info('Received event on CallAccept', data);
Charliec18d6402022-11-27 13:01:04 -0500261 if (data.conversationId !== conversationId) {
262 console.warn('Wrong incoming conversationId, ignoring action');
263 return;
264 }
265
simonf929a362022-11-18 16:53:45 -0500266 setCallStatus(CallStatus.Connecting);
267
simon9076a9a2022-11-29 17:13:01 -0500268 sendWebRtcOffer();
simonf929a362022-11-18 16:53:45 -0500269 };
270
271 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
272
273 return () => {
274 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
275 };
276 }
simon9076a9a2022-11-29 17:13:01 -0500277 }, [callRole, webSocket, sendWebRtcOffer, callStatus, conversationId]);
simonf929a362022-11-18 16:53:45 -0500278
simon9076a9a2022-11-29 17:13:01 -0500279 const endCall = useCallback(() => {
280 const callEnd: CallAction = {
281 contactId: contactUri,
282 conversationId,
283 };
MichelleSS55164202022-11-25 18:36:14 -0500284
simon9076a9a2022-11-29 17:13:01 -0500285 console.info('Sending CallEnd', callEnd);
286 closeConnection();
287 webSocket.send(WebSocketMessageType.CallEnd, callEnd);
simonaccd8022022-11-24 15:04:53 -0500288 navigate(`/conversation/${conversationId}`);
simon9076a9a2022-11-29 17:13:01 -0500289 // TODO: write in chat that the call ended
290 }, [webSocket, contactUri, conversationId, closeConnection, navigate]);
simonaccd8022022-11-24 15:04:53 -0500291
292 useEffect(() => {
simonaccd8022022-11-24 15:04:53 -0500293 const callEndListener = (data: CallAction) => {
294 console.info('Received event on CallEnd', data);
Charliec18d6402022-11-27 13:01:04 -0500295 if (data.conversationId !== conversationId) {
296 console.warn('Wrong incoming conversationId, ignoring action');
297 return;
298 }
299
simon9076a9a2022-11-29 17:13:01 -0500300 closeConnection();
301 navigate(`/conversation/${conversationId}`);
simonaccd8022022-11-24 15:04:53 -0500302 // TODO: write in chat that the call ended
303 };
304
305 webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
306 return () => {
307 webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
308 };
simon9076a9a2022-11-29 17:13:01 -0500309 }, [webSocket, navigate, conversationId, closeConnection]);
simonaccd8022022-11-24 15:04:53 -0500310
simonf929a362022-11-18 16:53:45 -0500311 useEffect(() => {
Charlieb837e8f2022-11-28 19:18:46 -0500312 if (
313 callStatus === CallStatus.Connecting &&
314 (iceConnectionState === 'connected' || iceConnectionState === 'completed')
315 ) {
simonf929a362022-11-18 16:53:45 -0500316 console.info('Changing call status to InCall');
317 setCallStatus(CallStatus.InCall);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500318 setCallStartTime(Date.now());
simonf929a362022-11-18 16:53:45 -0500319 }
simon9076a9a2022-11-29 17:13:01 -0500320 }, [iceConnectionState, callStatus]);
simonaccd8022022-11-24 15:04:53 -0500321
MichelleSS55164202022-11-25 18:36:14 -0500322 useEffect(() => {
Charlie380dc5e2022-11-29 16:51:42 -0500323 if (iceConnectionState === 'disconnected' || iceConnectionState === 'failed') {
324 console.info('ICE connection disconnected or failed, ending call');
Charlieb837e8f2022-11-28 19:18:46 -0500325 endCall();
326 }
simon9076a9a2022-11-29 17:13:01 -0500327 }, [iceConnectionState, callStatus, isVideoOn, endCall]);
Charlieb837e8f2022-11-28 19:18:46 -0500328
329 useEffect(() => {
MichelleSS55164202022-11-25 18:36:14 -0500330 const checkStatusTimeout = () => {
331 if (callStatus !== CallStatus.InCall) {
332 endCall();
simonff1cb352022-11-24 15:15:26 -0500333 }
MichelleSS55164202022-11-25 18:36:14 -0500334 };
335 const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);
simonff1cb352022-11-24 15:15:26 -0500336
MichelleSS55164202022-11-25 18:36:14 -0500337 return () => {
338 clearTimeout(timeoutId);
339 };
340 }, [callStatus, endCall]);
simonff1cb352022-11-24 15:15:26 -0500341
simon492e8402022-11-29 16:48:37 -0500342 const currentMediaDeviceIds: CurrentMediaDeviceIds = useMemo(() => {
343 const createSetIdForDeviceKind = (mediaInputKind: MediaInputKind) => async (id: string | undefined) => {
344 const mediaDeviceIds = {
345 audio: audioInputDeviceId,
346 video: videoDeviceId,
347 };
348
349 mediaDeviceIds[mediaInputKind] = id;
350
351 await updateLocalStream(mediaDeviceIds);
352 };
353
354 return {
355 audioinput: {
356 id: audioInputDeviceId,
357 setId: createSetIdForDeviceKind('audio'),
358 },
359 audiooutput: {
360 id: audioOutputDeviceId,
361 setId: setAudioOutputDeviceId,
362 },
363 videoinput: {
364 id: videoDeviceId,
365 setId: createSetIdForDeviceKind('video'),
366 },
367 };
368 }, [updateLocalStream, audioInputDeviceId, audioOutputDeviceId, videoDeviceId]);
369
Charlieb837e8f2022-11-28 19:18:46 -0500370 useEffect(() => {
371 navigate('.', {
372 replace: true,
373 state: {},
374 });
375 }, [navigate]);
376
simonff1cb352022-11-24 15:15:26 -0500377 if (!callRole || callStatus === undefined) {
378 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500379 return <Navigate to={'/'} />;
380 }
381
382 return (
383 <CallContext.Provider
384 value={{
simon492e8402022-11-29 16:48:37 -0500385 mediaDevices,
386 currentMediaDeviceIds,
simonf929a362022-11-18 16:53:45 -0500387 isAudioOn,
simon9076a9a2022-11-29 17:13:01 -0500388 setIsAudioOn,
simonf929a362022-11-18 16:53:45 -0500389 isVideoOn,
simon9076a9a2022-11-29 17:13:01 -0500390 setIsVideoOn,
simonf9d78f22022-11-25 15:47:15 -0500391 isChatShown,
392 setIsChatShown,
simon2a5cf142022-11-25 15:47:35 -0500393 isFullscreen,
394 setIsFullscreen,
simonf929a362022-11-18 16:53:45 -0500395 callRole,
396 callStatus,
Gabriel Rochone382a302022-11-23 12:37:04 -0500397 callStartTime,
simonf929a362022-11-18 16:53:45 -0500398 acceptCall,
simonaccd8022022-11-24 15:04:53 -0500399 endCall,
simonf929a362022-11-18 16:53:45 -0500400 }}
401 >
simon9076a9a2022-11-29 17:13:01 -0500402 {callStatus === CallStatus.PermissionsDenied ? <CallPermissionDenied /> : children}
simonf929a362022-11-18 16:53:45 -0500403 </CallContext.Provider>
404 );
405};