blob: e60250821db7e5dd8019ea625dfe61179bb75ead [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
22import { useUrlParams } from '../hooks/useUrlParams';
23import { CallRouteParams } from '../router';
MichelleSS55164202022-11-25 18:36:14 -050024import { callTimeoutMs } from '../utils/constants';
simonf9d78f22022-11-25 15:47:15 -050025import { SetState, WithChildren } from '../utils/utils';
simonf929a362022-11-18 16:53:45 -050026import { ConversationContext } from './ConversationProvider';
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050027import { WebRtcContext } from './WebRtcProvider';
simonf929a362022-11-18 16:53:45 -050028import { WebSocketContext } from './WebSocketProvider';
29
30export type CallRole = 'caller' | 'receiver';
31
32export enum CallStatus {
simonff1cb352022-11-24 15:15:26 -050033 Default,
simonf929a362022-11-18 16:53:45 -050034 Ringing,
35 Connecting,
36 InCall,
37}
38
39export interface ICallContext {
40 mediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]>;
41
42 localStream: MediaStream | undefined;
43 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
44
45 isAudioOn: boolean;
46 setAudioStatus: (isOn: boolean) => void;
47 isVideoOn: boolean;
48 setVideoStatus: (isOn: boolean) => void;
simonf9d78f22022-11-25 15:47:15 -050049 isChatShown: boolean;
50 setIsChatShown: SetState<boolean>;
simon2a5cf142022-11-25 15:47:35 -050051 isFullscreen: boolean;
52 setIsFullscreen: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050053 callRole: CallRole;
54 callStatus: CallStatus;
Gabriel Rochone382a302022-11-23 12:37:04 -050055 callStartTime: Date | undefined;
simonf929a362022-11-18 16:53:45 -050056
MichelleSS55164202022-11-25 18:36:14 -050057 acceptCall: (withVideoOn: boolean) => void;
simonaccd8022022-11-24 15:04:53 -050058 endCall: () => void;
simonf929a362022-11-18 16:53:45 -050059}
60
61const defaultCallContext: ICallContext = {
62 mediaDevices: {
63 audioinput: [],
64 audiooutput: [],
65 videoinput: [],
66 },
67
68 localStream: undefined,
69 remoteStream: undefined,
70
71 isAudioOn: false,
72 setAudioStatus: () => {},
73 isVideoOn: false,
74 setVideoStatus: () => {},
simonf9d78f22022-11-25 15:47:15 -050075 isChatShown: false,
76 setIsChatShown: () => {},
simon2a5cf142022-11-25 15:47:35 -050077 isFullscreen: false,
78 setIsFullscreen: () => {},
simonf929a362022-11-18 16:53:45 -050079 callRole: 'caller',
simonff1cb352022-11-24 15:15:26 -050080 callStatus: CallStatus.Default,
Gabriel Rochone382a302022-11-23 12:37:04 -050081 callStartTime: undefined,
simonf929a362022-11-18 16:53:45 -050082
MichelleSS55164202022-11-25 18:36:14 -050083 acceptCall: (_: boolean) => {},
simonaccd8022022-11-24 15:04:53 -050084 endCall: () => {},
simonf929a362022-11-18 16:53:45 -050085};
86
87export const CallContext = createContext<ICallContext>(defaultCallContext);
88
89export default ({ children }: WithChildren) => {
90 const {
91 queryParams: { role: callRole },
simonff1cb352022-11-24 15:15:26 -050092 state: routeState,
simonf929a362022-11-18 16:53:45 -050093 } = useUrlParams<CallRouteParams>();
simonf929a362022-11-18 16:53:45 -050094 const webSocket = useContext(WebSocketContext);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050095 const { webRtcConnection, remoteStreams, sendWebRtcOffer, isConnected } = useContext(WebRtcContext);
96 const { conversationId, conversation } = useContext(ConversationContext);
simonaccd8022022-11-24 15:04:53 -050097 const navigate = useNavigate();
simonf929a362022-11-18 16:53:45 -050098
99 const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>(
100 defaultCallContext.mediaDevices
101 );
102 const [localStream, setLocalStream] = useState<MediaStream>();
103
104 const [isAudioOn, setIsAudioOn] = useState(false);
105 const [isVideoOn, setIsVideoOn] = useState(false);
simonf9d78f22022-11-25 15:47:15 -0500106 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500107 const [isFullscreen, setIsFullscreen] = useState(false);
simonff1cb352022-11-24 15:15:26 -0500108 const [callStatus, setCallStatus] = useState(routeState?.callStatus);
Gabriel Rochone382a302022-11-23 12:37:04 -0500109 const [callStartTime, setCallStartTime] = useState<Date | undefined>(undefined);
simonf929a362022-11-18 16:53:45 -0500110
simonff1cb352022-11-24 15:15:26 -0500111 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
112 // The client could make a single request with the conversationId, and the server would be tasked with sending
113 // all the individual requests to the members of the conversation.
simonf929a362022-11-18 16:53:45 -0500114 const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
115
116 useEffect(() => {
simonf929a362022-11-18 16:53:45 -0500117 try {
simonfeaa1db2022-11-26 20:13:18 -0500118 // TODO: Wait until status is `InCall` before getting devices
119 navigator.mediaDevices.enumerateDevices().then((devices) => {
120 const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
121 audioinput: [],
122 audiooutput: [],
123 videoinput: [],
124 };
125
126 for (const device of devices) {
127 newMediaDevices[device.kind].push(device);
128 }
129
130 setMediaDevices(newMediaDevices);
131 });
132 } catch (e) {
133 console.error('Could not get media devices:', e);
134 }
135
136 try {
simonf929a362022-11-18 16:53:45 -0500137 navigator.mediaDevices
138 .getUserMedia({
139 audio: true, // TODO: Set both to false by default
140 video: true,
141 })
142 .then((stream) => {
143 for (const track of stream.getTracks()) {
144 // TODO: Set default from isVideoOn and isMicOn values
145 track.enabled = false;
146 }
147 setLocalStream(stream);
148 });
149 } catch (e) {
150 // TODO: Better handle user denial
151 console.error('Could not get media devices:', e);
152 }
simon8b4756f2022-11-27 17:15:50 -0500153 }, []);
simonf929a362022-11-18 16:53:45 -0500154
155 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500156 if (localStream && webRtcConnection) {
simonf929a362022-11-18 16:53:45 -0500157 for (const track of localStream.getTracks()) {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500158 webRtcConnection.addTrack(track, localStream);
simonf929a362022-11-18 16:53:45 -0500159 }
160 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500161 }, [localStream, webRtcConnection]);
simonf929a362022-11-18 16:53:45 -0500162
MichelleSS55164202022-11-25 18:36:14 -0500163 const setAudioStatus = useCallback(
164 (isOn: boolean) => {
165 if (!localStream) {
166 return;
167 }
168
169 for (const track of localStream.getAudioTracks()) {
170 track.enabled = isOn;
171 }
172
173 setIsAudioOn(isOn);
174 },
175 [localStream]
176 );
177
178 const setVideoStatus = useCallback(
179 (isOn: boolean) => {
180 if (!localStream) {
181 return;
182 }
183
184 for (const track of localStream.getVideoTracks()) {
185 track.enabled = isOn;
186 }
187
188 setIsVideoOn(isOn);
189 },
190 [localStream]
191 );
192
simonff1cb352022-11-24 15:15:26 -0500193 useEffect(() => {
194 if (!webSocket) {
195 return;
196 }
simonf929a362022-11-18 16:53:45 -0500197
simonff1cb352022-11-24 15:15:26 -0500198 if (callRole === 'caller' && callStatus === CallStatus.Default) {
MichelleSS55164202022-11-25 18:36:14 -0500199 const callBegin: CallBegin = {
simonff1cb352022-11-24 15:15:26 -0500200 contactId: contactUri,
201 conversationId,
MichelleSS55164202022-11-25 18:36:14 -0500202 withVideoOn: routeState?.isVideoOn ?? false,
simonff1cb352022-11-24 15:15:26 -0500203 };
simonf929a362022-11-18 16:53:45 -0500204
simonff1cb352022-11-24 15:15:26 -0500205 console.info('Sending CallBegin', callBegin);
206 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
207 setCallStatus(CallStatus.Ringing);
MichelleSS55164202022-11-25 18:36:14 -0500208 setIsVideoOn(routeState?.isVideoOn ?? false);
simonff1cb352022-11-24 15:15:26 -0500209 }
MichelleSS55164202022-11-25 18:36:14 -0500210 }, [webSocket, callRole, callStatus, contactUri, conversationId, routeState]);
simonf929a362022-11-18 16:53:45 -0500211
212 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500213 const onFullscreenChange = () => {
214 setIsFullscreen(document.fullscreenElement !== null);
215 };
216
217 document.addEventListener('fullscreenchange', onFullscreenChange);
218 return () => {
219 document.removeEventListener('fullscreenchange', onFullscreenChange);
220 };
221 }, []);
222
223 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500224 if (!webSocket || !webRtcConnection) {
simonf929a362022-11-18 16:53:45 -0500225 return;
226 }
227
228 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
simonaccd8022022-11-24 15:04:53 -0500229 const callAcceptListener = (data: CallAction) => {
230 console.info('Received event on CallAccept', data);
Charliec18d6402022-11-27 13:01:04 -0500231 if (data.conversationId !== conversationId) {
232 console.warn('Wrong incoming conversationId, ignoring action');
233 return;
234 }
235
simonf929a362022-11-18 16:53:45 -0500236 setCallStatus(CallStatus.Connecting);
237
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500238 webRtcConnection
simonf929a362022-11-18 16:53:45 -0500239 .createOffer({
240 offerToReceiveAudio: true,
241 offerToReceiveVideo: true,
242 })
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500243 .then((sdp) => {
244 sendWebRtcOffer(sdp);
simonf929a362022-11-18 16:53:45 -0500245 });
246 };
247
248 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
249
250 return () => {
251 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
252 };
253 }
Charliec18d6402022-11-27 13:01:04 -0500254 }, [callRole, webSocket, webRtcConnection, sendWebRtcOffer, callStatus, conversationId]);
simonf929a362022-11-18 16:53:45 -0500255
simonaccd8022022-11-24 15:04:53 -0500256 const quitCall = useCallback(() => {
257 if (!webRtcConnection) {
258 throw new Error('Could not quit call: webRtcConnection is not defined');
259 }
260
MichelleSS55164202022-11-25 18:36:14 -0500261 const localTracks = localStream?.getTracks();
262 if (localTracks) {
263 for (const track of localTracks) {
264 track.stop();
265 }
266 }
267
simonaccd8022022-11-24 15:04:53 -0500268 webRtcConnection.close();
269 navigate(`/conversation/${conversationId}`);
MichelleSS55164202022-11-25 18:36:14 -0500270 }, [webRtcConnection, localStream, navigate, conversationId]);
simonaccd8022022-11-24 15:04:53 -0500271
272 useEffect(() => {
273 if (!webSocket) {
274 return;
275 }
276
277 const callEndListener = (data: CallAction) => {
278 console.info('Received event on CallEnd', data);
Charliec18d6402022-11-27 13:01:04 -0500279 if (data.conversationId !== conversationId) {
280 console.warn('Wrong incoming conversationId, ignoring action');
281 return;
282 }
283
simonaccd8022022-11-24 15:04:53 -0500284 quitCall();
285 // TODO: write in chat that the call ended
286 };
287
288 webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
289 return () => {
290 webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
291 };
292 }, [webSocket, navigate, conversationId, quitCall]);
293
simonf929a362022-11-18 16:53:45 -0500294 useEffect(() => {
295 if (callStatus === CallStatus.Connecting && isConnected) {
296 console.info('Changing call status to InCall');
297 setCallStatus(CallStatus.InCall);
MichelleSS55164202022-11-25 18:36:14 -0500298 setVideoStatus(isVideoOn);
Gabriel Rochone382a302022-11-23 12:37:04 -0500299 setCallStartTime(new Date());
simonf929a362022-11-18 16:53:45 -0500300 }
MichelleSS55164202022-11-25 18:36:14 -0500301 }, [isConnected, callStatus, setVideoStatus, isVideoOn]);
simonf929a362022-11-18 16:53:45 -0500302
MichelleSS55164202022-11-25 18:36:14 -0500303 const acceptCall = useCallback(
304 (withVideoOn: boolean) => {
305 if (!webSocket) {
306 throw new Error('Could not accept call');
307 }
simonf929a362022-11-18 16:53:45 -0500308
MichelleSS55164202022-11-25 18:36:14 -0500309 const callAccept: CallAction = {
310 contactId: contactUri,
311 conversationId,
312 };
simonf929a362022-11-18 16:53:45 -0500313
MichelleSS55164202022-11-25 18:36:14 -0500314 console.info('Sending CallAccept', callAccept);
315 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
316 setIsVideoOn(withVideoOn);
317 setCallStatus(CallStatus.Connecting);
318 },
319 [webSocket, contactUri, conversationId]
320 );
simonf929a362022-11-18 16:53:45 -0500321
simonaccd8022022-11-24 15:04:53 -0500322 const endCall = useCallback(() => {
323 if (!webSocket) {
324 throw new Error('Could not end call');
325 }
326
327 const callEnd: CallAction = {
328 contactId: contactUri,
329 conversationId,
330 };
331
332 console.info('Sending CallEnd', callEnd);
333 webSocket.send(WebSocketMessageType.CallEnd, callEnd);
334 quitCall();
335 // TODO: write in chat that the call ended
336 }, [webSocket, contactUri, conversationId, quitCall]);
337
MichelleSS55164202022-11-25 18:36:14 -0500338 useEffect(() => {
339 const checkStatusTimeout = () => {
340 if (callStatus !== CallStatus.InCall) {
341 endCall();
simonff1cb352022-11-24 15:15:26 -0500342 }
MichelleSS55164202022-11-25 18:36:14 -0500343 };
344 const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);
simonff1cb352022-11-24 15:15:26 -0500345
MichelleSS55164202022-11-25 18:36:14 -0500346 return () => {
347 clearTimeout(timeoutId);
348 };
349 }, [callStatus, endCall]);
simonff1cb352022-11-24 15:15:26 -0500350
351 if (!callRole || callStatus === undefined) {
352 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500353 return <Navigate to={'/'} />;
354 }
355
356 return (
357 <CallContext.Provider
358 value={{
359 mediaDevices,
360 localStream,
361 remoteStream: remoteStreams?.at(-1),
362 isAudioOn,
363 setAudioStatus,
364 isVideoOn,
365 setVideoStatus,
simonf9d78f22022-11-25 15:47:15 -0500366 isChatShown,
367 setIsChatShown,
simon2a5cf142022-11-25 15:47:35 -0500368 isFullscreen,
369 setIsFullscreen,
simonf929a362022-11-18 16:53:45 -0500370 callRole,
371 callStatus,
Gabriel Rochone382a302022-11-23 12:37:04 -0500372 callStartTime,
simonf929a362022-11-18 16:53:45 -0500373 acceptCall,
simonaccd8022022-11-24 15:04:53 -0500374 endCall,
simonf929a362022-11-18 16:53:45 -0500375 }}
376 >
377 {children}
378 </CallContext.Provider>
379 );
380};