blob: 88951a984a5a04484db46f98e1f0a076dd3f1ae0 [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';
simon1e2bf342022-12-02 12:19:40 -050027import { AsyncSetState, 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
simon1e2bf342022-12-02 12:19:40 -050043export enum VideoStatus {
44 Off,
45 Camera,
46 ScreenShare,
47}
48
simon492e8402022-11-29 16:48:37 -050049type MediaDeviceIdState = {
50 id: string | undefined;
51 setId: (id: string | undefined) => void | Promise<void>;
52};
53type CurrentMediaDeviceIds = Record<MediaDeviceKind, MediaDeviceIdState>;
54
simonf929a362022-11-18 16:53:45 -050055export interface ICallContext {
simon492e8402022-11-29 16:48:37 -050056 mediaDevices: MediaDevicesInfo;
57 currentMediaDeviceIds: CurrentMediaDeviceIds;
58
simonf929a362022-11-18 16:53:45 -050059 isAudioOn: boolean;
simon9076a9a2022-11-29 17:13:01 -050060 setIsAudioOn: SetState<boolean>;
simon1e2bf342022-12-02 12:19:40 -050061 videoStatus: VideoStatus;
62 updateVideoStatus: AsyncSetState<VideoStatus>;
simonf9d78f22022-11-25 15:47:15 -050063 isChatShown: boolean;
64 setIsChatShown: SetState<boolean>;
simon2a5cf142022-11-25 15:47:35 -050065 isFullscreen: boolean;
66 setIsFullscreen: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050067 callRole: CallRole;
68 callStatus: CallStatus;
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -050069 callStartTime: number | undefined;
simonf929a362022-11-18 16:53:45 -050070
MichelleSS55164202022-11-25 18:36:14 -050071 acceptCall: (withVideoOn: boolean) => void;
simonaccd8022022-11-24 15:04:53 -050072 endCall: () => void;
simonf929a362022-11-18 16:53:45 -050073}
74
75const defaultCallContext: ICallContext = {
simon492e8402022-11-29 16:48:37 -050076 mediaDevices: {
77 audioinput: [],
78 audiooutput: [],
79 videoinput: [],
80 },
81 currentMediaDeviceIds: {
82 audioinput: {
83 id: undefined,
84 setId: async () => {},
85 },
86 audiooutput: {
87 id: undefined,
88 setId: async () => {},
89 },
90 videoinput: {
91 id: undefined,
92 setId: async () => {},
93 },
94 },
95
simonf929a362022-11-18 16:53:45 -050096 isAudioOn: false,
simon9076a9a2022-11-29 17:13:01 -050097 setIsAudioOn: () => {},
simon1e2bf342022-12-02 12:19:40 -050098 videoStatus: VideoStatus.Off,
99 updateVideoStatus: () => Promise.reject(),
simonf9d78f22022-11-25 15:47:15 -0500100 isChatShown: false,
101 setIsChatShown: () => {},
simon2a5cf142022-11-25 15:47:35 -0500102 isFullscreen: false,
103 setIsFullscreen: () => {},
simonf929a362022-11-18 16:53:45 -0500104 callRole: 'caller',
simonff1cb352022-11-24 15:15:26 -0500105 callStatus: CallStatus.Default,
Gabriel Rochone382a302022-11-23 12:37:04 -0500106 callStartTime: undefined,
simonf929a362022-11-18 16:53:45 -0500107
MichelleSS55164202022-11-25 18:36:14 -0500108 acceptCall: (_: boolean) => {},
simonaccd8022022-11-24 15:04:53 -0500109 endCall: () => {},
simonf929a362022-11-18 16:53:45 -0500110};
111
112export const CallContext = createContext<ICallContext>(defaultCallContext);
113
114export default ({ children }: WithChildren) => {
simonf353ef42022-11-28 23:14:53 -0500115 const webSocket = useContext(WebSocketContext);
simonf353ef42022-11-28 23:14:53 -0500116
simon9076a9a2022-11-29 17:13:01 -0500117 if (!webSocket) {
simonf353ef42022-11-28 23:14:53 -0500118 return <LoadingPage />;
119 }
120
simon9076a9a2022-11-29 17:13:01 -0500121 return <CallProvider webSocket={webSocket}>{children}</CallProvider>;
simonf353ef42022-11-28 23:14:53 -0500122};
123
124const CallProvider = ({
125 children,
126 webSocket,
simonf353ef42022-11-28 23:14:53 -0500127}: WithChildren & {
128 webSocket: IWebSocketContext;
simonf353ef42022-11-28 23:14:53 -0500129}) => {
Charlieb837e8f2022-11-28 19:18:46 -0500130 const { state: routeState } = useUrlParams<CallRouteParams>();
simon1e2bf342022-12-02 12:19:40 -0500131 const {
132 localStream,
133 updateScreenShare,
134 sendWebRtcOffer,
135 iceConnectionState,
136 closeConnection,
137 getMediaDevices,
138 updateLocalStream,
139 } = useContext(WebRtcContext);
simon09fe4822022-11-30 23:36:25 -0500140 const { conversationId, conversation } = useConversationContext();
simonaccd8022022-11-24 15:04:53 -0500141 const navigate = useNavigate();
simonf929a362022-11-18 16:53:45 -0500142
simon492e8402022-11-29 16:48:37 -0500143 const [mediaDevices, setMediaDevices] = useState(defaultCallContext.mediaDevices);
144 const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
145 const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
146 const [videoDeviceId, setVideoDeviceId] = useState<string>();
147
simonf929a362022-11-18 16:53:45 -0500148 const [isAudioOn, setIsAudioOn] = useState(false);
simon1e2bf342022-12-02 12:19:40 -0500149 const [videoStatus, setVideoStatus] = useState(VideoStatus.Off);
simonf9d78f22022-11-25 15:47:15 -0500150 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500151 const [isFullscreen, setIsFullscreen] = useState(false);
simonff1cb352022-11-24 15:15:26 -0500152 const [callStatus, setCallStatus] = useState(routeState?.callStatus);
Charlieb837e8f2022-11-28 19:18:46 -0500153 const [callRole] = useState(routeState?.role);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500154 const [callStartTime, setCallStartTime] = useState<number | undefined>(undefined);
simonf929a362022-11-18 16:53:45 -0500155
simonff1cb352022-11-24 15:15:26 -0500156 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
157 // The client could make a single request with the conversationId, and the server would be tasked with sending
158 // all the individual requests to the members of the conversation.
Misha Krieger-Raynauldcfa44302022-11-30 18:36:36 -0500159 const contactUri = useMemo(() => conversation.getFirstMember().contact.uri, [conversation]);
simonf929a362022-11-18 16:53:45 -0500160
161 useEffect(() => {
simon492e8402022-11-29 16:48:37 -0500162 if (callStatus !== CallStatus.InCall) {
163 return;
164 }
165
166 const updateMediaDevices = async () => {
167 try {
168 const newMediaDevices = await getMediaDevices();
169
170 if (newMediaDevices.audiooutput.length !== 0 && !audioOutputDeviceId) {
171 setAudioOutputDeviceId(newMediaDevices.audiooutput[0].deviceId);
172 }
173
174 setMediaDevices(newMediaDevices);
175 } catch (e) {
176 console.error('Could not update media devices:', e);
177 }
178 };
179
180 navigator.mediaDevices.addEventListener('devicechange', updateMediaDevices);
181 updateMediaDevices();
182
183 return () => {
184 navigator.mediaDevices.removeEventListener('devicechange', updateMediaDevices);
185 };
186 }, [callStatus, getMediaDevices, audioOutputDeviceId]);
187
188 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500189 if (localStream) {
190 for (const track of localStream.getAudioTracks()) {
191 track.enabled = isAudioOn;
simon492e8402022-11-29 16:48:37 -0500192 const deviceId = track.getSettings().deviceId;
193 if (deviceId) {
194 setAudioInputDeviceId(deviceId);
195 }
simon9076a9a2022-11-29 17:13:01 -0500196 }
simonfeaa1db2022-11-26 20:13:18 -0500197 }
simon9076a9a2022-11-29 17:13:01 -0500198 }, [isAudioOn, localStream]);
simonf929a362022-11-18 16:53:45 -0500199
200 useEffect(() => {
simonf353ef42022-11-28 23:14:53 -0500201 if (localStream) {
MichelleSS55164202022-11-25 18:36:14 -0500202 for (const track of localStream.getVideoTracks()) {
simon1e2bf342022-12-02 12:19:40 -0500203 track.enabled = videoStatus === VideoStatus.Camera;
simon492e8402022-11-29 16:48:37 -0500204 const deviceId = track.getSettings().deviceId;
205 if (deviceId) {
206 setVideoDeviceId(deviceId);
207 }
MichelleSS55164202022-11-25 18:36:14 -0500208 }
simonff1cb352022-11-24 15:15:26 -0500209 }
simon1e2bf342022-12-02 12:19:40 -0500210 }, [videoStatus, localStream]);
211
212 const updateVideoStatus = useCallback(
213 async (newStatus: ((prevState: VideoStatus) => VideoStatus) | VideoStatus) => {
214 if (typeof newStatus === 'function') {
215 newStatus = newStatus(videoStatus);
216 }
217
218 const stream = await updateScreenShare(newStatus === VideoStatus.ScreenShare);
219 if (stream) {
220 for (const track of stream.getTracks()) {
221 track.addEventListener('ended', () => {
222 console.warn('Browser ended screen sharing');
223 updateVideoStatus(VideoStatus.Off);
224 });
225 }
226 }
227
228 setVideoStatus(newStatus);
229 },
230 [videoStatus, updateScreenShare]
231 );
simonf929a362022-11-18 16:53:45 -0500232
233 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500234 const onFullscreenChange = () => {
235 setIsFullscreen(document.fullscreenElement !== null);
236 };
237
238 document.addEventListener('fullscreenchange', onFullscreenChange);
239 return () => {
240 document.removeEventListener('fullscreenchange', onFullscreenChange);
241 };
242 }, []);
243
244 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500245 if (callRole === 'caller' && callStatus === CallStatus.Default) {
simon492e8402022-11-29 16:48:37 -0500246 const withVideoOn = routeState?.isVideoOn ?? false;
simon9076a9a2022-11-29 17:13:01 -0500247 setCallStatus(CallStatus.Loading);
simon492e8402022-11-29 16:48:37 -0500248 updateLocalStream()
simon9076a9a2022-11-29 17:13:01 -0500249 .then(() => {
250 const callBegin: CallBegin = {
251 contactId: contactUri,
252 conversationId,
simon492e8402022-11-29 16:48:37 -0500253 withVideoOn,
simon9076a9a2022-11-29 17:13:01 -0500254 };
255
256 setCallStatus(CallStatus.Ringing);
simon1e2bf342022-12-02 12:19:40 -0500257 setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off);
simon9076a9a2022-11-29 17:13:01 -0500258 console.info('Sending CallBegin', callBegin);
259 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
260 })
261 .catch((e) => {
262 console.error(e);
263 setCallStatus(CallStatus.PermissionsDenied);
264 });
265 }
simon492e8402022-11-29 16:48:37 -0500266 }, [webSocket, updateLocalStream, callRole, callStatus, contactUri, conversationId, routeState]);
simon9076a9a2022-11-29 17:13:01 -0500267
268 const acceptCall = useCallback(
269 (withVideoOn: boolean) => {
270 setCallStatus(CallStatus.Loading);
simon492e8402022-11-29 16:48:37 -0500271 updateLocalStream()
simon9076a9a2022-11-29 17:13:01 -0500272 .then(() => {
273 const callAccept: CallAction = {
274 contactId: contactUri,
275 conversationId,
276 };
277
simon1e2bf342022-12-02 12:19:40 -0500278 setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off);
simon9076a9a2022-11-29 17:13:01 -0500279 setCallStatus(CallStatus.Connecting);
280 console.info('Sending CallAccept', callAccept);
281 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
282 })
283 .catch((e) => {
284 console.error(e);
285 setCallStatus(CallStatus.PermissionsDenied);
286 });
287 },
simon492e8402022-11-29 16:48:37 -0500288 [webSocket, updateLocalStream, contactUri, conversationId]
simon9076a9a2022-11-29 17:13:01 -0500289 );
290
291 useEffect(() => {
simonf929a362022-11-18 16:53:45 -0500292 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
simonaccd8022022-11-24 15:04:53 -0500293 const callAcceptListener = (data: CallAction) => {
294 console.info('Received event on CallAccept', data);
Charliec18d6402022-11-27 13:01:04 -0500295 if (data.conversationId !== conversationId) {
296 console.warn('Wrong incoming conversationId, ignoring action');
297 return;
298 }
299
simonf929a362022-11-18 16:53:45 -0500300 setCallStatus(CallStatus.Connecting);
301
simon9076a9a2022-11-29 17:13:01 -0500302 sendWebRtcOffer();
simonf929a362022-11-18 16:53:45 -0500303 };
304
305 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
306
307 return () => {
308 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
309 };
310 }
simon9076a9a2022-11-29 17:13:01 -0500311 }, [callRole, webSocket, sendWebRtcOffer, callStatus, conversationId]);
simonf929a362022-11-18 16:53:45 -0500312
simon9076a9a2022-11-29 17:13:01 -0500313 const endCall = useCallback(() => {
314 const callEnd: CallAction = {
315 contactId: contactUri,
316 conversationId,
317 };
MichelleSS55164202022-11-25 18:36:14 -0500318
simon9076a9a2022-11-29 17:13:01 -0500319 console.info('Sending CallEnd', callEnd);
320 closeConnection();
321 webSocket.send(WebSocketMessageType.CallEnd, callEnd);
simonaccd8022022-11-24 15:04:53 -0500322 navigate(`/conversation/${conversationId}`);
simon9076a9a2022-11-29 17:13:01 -0500323 // TODO: write in chat that the call ended
324 }, [webSocket, contactUri, conversationId, closeConnection, navigate]);
simonaccd8022022-11-24 15:04:53 -0500325
326 useEffect(() => {
simonaccd8022022-11-24 15:04:53 -0500327 const callEndListener = (data: CallAction) => {
328 console.info('Received event on CallEnd', data);
Charliec18d6402022-11-27 13:01:04 -0500329 if (data.conversationId !== conversationId) {
330 console.warn('Wrong incoming conversationId, ignoring action');
331 return;
332 }
333
simon9076a9a2022-11-29 17:13:01 -0500334 closeConnection();
335 navigate(`/conversation/${conversationId}`);
simonaccd8022022-11-24 15:04:53 -0500336 // TODO: write in chat that the call ended
337 };
338
339 webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
340 return () => {
341 webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
342 };
simon9076a9a2022-11-29 17:13:01 -0500343 }, [webSocket, navigate, conversationId, closeConnection]);
simonaccd8022022-11-24 15:04:53 -0500344
simonf929a362022-11-18 16:53:45 -0500345 useEffect(() => {
Charlieb837e8f2022-11-28 19:18:46 -0500346 if (
347 callStatus === CallStatus.Connecting &&
348 (iceConnectionState === 'connected' || iceConnectionState === 'completed')
349 ) {
simonf929a362022-11-18 16:53:45 -0500350 console.info('Changing call status to InCall');
351 setCallStatus(CallStatus.InCall);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500352 setCallStartTime(Date.now());
simonf929a362022-11-18 16:53:45 -0500353 }
simon9076a9a2022-11-29 17:13:01 -0500354 }, [iceConnectionState, callStatus]);
simonaccd8022022-11-24 15:04:53 -0500355
MichelleSS55164202022-11-25 18:36:14 -0500356 useEffect(() => {
Charlie380dc5e2022-11-29 16:51:42 -0500357 if (iceConnectionState === 'disconnected' || iceConnectionState === 'failed') {
358 console.info('ICE connection disconnected or failed, ending call');
Charlieb837e8f2022-11-28 19:18:46 -0500359 endCall();
360 }
simon1e2bf342022-12-02 12:19:40 -0500361 }, [iceConnectionState, callStatus, videoStatus, endCall]);
Charlieb837e8f2022-11-28 19:18:46 -0500362
363 useEffect(() => {
MichelleSS55164202022-11-25 18:36:14 -0500364 const checkStatusTimeout = () => {
365 if (callStatus !== CallStatus.InCall) {
366 endCall();
simonff1cb352022-11-24 15:15:26 -0500367 }
MichelleSS55164202022-11-25 18:36:14 -0500368 };
369 const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);
simonff1cb352022-11-24 15:15:26 -0500370
MichelleSS55164202022-11-25 18:36:14 -0500371 return () => {
372 clearTimeout(timeoutId);
373 };
374 }, [callStatus, endCall]);
simonff1cb352022-11-24 15:15:26 -0500375
simon492e8402022-11-29 16:48:37 -0500376 const currentMediaDeviceIds: CurrentMediaDeviceIds = useMemo(() => {
377 const createSetIdForDeviceKind = (mediaInputKind: MediaInputKind) => async (id: string | undefined) => {
378 const mediaDeviceIds = {
379 audio: audioInputDeviceId,
380 video: videoDeviceId,
381 };
382
383 mediaDeviceIds[mediaInputKind] = id;
384
385 await updateLocalStream(mediaDeviceIds);
386 };
387
388 return {
389 audioinput: {
390 id: audioInputDeviceId,
391 setId: createSetIdForDeviceKind('audio'),
392 },
393 audiooutput: {
394 id: audioOutputDeviceId,
395 setId: setAudioOutputDeviceId,
396 },
397 videoinput: {
398 id: videoDeviceId,
399 setId: createSetIdForDeviceKind('video'),
400 },
401 };
402 }, [updateLocalStream, audioInputDeviceId, audioOutputDeviceId, videoDeviceId]);
403
Charlieb837e8f2022-11-28 19:18:46 -0500404 useEffect(() => {
405 navigate('.', {
406 replace: true,
407 state: {},
408 });
409 }, [navigate]);
410
simonff1cb352022-11-24 15:15:26 -0500411 if (!callRole || callStatus === undefined) {
412 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500413 return <Navigate to={'/'} />;
414 }
415
416 return (
417 <CallContext.Provider
418 value={{
simon492e8402022-11-29 16:48:37 -0500419 mediaDevices,
420 currentMediaDeviceIds,
simonf929a362022-11-18 16:53:45 -0500421 isAudioOn,
simon9076a9a2022-11-29 17:13:01 -0500422 setIsAudioOn,
simon1e2bf342022-12-02 12:19:40 -0500423 videoStatus,
424 updateVideoStatus,
simonf9d78f22022-11-25 15:47:15 -0500425 isChatShown,
426 setIsChatShown,
simon2a5cf142022-11-25 15:47:35 -0500427 isFullscreen,
428 setIsFullscreen,
simonf929a362022-11-18 16:53:45 -0500429 callRole,
430 callStatus,
Gabriel Rochone382a302022-11-23 12:37:04 -0500431 callStartTime,
simonf929a362022-11-18 16:53:45 -0500432 acceptCall,
simonaccd8022022-11-24 15:04:53 -0500433 endCall,
simonf929a362022-11-18 16:53:45 -0500434 }}
435 >
simon9076a9a2022-11-29 17:13:01 -0500436 {callStatus === CallStatus.PermissionsDenied ? <CallPermissionDenied /> : children}
simonf929a362022-11-18 16:53:45 -0500437 </CallContext.Provider>
438 );
439};