blob: b20d0e72b0240d351726919fb838816dd83a914d [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';
simone35acc22022-12-02 16:51:12 -050020import { Navigate } from 'react-router-dom';
simonf929a362022-11-18 16:53:45 -050021
simonf353ef42022-11-28 23:14:53 -050022import LoadingPage from '../components/Loading';
simone35acc22022-12-02 16:51:12 -050023import { Conversation } from '../models/conversation';
MichelleSS55164202022-11-25 18:36:14 -050024import { callTimeoutMs } from '../utils/constants';
simon1e2bf342022-12-02 12:19:40 -050025import { AsyncSetState, SetState, WithChildren } from '../utils/utils';
simone35acc22022-12-02 16:51:12 -050026import { CallManagerContext } from './CallManagerProvider';
simon492e8402022-11-29 16:48:37 -050027import { MediaDevicesInfo, MediaInputKind, WebRtcContext } from './WebRtcProvider';
simonf353ef42022-11-28 23:14:53 -050028import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
simonf929a362022-11-18 16:53:45 -050029
30export type CallRole = 'caller' | 'receiver';
31
32export enum CallStatus {
simonff1cb352022-11-24 15:15:26 -050033 Default,
simon9076a9a2022-11-29 17:13:01 -050034 Loading,
simonf929a362022-11-18 16:53:45 -050035 Ringing,
36 Connecting,
37 InCall,
simon9076a9a2022-11-29 17:13:01 -050038 PermissionsDenied,
simonf929a362022-11-18 16:53:45 -050039}
40
simon1e2bf342022-12-02 12:19:40 -050041export enum VideoStatus {
42 Off,
43 Camera,
44 ScreenShare,
45}
46
simon492e8402022-11-29 16:48:37 -050047type MediaDeviceIdState = {
48 id: string | undefined;
49 setId: (id: string | undefined) => void | Promise<void>;
50};
51type CurrentMediaDeviceIds = Record<MediaDeviceKind, MediaDeviceIdState>;
52
simonf929a362022-11-18 16:53:45 -050053export interface ICallContext {
simon492e8402022-11-29 16:48:37 -050054 mediaDevices: MediaDevicesInfo;
55 currentMediaDeviceIds: CurrentMediaDeviceIds;
56
simonf929a362022-11-18 16:53:45 -050057 isAudioOn: boolean;
simon9076a9a2022-11-29 17:13:01 -050058 setIsAudioOn: SetState<boolean>;
simon1e2bf342022-12-02 12:19:40 -050059 videoStatus: VideoStatus;
60 updateVideoStatus: AsyncSetState<VideoStatus>;
simonf9d78f22022-11-25 15:47:15 -050061 isChatShown: boolean;
62 setIsChatShown: SetState<boolean>;
simon2a5cf142022-11-25 15:47:35 -050063 isFullscreen: boolean;
64 setIsFullscreen: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050065 callRole: CallRole;
66 callStatus: CallStatus;
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -050067 callStartTime: number | undefined;
simonf929a362022-11-18 16:53:45 -050068
MichelleSS55164202022-11-25 18:36:14 -050069 acceptCall: (withVideoOn: boolean) => void;
simonaccd8022022-11-24 15:04:53 -050070 endCall: () => void;
simonf929a362022-11-18 16:53:45 -050071}
72
73const defaultCallContext: ICallContext = {
simon492e8402022-11-29 16:48:37 -050074 mediaDevices: {
75 audioinput: [],
76 audiooutput: [],
77 videoinput: [],
78 },
79 currentMediaDeviceIds: {
80 audioinput: {
81 id: undefined,
82 setId: async () => {},
83 },
84 audiooutput: {
85 id: undefined,
86 setId: async () => {},
87 },
88 videoinput: {
89 id: undefined,
90 setId: async () => {},
91 },
92 },
93
simonf929a362022-11-18 16:53:45 -050094 isAudioOn: false,
simon9076a9a2022-11-29 17:13:01 -050095 setIsAudioOn: () => {},
simon1e2bf342022-12-02 12:19:40 -050096 videoStatus: VideoStatus.Off,
97 updateVideoStatus: () => Promise.reject(),
simonf9d78f22022-11-25 15:47:15 -050098 isChatShown: false,
99 setIsChatShown: () => {},
simon2a5cf142022-11-25 15:47:35 -0500100 isFullscreen: false,
101 setIsFullscreen: () => {},
simonf929a362022-11-18 16:53:45 -0500102 callRole: 'caller',
simonff1cb352022-11-24 15:15:26 -0500103 callStatus: CallStatus.Default,
Gabriel Rochone382a302022-11-23 12:37:04 -0500104 callStartTime: undefined,
simonf929a362022-11-18 16:53:45 -0500105
MichelleSS55164202022-11-25 18:36:14 -0500106 acceptCall: (_: boolean) => {},
simonaccd8022022-11-24 15:04:53 -0500107 endCall: () => {},
simonf929a362022-11-18 16:53:45 -0500108};
109
110export const CallContext = createContext<ICallContext>(defaultCallContext);
111
112export default ({ children }: WithChildren) => {
simonf353ef42022-11-28 23:14:53 -0500113 const webSocket = useContext(WebSocketContext);
simone35acc22022-12-02 16:51:12 -0500114 const { callConversation, callData } = useContext(CallManagerContext);
simonf353ef42022-11-28 23:14:53 -0500115
simone35acc22022-12-02 16:51:12 -0500116 if (!webSocket || !callConversation || !callData?.conversationId) {
simonf353ef42022-11-28 23:14:53 -0500117 return <LoadingPage />;
118 }
119
simone35acc22022-12-02 16:51:12 -0500120 return (
121 <CallProvider webSocket={webSocket} conversation={callConversation} conversationId={callData?.conversationId}>
122 {children}
123 </CallProvider>
124 );
simonf353ef42022-11-28 23:14:53 -0500125};
126
127const CallProvider = ({
128 children,
simone35acc22022-12-02 16:51:12 -0500129 conversation,
130 conversationId,
simonf353ef42022-11-28 23:14:53 -0500131 webSocket,
simonf353ef42022-11-28 23:14:53 -0500132}: WithChildren & {
133 webSocket: IWebSocketContext;
simone35acc22022-12-02 16:51:12 -0500134 conversation: Conversation;
135 conversationId: string;
simonf353ef42022-11-28 23:14:53 -0500136}) => {
simone35acc22022-12-02 16:51:12 -0500137 const { callData, exitCall } = useContext(CallManagerContext);
simon1e2bf342022-12-02 12:19:40 -0500138 const {
139 localStream,
140 updateScreenShare,
141 sendWebRtcOffer,
142 iceConnectionState,
143 closeConnection,
144 getMediaDevices,
145 updateLocalStream,
146 } = useContext(WebRtcContext);
simonf929a362022-11-18 16:53:45 -0500147
simon492e8402022-11-29 16:48:37 -0500148 const [mediaDevices, setMediaDevices] = useState(defaultCallContext.mediaDevices);
149 const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
150 const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
151 const [videoDeviceId, setVideoDeviceId] = useState<string>();
152
simonf929a362022-11-18 16:53:45 -0500153 const [isAudioOn, setIsAudioOn] = useState(false);
simon1e2bf342022-12-02 12:19:40 -0500154 const [videoStatus, setVideoStatus] = useState(VideoStatus.Off);
simonf9d78f22022-11-25 15:47:15 -0500155 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500156 const [isFullscreen, setIsFullscreen] = useState(false);
simone35acc22022-12-02 16:51:12 -0500157 const [callStatus, setCallStatus] = useState(CallStatus.Default);
158 const [callRole] = useState(callData?.role);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500159 const [callStartTime, setCallStartTime] = useState<number | undefined>(undefined);
simonf929a362022-11-18 16:53:45 -0500160
simonff1cb352022-11-24 15:15:26 -0500161 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
162 // The client could make a single request with the conversationId, and the server would be tasked with sending
163 // all the individual requests to the members of the conversation.
Misha Krieger-Raynauldcfa44302022-11-30 18:36:36 -0500164 const contactUri = useMemo(() => conversation.getFirstMember().contact.uri, [conversation]);
simonf929a362022-11-18 16:53:45 -0500165
166 useEffect(() => {
simon492e8402022-11-29 16:48:37 -0500167 if (callStatus !== CallStatus.InCall) {
168 return;
169 }
170
171 const updateMediaDevices = async () => {
172 try {
173 const newMediaDevices = await getMediaDevices();
174
175 if (newMediaDevices.audiooutput.length !== 0 && !audioOutputDeviceId) {
176 setAudioOutputDeviceId(newMediaDevices.audiooutput[0].deviceId);
177 }
178
179 setMediaDevices(newMediaDevices);
180 } catch (e) {
181 console.error('Could not update media devices:', e);
182 }
183 };
184
185 navigator.mediaDevices.addEventListener('devicechange', updateMediaDevices);
186 updateMediaDevices();
187
188 return () => {
189 navigator.mediaDevices.removeEventListener('devicechange', updateMediaDevices);
190 };
191 }, [callStatus, getMediaDevices, audioOutputDeviceId]);
192
193 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500194 if (localStream) {
195 for (const track of localStream.getAudioTracks()) {
196 track.enabled = isAudioOn;
simon492e8402022-11-29 16:48:37 -0500197 const deviceId = track.getSettings().deviceId;
198 if (deviceId) {
199 setAudioInputDeviceId(deviceId);
200 }
simon9076a9a2022-11-29 17:13:01 -0500201 }
simonfeaa1db2022-11-26 20:13:18 -0500202 }
simon9076a9a2022-11-29 17:13:01 -0500203 }, [isAudioOn, localStream]);
simonf929a362022-11-18 16:53:45 -0500204
205 useEffect(() => {
simonf353ef42022-11-28 23:14:53 -0500206 if (localStream) {
MichelleSS55164202022-11-25 18:36:14 -0500207 for (const track of localStream.getVideoTracks()) {
simon1e2bf342022-12-02 12:19:40 -0500208 track.enabled = videoStatus === VideoStatus.Camera;
simon492e8402022-11-29 16:48:37 -0500209 const deviceId = track.getSettings().deviceId;
210 if (deviceId) {
211 setVideoDeviceId(deviceId);
212 }
MichelleSS55164202022-11-25 18:36:14 -0500213 }
simonff1cb352022-11-24 15:15:26 -0500214 }
simon1e2bf342022-12-02 12:19:40 -0500215 }, [videoStatus, localStream]);
216
217 const updateVideoStatus = useCallback(
218 async (newStatus: ((prevState: VideoStatus) => VideoStatus) | VideoStatus) => {
219 if (typeof newStatus === 'function') {
220 newStatus = newStatus(videoStatus);
221 }
222
223 const stream = await updateScreenShare(newStatus === VideoStatus.ScreenShare);
224 if (stream) {
225 for (const track of stream.getTracks()) {
226 track.addEventListener('ended', () => {
227 console.warn('Browser ended screen sharing');
228 updateVideoStatus(VideoStatus.Off);
229 });
230 }
231 }
232
233 setVideoStatus(newStatus);
234 },
235 [videoStatus, updateScreenShare]
236 );
simonf929a362022-11-18 16:53:45 -0500237
238 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500239 const onFullscreenChange = () => {
240 setIsFullscreen(document.fullscreenElement !== null);
241 };
242
243 document.addEventListener('fullscreenchange', onFullscreenChange);
244 return () => {
245 document.removeEventListener('fullscreenchange', onFullscreenChange);
246 };
247 }, []);
248
249 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500250 if (callRole === 'caller' && callStatus === CallStatus.Default) {
simone35acc22022-12-02 16:51:12 -0500251 const withVideoOn = callData?.withVideoOn ?? false;
simon9076a9a2022-11-29 17:13:01 -0500252 setCallStatus(CallStatus.Loading);
simon492e8402022-11-29 16:48:37 -0500253 updateLocalStream()
simon9076a9a2022-11-29 17:13:01 -0500254 .then(() => {
255 const callBegin: CallBegin = {
256 contactId: contactUri,
257 conversationId,
simon492e8402022-11-29 16:48:37 -0500258 withVideoOn,
simon9076a9a2022-11-29 17:13:01 -0500259 };
260
261 setCallStatus(CallStatus.Ringing);
simon1e2bf342022-12-02 12:19:40 -0500262 setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off);
simon9076a9a2022-11-29 17:13:01 -0500263 console.info('Sending CallBegin', callBegin);
264 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
265 })
266 .catch((e) => {
267 console.error(e);
268 setCallStatus(CallStatus.PermissionsDenied);
269 });
270 }
simone35acc22022-12-02 16:51:12 -0500271 }, [webSocket, updateLocalStream, callRole, callStatus, contactUri, conversationId, callData]);
simon9076a9a2022-11-29 17:13:01 -0500272
273 const acceptCall = useCallback(
274 (withVideoOn: boolean) => {
275 setCallStatus(CallStatus.Loading);
simon492e8402022-11-29 16:48:37 -0500276 updateLocalStream()
simon9076a9a2022-11-29 17:13:01 -0500277 .then(() => {
278 const callAccept: CallAction = {
279 contactId: contactUri,
280 conversationId,
281 };
282
simon1e2bf342022-12-02 12:19:40 -0500283 setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off);
simon9076a9a2022-11-29 17:13:01 -0500284 setCallStatus(CallStatus.Connecting);
285 console.info('Sending CallAccept', callAccept);
286 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
287 })
288 .catch((e) => {
289 console.error(e);
290 setCallStatus(CallStatus.PermissionsDenied);
291 });
292 },
simon492e8402022-11-29 16:48:37 -0500293 [webSocket, updateLocalStream, contactUri, conversationId]
simon9076a9a2022-11-29 17:13:01 -0500294 );
295
296 useEffect(() => {
simonf929a362022-11-18 16:53:45 -0500297 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
simonaccd8022022-11-24 15:04:53 -0500298 const callAcceptListener = (data: CallAction) => {
299 console.info('Received event on CallAccept', data);
Charliec18d6402022-11-27 13:01:04 -0500300 if (data.conversationId !== conversationId) {
301 console.warn('Wrong incoming conversationId, ignoring action');
302 return;
303 }
304
simonf929a362022-11-18 16:53:45 -0500305 setCallStatus(CallStatus.Connecting);
306
simon9076a9a2022-11-29 17:13:01 -0500307 sendWebRtcOffer();
simonf929a362022-11-18 16:53:45 -0500308 };
309
310 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
311
312 return () => {
313 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
314 };
315 }
simon9076a9a2022-11-29 17:13:01 -0500316 }, [callRole, webSocket, sendWebRtcOffer, callStatus, conversationId]);
simonf929a362022-11-18 16:53:45 -0500317
simon9076a9a2022-11-29 17:13:01 -0500318 const endCall = useCallback(() => {
319 const callEnd: CallAction = {
320 contactId: contactUri,
321 conversationId,
322 };
MichelleSS55164202022-11-25 18:36:14 -0500323
simon9076a9a2022-11-29 17:13:01 -0500324 console.info('Sending CallEnd', callEnd);
325 closeConnection();
326 webSocket.send(WebSocketMessageType.CallEnd, callEnd);
simone35acc22022-12-02 16:51:12 -0500327 exitCall();
simon9076a9a2022-11-29 17:13:01 -0500328 // TODO: write in chat that the call ended
simone35acc22022-12-02 16:51:12 -0500329 }, [webSocket, contactUri, conversationId, closeConnection, exitCall]);
simonaccd8022022-11-24 15:04:53 -0500330
331 useEffect(() => {
simonaccd8022022-11-24 15:04:53 -0500332 const callEndListener = (data: CallAction) => {
333 console.info('Received event on CallEnd', data);
Charliec18d6402022-11-27 13:01:04 -0500334 if (data.conversationId !== conversationId) {
335 console.warn('Wrong incoming conversationId, ignoring action');
336 return;
337 }
338
simon9076a9a2022-11-29 17:13:01 -0500339 closeConnection();
simone35acc22022-12-02 16:51:12 -0500340 exitCall();
simonaccd8022022-11-24 15:04:53 -0500341 // TODO: write in chat that the call ended
342 };
343
344 webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
345 return () => {
346 webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
347 };
simone35acc22022-12-02 16:51:12 -0500348 }, [webSocket, exitCall, conversationId, closeConnection]);
simonaccd8022022-11-24 15:04:53 -0500349
simonf929a362022-11-18 16:53:45 -0500350 useEffect(() => {
Charlieb837e8f2022-11-28 19:18:46 -0500351 if (
352 callStatus === CallStatus.Connecting &&
353 (iceConnectionState === 'connected' || iceConnectionState === 'completed')
354 ) {
simonf929a362022-11-18 16:53:45 -0500355 console.info('Changing call status to InCall');
356 setCallStatus(CallStatus.InCall);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500357 setCallStartTime(Date.now());
simonf929a362022-11-18 16:53:45 -0500358 }
simon9076a9a2022-11-29 17:13:01 -0500359 }, [iceConnectionState, callStatus]);
simonaccd8022022-11-24 15:04:53 -0500360
MichelleSS55164202022-11-25 18:36:14 -0500361 useEffect(() => {
Charlie380dc5e2022-11-29 16:51:42 -0500362 if (iceConnectionState === 'disconnected' || iceConnectionState === 'failed') {
363 console.info('ICE connection disconnected or failed, ending call');
Charlieb837e8f2022-11-28 19:18:46 -0500364 endCall();
365 }
simon1e2bf342022-12-02 12:19:40 -0500366 }, [iceConnectionState, callStatus, videoStatus, endCall]);
Charlieb837e8f2022-11-28 19:18:46 -0500367
368 useEffect(() => {
MichelleSS55164202022-11-25 18:36:14 -0500369 const checkStatusTimeout = () => {
370 if (callStatus !== CallStatus.InCall) {
371 endCall();
simonff1cb352022-11-24 15:15:26 -0500372 }
MichelleSS55164202022-11-25 18:36:14 -0500373 };
374 const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);
simonff1cb352022-11-24 15:15:26 -0500375
MichelleSS55164202022-11-25 18:36:14 -0500376 return () => {
377 clearTimeout(timeoutId);
378 };
379 }, [callStatus, endCall]);
simonff1cb352022-11-24 15:15:26 -0500380
simon492e8402022-11-29 16:48:37 -0500381 const currentMediaDeviceIds: CurrentMediaDeviceIds = useMemo(() => {
382 const createSetIdForDeviceKind = (mediaInputKind: MediaInputKind) => async (id: string | undefined) => {
383 const mediaDeviceIds = {
384 audio: audioInputDeviceId,
385 video: videoDeviceId,
386 };
387
388 mediaDeviceIds[mediaInputKind] = id;
389
390 await updateLocalStream(mediaDeviceIds);
391 };
392
393 return {
394 audioinput: {
395 id: audioInputDeviceId,
396 setId: createSetIdForDeviceKind('audio'),
397 },
398 audiooutput: {
399 id: audioOutputDeviceId,
400 setId: setAudioOutputDeviceId,
401 },
402 videoinput: {
403 id: videoDeviceId,
404 setId: createSetIdForDeviceKind('video'),
405 },
406 };
407 }, [updateLocalStream, audioInputDeviceId, audioOutputDeviceId, videoDeviceId]);
408
simone35acc22022-12-02 16:51:12 -0500409 if (!callData || !callRole) {
simonff1cb352022-11-24 15:15:26 -0500410 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500411 return <Navigate to={'/'} />;
412 }
413
414 return (
415 <CallContext.Provider
416 value={{
simon492e8402022-11-29 16:48:37 -0500417 mediaDevices,
418 currentMediaDeviceIds,
simonf929a362022-11-18 16:53:45 -0500419 isAudioOn,
simon9076a9a2022-11-29 17:13:01 -0500420 setIsAudioOn,
simon1e2bf342022-12-02 12:19:40 -0500421 videoStatus,
422 updateVideoStatus,
simonf9d78f22022-11-25 15:47:15 -0500423 isChatShown,
424 setIsChatShown,
simon2a5cf142022-11-25 15:47:35 -0500425 isFullscreen,
426 setIsFullscreen,
simonf929a362022-11-18 16:53:45 -0500427 callRole,
428 callStatus,
Gabriel Rochone382a302022-11-23 12:37:04 -0500429 callStartTime,
simonf929a362022-11-18 16:53:45 -0500430 acceptCall,
simonaccd8022022-11-24 15:04:53 -0500431 endCall,
simonf929a362022-11-18 16:53:45 -0500432 }}
433 >
simone35acc22022-12-02 16:51:12 -0500434 {children}
simonf929a362022-11-18 16:53:45 -0500435 </CallContext.Provider>
436 );
437};