blob: f54b75f9c78a9a4e77da30b982453683a64b4ddd [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';
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -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';
24import { CallRouteParams } from '../router';
MichelleSS55164202022-11-25 18:36:14 -050025import { callTimeoutMs } from '../utils/constants';
simonf9d78f22022-11-25 15:47:15 -050026import { SetState, WithChildren } from '../utils/utils';
simonf929a362022-11-18 16:53:45 -050027import { ConversationContext } from './ConversationProvider';
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050028import { WebRtcContext } from './WebRtcProvider';
simonf353ef42022-11-28 23:14:53 -050029import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
simonf929a362022-11-18 16:53:45 -050030
31export type CallRole = 'caller' | 'receiver';
32
33export enum CallStatus {
simonff1cb352022-11-24 15:15:26 -050034 Default,
simonf929a362022-11-18 16:53:45 -050035 Ringing,
36 Connecting,
37 InCall,
38}
39
40export interface ICallContext {
41 mediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]>;
42
43 localStream: MediaStream | undefined;
44 remoteStream: MediaStream | undefined; // TODO: should be an array of participants. find a way to map MediaStream id to contactid https://stackoverflow.com/a/68663155/6592293
45
46 isAudioOn: boolean;
47 setAudioStatus: (isOn: boolean) => void;
48 isVideoOn: boolean;
49 setVideoStatus: (isOn: boolean) => void;
simonf9d78f22022-11-25 15:47:15 -050050 isChatShown: boolean;
51 setIsChatShown: SetState<boolean>;
simon2a5cf142022-11-25 15:47:35 -050052 isFullscreen: boolean;
53 setIsFullscreen: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050054 callRole: CallRole;
55 callStatus: CallStatus;
Gabriel Rochone382a302022-11-23 12:37:04 -050056 callStartTime: Date | undefined;
simonf929a362022-11-18 16:53:45 -050057
MichelleSS55164202022-11-25 18:36:14 -050058 acceptCall: (withVideoOn: boolean) => void;
simonaccd8022022-11-24 15:04:53 -050059 endCall: () => void;
simonf929a362022-11-18 16:53:45 -050060}
61
62const defaultCallContext: ICallContext = {
63 mediaDevices: {
64 audioinput: [],
65 audiooutput: [],
66 videoinput: [],
67 },
68
69 localStream: undefined,
70 remoteStream: undefined,
71
72 isAudioOn: false,
73 setAudioStatus: () => {},
74 isVideoOn: false,
75 setVideoStatus: () => {},
simonf9d78f22022-11-25 15:47:15 -050076 isChatShown: false,
77 setIsChatShown: () => {},
simon2a5cf142022-11-25 15:47:35 -050078 isFullscreen: false,
79 setIsFullscreen: () => {},
simonf929a362022-11-18 16:53:45 -050080 callRole: 'caller',
simonff1cb352022-11-24 15:15:26 -050081 callStatus: CallStatus.Default,
Gabriel Rochone382a302022-11-23 12:37:04 -050082 callStartTime: undefined,
simonf929a362022-11-18 16:53:45 -050083
MichelleSS55164202022-11-25 18:36:14 -050084 acceptCall: (_: boolean) => {},
simonaccd8022022-11-24 15:04:53 -050085 endCall: () => {},
simonf929a362022-11-18 16:53:45 -050086};
87
88export const CallContext = createContext<ICallContext>(defaultCallContext);
89
90export default ({ children }: WithChildren) => {
simonf353ef42022-11-28 23:14:53 -050091 const webSocket = useContext(WebSocketContext);
92 const { webRtcConnection } = useContext(WebRtcContext);
93
94 if (!webSocket || !webRtcConnection) {
95 return <LoadingPage />;
96 }
97
98 return (
99 <CallProvider webSocket={webSocket} webRtcConnection={webRtcConnection}>
100 {children}
101 </CallProvider>
102 );
103};
104
105const CallProvider = ({
106 children,
107 webSocket,
108 webRtcConnection,
109}: WithChildren & {
110 webSocket: IWebSocketContext;
111 webRtcConnection: RTCPeerConnection;
112}) => {
Charlieb837e8f2022-11-28 19:18:46 -0500113 const { state: routeState } = useUrlParams<CallRouteParams>();
114 const { remoteStreams, sendWebRtcOffer, iceConnectionState } = useContext(WebRtcContext);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500115 const { conversationId, conversation } = useContext(ConversationContext);
simonaccd8022022-11-24 15:04:53 -0500116 const navigate = useNavigate();
simonf929a362022-11-18 16:53:45 -0500117
118 const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>(
119 defaultCallContext.mediaDevices
120 );
121 const [localStream, setLocalStream] = useState<MediaStream>();
122
123 const [isAudioOn, setIsAudioOn] = useState(false);
124 const [isVideoOn, setIsVideoOn] = useState(false);
simonf9d78f22022-11-25 15:47:15 -0500125 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500126 const [isFullscreen, setIsFullscreen] = useState(false);
simonff1cb352022-11-24 15:15:26 -0500127 const [callStatus, setCallStatus] = useState(routeState?.callStatus);
Charlieb837e8f2022-11-28 19:18:46 -0500128 const [callRole] = useState(routeState?.role);
Gabriel Rochone382a302022-11-23 12:37:04 -0500129 const [callStartTime, setCallStartTime] = useState<Date | undefined>(undefined);
simonf929a362022-11-18 16:53:45 -0500130
simonff1cb352022-11-24 15:15:26 -0500131 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
132 // The client could make a single request with the conversationId, and the server would be tasked with sending
133 // all the individual requests to the members of the conversation.
simonf929a362022-11-18 16:53:45 -0500134 const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
135
136 useEffect(() => {
simonf929a362022-11-18 16:53:45 -0500137 try {
simonfeaa1db2022-11-26 20:13:18 -0500138 // TODO: Wait until status is `InCall` before getting devices
139 navigator.mediaDevices.enumerateDevices().then((devices) => {
140 const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
141 audioinput: [],
142 audiooutput: [],
143 videoinput: [],
144 };
145
146 for (const device of devices) {
147 newMediaDevices[device.kind].push(device);
148 }
149
150 setMediaDevices(newMediaDevices);
151 });
152 } catch (e) {
153 console.error('Could not get media devices:', e);
154 }
155
156 try {
simonf929a362022-11-18 16:53:45 -0500157 navigator.mediaDevices
158 .getUserMedia({
159 audio: true, // TODO: Set both to false by default
160 video: true,
161 })
162 .then((stream) => {
163 for (const track of stream.getTracks()) {
164 // TODO: Set default from isVideoOn and isMicOn values
165 track.enabled = false;
166 }
167 setLocalStream(stream);
168 });
169 } catch (e) {
170 // TODO: Better handle user denial
171 console.error('Could not get media devices:', e);
172 }
simon8b4756f2022-11-27 17:15:50 -0500173 }, []);
simonf929a362022-11-18 16:53:45 -0500174
175 useEffect(() => {
simonf353ef42022-11-28 23:14:53 -0500176 if (localStream) {
simonf929a362022-11-18 16:53:45 -0500177 for (const track of localStream.getTracks()) {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500178 webRtcConnection.addTrack(track, localStream);
simonf929a362022-11-18 16:53:45 -0500179 }
180 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500181 }, [localStream, webRtcConnection]);
simonf929a362022-11-18 16:53:45 -0500182
MichelleSS55164202022-11-25 18:36:14 -0500183 const setAudioStatus = useCallback(
184 (isOn: boolean) => {
185 if (!localStream) {
186 return;
187 }
188
189 for (const track of localStream.getAudioTracks()) {
190 track.enabled = isOn;
191 }
192
193 setIsAudioOn(isOn);
194 },
195 [localStream]
196 );
197
198 const setVideoStatus = useCallback(
199 (isOn: boolean) => {
200 if (!localStream) {
201 return;
202 }
203
204 for (const track of localStream.getVideoTracks()) {
205 track.enabled = isOn;
206 }
207
208 setIsVideoOn(isOn);
209 },
210 [localStream]
211 );
212
simonff1cb352022-11-24 15:15:26 -0500213 useEffect(() => {
simonff1cb352022-11-24 15:15:26 -0500214 if (callRole === 'caller' && callStatus === CallStatus.Default) {
MichelleSS55164202022-11-25 18:36:14 -0500215 const callBegin: CallBegin = {
simonff1cb352022-11-24 15:15:26 -0500216 contactId: contactUri,
217 conversationId,
MichelleSS55164202022-11-25 18:36:14 -0500218 withVideoOn: routeState?.isVideoOn ?? false,
simonff1cb352022-11-24 15:15:26 -0500219 };
simonf929a362022-11-18 16:53:45 -0500220
simonff1cb352022-11-24 15:15:26 -0500221 console.info('Sending CallBegin', callBegin);
222 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
223 setCallStatus(CallStatus.Ringing);
MichelleSS55164202022-11-25 18:36:14 -0500224 setIsVideoOn(routeState?.isVideoOn ?? false);
simonff1cb352022-11-24 15:15:26 -0500225 }
MichelleSS55164202022-11-25 18:36:14 -0500226 }, [webSocket, callRole, callStatus, contactUri, conversationId, routeState]);
simonf929a362022-11-18 16:53:45 -0500227
228 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500229 const onFullscreenChange = () => {
230 setIsFullscreen(document.fullscreenElement !== null);
231 };
232
233 document.addEventListener('fullscreenchange', onFullscreenChange);
234 return () => {
235 document.removeEventListener('fullscreenchange', onFullscreenChange);
236 };
237 }, []);
238
239 useEffect(() => {
simonf929a362022-11-18 16:53:45 -0500240 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
simonaccd8022022-11-24 15:04:53 -0500241 const callAcceptListener = (data: CallAction) => {
242 console.info('Received event on CallAccept', data);
Charliec18d6402022-11-27 13:01:04 -0500243 if (data.conversationId !== conversationId) {
244 console.warn('Wrong incoming conversationId, ignoring action');
245 return;
246 }
247
simonf929a362022-11-18 16:53:45 -0500248 setCallStatus(CallStatus.Connecting);
249
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500250 webRtcConnection
simonf929a362022-11-18 16:53:45 -0500251 .createOffer({
252 offerToReceiveAudio: true,
253 offerToReceiveVideo: true,
254 })
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500255 .then((sdp) => {
256 sendWebRtcOffer(sdp);
simonf929a362022-11-18 16:53:45 -0500257 });
258 };
259
260 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
261
262 return () => {
263 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
264 };
265 }
Charliec18d6402022-11-27 13:01:04 -0500266 }, [callRole, webSocket, webRtcConnection, sendWebRtcOffer, callStatus, conversationId]);
simonf929a362022-11-18 16:53:45 -0500267
simonaccd8022022-11-24 15:04:53 -0500268 const quitCall = useCallback(() => {
MichelleSS55164202022-11-25 18:36:14 -0500269 const localTracks = localStream?.getTracks();
270 if (localTracks) {
271 for (const track of localTracks) {
272 track.stop();
273 }
274 }
275
simonaccd8022022-11-24 15:04:53 -0500276 webRtcConnection.close();
277 navigate(`/conversation/${conversationId}`);
MichelleSS55164202022-11-25 18:36:14 -0500278 }, [webRtcConnection, localStream, navigate, conversationId]);
simonaccd8022022-11-24 15:04:53 -0500279
280 useEffect(() => {
simonaccd8022022-11-24 15:04:53 -0500281 const callEndListener = (data: CallAction) => {
282 console.info('Received event on CallEnd', data);
Charliec18d6402022-11-27 13:01:04 -0500283 if (data.conversationId !== conversationId) {
284 console.warn('Wrong incoming conversationId, ignoring action');
285 return;
286 }
287
simonaccd8022022-11-24 15:04:53 -0500288 quitCall();
289 // TODO: write in chat that the call ended
290 };
291
292 webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
293 return () => {
294 webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
295 };
296 }, [webSocket, navigate, conversationId, quitCall]);
297
simonf929a362022-11-18 16:53:45 -0500298 useEffect(() => {
Charlieb837e8f2022-11-28 19:18:46 -0500299 if (
300 callStatus === CallStatus.Connecting &&
301 (iceConnectionState === 'connected' || iceConnectionState === 'completed')
302 ) {
simonf929a362022-11-18 16:53:45 -0500303 console.info('Changing call status to InCall');
304 setCallStatus(CallStatus.InCall);
MichelleSS55164202022-11-25 18:36:14 -0500305 setVideoStatus(isVideoOn);
Gabriel Rochone382a302022-11-23 12:37:04 -0500306 setCallStartTime(new Date());
simonf929a362022-11-18 16:53:45 -0500307 }
Charlieb837e8f2022-11-28 19:18:46 -0500308 }, [iceConnectionState, callStatus, setVideoStatus, isVideoOn]);
simonf929a362022-11-18 16:53:45 -0500309
MichelleSS55164202022-11-25 18:36:14 -0500310 const acceptCall = useCallback(
311 (withVideoOn: boolean) => {
MichelleSS55164202022-11-25 18:36:14 -0500312 const callAccept: CallAction = {
313 contactId: contactUri,
314 conversationId,
315 };
simonf929a362022-11-18 16:53:45 -0500316
MichelleSS55164202022-11-25 18:36:14 -0500317 console.info('Sending CallAccept', callAccept);
318 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
319 setIsVideoOn(withVideoOn);
320 setCallStatus(CallStatus.Connecting);
321 },
322 [webSocket, contactUri, conversationId]
323 );
simonf929a362022-11-18 16:53:45 -0500324
simonaccd8022022-11-24 15:04:53 -0500325 const endCall = useCallback(() => {
simonaccd8022022-11-24 15:04:53 -0500326 const callEnd: CallAction = {
327 contactId: contactUri,
328 conversationId,
329 };
330
331 console.info('Sending CallEnd', callEnd);
332 webSocket.send(WebSocketMessageType.CallEnd, callEnd);
333 quitCall();
334 // TODO: write in chat that the call ended
335 }, [webSocket, contactUri, conversationId, quitCall]);
336
MichelleSS55164202022-11-25 18:36:14 -0500337 useEffect(() => {
Charlieb837e8f2022-11-28 19:18:46 -0500338 if (iceConnectionState === 'disconnected') {
339 console.info('ICE connection disconnected');
340 endCall();
341 }
342 }, [iceConnectionState, callStatus, setVideoStatus, isVideoOn, endCall]);
343
344 useEffect(() => {
MichelleSS55164202022-11-25 18:36:14 -0500345 const checkStatusTimeout = () => {
346 if (callStatus !== CallStatus.InCall) {
347 endCall();
simonff1cb352022-11-24 15:15:26 -0500348 }
MichelleSS55164202022-11-25 18:36:14 -0500349 };
350 const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);
simonff1cb352022-11-24 15:15:26 -0500351
MichelleSS55164202022-11-25 18:36:14 -0500352 return () => {
353 clearTimeout(timeoutId);
354 };
355 }, [callStatus, endCall]);
simonff1cb352022-11-24 15:15:26 -0500356
Charlieb837e8f2022-11-28 19:18:46 -0500357 useEffect(() => {
358 navigate('.', {
359 replace: true,
360 state: {},
361 });
362 }, [navigate]);
363
simonff1cb352022-11-24 15:15:26 -0500364 if (!callRole || callStatus === undefined) {
365 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500366 return <Navigate to={'/'} />;
367 }
368
369 return (
370 <CallContext.Provider
371 value={{
372 mediaDevices,
373 localStream,
374 remoteStream: remoteStreams?.at(-1),
375 isAudioOn,
376 setAudioStatus,
377 isVideoOn,
378 setVideoStatus,
simonf9d78f22022-11-25 15:47:15 -0500379 isChatShown,
380 setIsChatShown,
simon2a5cf142022-11-25 15:47:35 -0500381 isFullscreen,
382 setIsFullscreen,
simonf929a362022-11-18 16:53:45 -0500383 callRole,
384 callStatus,
Gabriel Rochone382a302022-11-23 12:37:04 -0500385 callStartTime,
simonf929a362022-11-18 16:53:45 -0500386 acceptCall,
simonaccd8022022-11-24 15:04:53 -0500387 endCall,
simonf929a362022-11-18 16:53:45 -0500388 }}
389 >
390 {children}
391 </CallContext.Provider>
392 );
393};