blob: 6b8d4eee75da354902d8ba21dfb8a89f1eea0622 [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(() => {
simonfeaa1db2022-11-26 20:13:18 -0500117 if (!isConnected) {
118 return;
119 }
simonf929a362022-11-18 16:53:45 -0500120
simonf929a362022-11-18 16:53:45 -0500121 try {
simonfeaa1db2022-11-26 20:13:18 -0500122 // TODO: Wait until status is `InCall` before getting devices
123 navigator.mediaDevices.enumerateDevices().then((devices) => {
124 const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
125 audioinput: [],
126 audiooutput: [],
127 videoinput: [],
128 };
129
130 for (const device of devices) {
131 newMediaDevices[device.kind].push(device);
132 }
133
134 setMediaDevices(newMediaDevices);
135 });
136 } catch (e) {
137 console.error('Could not get media devices:', e);
138 }
139
140 try {
simonf929a362022-11-18 16:53:45 -0500141 navigator.mediaDevices
142 .getUserMedia({
143 audio: true, // TODO: Set both to false by default
144 video: true,
145 })
146 .then((stream) => {
147 for (const track of stream.getTracks()) {
148 // TODO: Set default from isVideoOn and isMicOn values
149 track.enabled = false;
150 }
151 setLocalStream(stream);
152 });
153 } catch (e) {
154 // TODO: Better handle user denial
155 console.error('Could not get media devices:', e);
156 }
simonfeaa1db2022-11-26 20:13:18 -0500157 }, [isConnected]);
simonf929a362022-11-18 16:53:45 -0500158
159 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500160 if (localStream && webRtcConnection) {
simonf929a362022-11-18 16:53:45 -0500161 for (const track of localStream.getTracks()) {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500162 webRtcConnection.addTrack(track, localStream);
simonf929a362022-11-18 16:53:45 -0500163 }
164 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500165 }, [localStream, webRtcConnection]);
simonf929a362022-11-18 16:53:45 -0500166
MichelleSS55164202022-11-25 18:36:14 -0500167 const setAudioStatus = useCallback(
168 (isOn: boolean) => {
169 if (!localStream) {
170 return;
171 }
172
173 for (const track of localStream.getAudioTracks()) {
174 track.enabled = isOn;
175 }
176
177 setIsAudioOn(isOn);
178 },
179 [localStream]
180 );
181
182 const setVideoStatus = useCallback(
183 (isOn: boolean) => {
184 if (!localStream) {
185 return;
186 }
187
188 for (const track of localStream.getVideoTracks()) {
189 track.enabled = isOn;
190 }
191
192 setIsVideoOn(isOn);
193 },
194 [localStream]
195 );
196
simonff1cb352022-11-24 15:15:26 -0500197 useEffect(() => {
198 if (!webSocket) {
199 return;
200 }
simonf929a362022-11-18 16:53:45 -0500201
simonff1cb352022-11-24 15:15:26 -0500202 if (callRole === 'caller' && callStatus === CallStatus.Default) {
MichelleSS55164202022-11-25 18:36:14 -0500203 const callBegin: CallBegin = {
simonff1cb352022-11-24 15:15:26 -0500204 contactId: contactUri,
205 conversationId,
MichelleSS55164202022-11-25 18:36:14 -0500206 withVideoOn: routeState?.isVideoOn ?? false,
simonff1cb352022-11-24 15:15:26 -0500207 };
simonf929a362022-11-18 16:53:45 -0500208
simonff1cb352022-11-24 15:15:26 -0500209 console.info('Sending CallBegin', callBegin);
210 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
211 setCallStatus(CallStatus.Ringing);
MichelleSS55164202022-11-25 18:36:14 -0500212 setIsVideoOn(routeState?.isVideoOn ?? false);
simonff1cb352022-11-24 15:15:26 -0500213 }
MichelleSS55164202022-11-25 18:36:14 -0500214 }, [webSocket, callRole, callStatus, contactUri, conversationId, routeState]);
simonf929a362022-11-18 16:53:45 -0500215
216 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500217 const onFullscreenChange = () => {
218 setIsFullscreen(document.fullscreenElement !== null);
219 };
220
221 document.addEventListener('fullscreenchange', onFullscreenChange);
222 return () => {
223 document.removeEventListener('fullscreenchange', onFullscreenChange);
224 };
225 }, []);
226
227 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500228 if (!webSocket || !webRtcConnection) {
simonf929a362022-11-18 16:53:45 -0500229 return;
230 }
231
232 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
simonaccd8022022-11-24 15:04:53 -0500233 const callAcceptListener = (data: CallAction) => {
234 console.info('Received event on CallAccept', data);
simonf929a362022-11-18 16:53:45 -0500235 setCallStatus(CallStatus.Connecting);
236
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500237 webRtcConnection
simonf929a362022-11-18 16:53:45 -0500238 .createOffer({
239 offerToReceiveAudio: true,
240 offerToReceiveVideo: true,
241 })
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500242 .then((sdp) => {
243 sendWebRtcOffer(sdp);
simonf929a362022-11-18 16:53:45 -0500244 });
245 };
246
247 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
248
249 return () => {
250 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
251 };
252 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500253 }, [callRole, webSocket, webRtcConnection, sendWebRtcOffer, callStatus]);
simonf929a362022-11-18 16:53:45 -0500254
simonaccd8022022-11-24 15:04:53 -0500255 const quitCall = useCallback(() => {
256 if (!webRtcConnection) {
257 throw new Error('Could not quit call: webRtcConnection is not defined');
258 }
259
MichelleSS55164202022-11-25 18:36:14 -0500260 const localTracks = localStream?.getTracks();
261 if (localTracks) {
262 for (const track of localTracks) {
263 track.stop();
264 }
265 }
266
simonaccd8022022-11-24 15:04:53 -0500267 webRtcConnection.close();
268 navigate(`/conversation/${conversationId}`);
MichelleSS55164202022-11-25 18:36:14 -0500269 }, [webRtcConnection, localStream, navigate, conversationId]);
simonaccd8022022-11-24 15:04:53 -0500270
271 useEffect(() => {
272 if (!webSocket) {
273 return;
274 }
275
276 const callEndListener = (data: CallAction) => {
277 console.info('Received event on CallEnd', data);
278 quitCall();
279 // TODO: write in chat that the call ended
280 };
281
282 webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
283 return () => {
284 webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
285 };
286 }, [webSocket, navigate, conversationId, quitCall]);
287
simonf929a362022-11-18 16:53:45 -0500288 useEffect(() => {
289 if (callStatus === CallStatus.Connecting && isConnected) {
290 console.info('Changing call status to InCall');
291 setCallStatus(CallStatus.InCall);
MichelleSS55164202022-11-25 18:36:14 -0500292 setVideoStatus(isVideoOn);
Gabriel Rochone382a302022-11-23 12:37:04 -0500293 setCallStartTime(new Date());
simonf929a362022-11-18 16:53:45 -0500294 }
MichelleSS55164202022-11-25 18:36:14 -0500295 }, [isConnected, callStatus, setVideoStatus, isVideoOn]);
simonf929a362022-11-18 16:53:45 -0500296
MichelleSS55164202022-11-25 18:36:14 -0500297 const acceptCall = useCallback(
298 (withVideoOn: boolean) => {
299 if (!webSocket) {
300 throw new Error('Could not accept call');
301 }
simonf929a362022-11-18 16:53:45 -0500302
MichelleSS55164202022-11-25 18:36:14 -0500303 const callAccept: CallAction = {
304 contactId: contactUri,
305 conversationId,
306 };
simonf929a362022-11-18 16:53:45 -0500307
MichelleSS55164202022-11-25 18:36:14 -0500308 console.info('Sending CallAccept', callAccept);
309 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
310 setIsVideoOn(withVideoOn);
311 setCallStatus(CallStatus.Connecting);
312 },
313 [webSocket, contactUri, conversationId]
314 );
simonf929a362022-11-18 16:53:45 -0500315
simonaccd8022022-11-24 15:04:53 -0500316 const endCall = useCallback(() => {
317 if (!webSocket) {
318 throw new Error('Could not end call');
319 }
320
321 const callEnd: CallAction = {
322 contactId: contactUri,
323 conversationId,
324 };
325
326 console.info('Sending CallEnd', callEnd);
327 webSocket.send(WebSocketMessageType.CallEnd, callEnd);
328 quitCall();
329 // TODO: write in chat that the call ended
330 }, [webSocket, contactUri, conversationId, quitCall]);
331
MichelleSS55164202022-11-25 18:36:14 -0500332 useEffect(() => {
333 const checkStatusTimeout = () => {
334 if (callStatus !== CallStatus.InCall) {
335 endCall();
simonff1cb352022-11-24 15:15:26 -0500336 }
MichelleSS55164202022-11-25 18:36:14 -0500337 };
338 const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);
simonff1cb352022-11-24 15:15:26 -0500339
MichelleSS55164202022-11-25 18:36:14 -0500340 return () => {
341 clearTimeout(timeoutId);
342 };
343 }, [callStatus, endCall]);
simonff1cb352022-11-24 15:15:26 -0500344
345 if (!callRole || callStatus === undefined) {
346 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500347 return <Navigate to={'/'} />;
348 }
349
350 return (
351 <CallContext.Provider
352 value={{
353 mediaDevices,
354 localStream,
355 remoteStream: remoteStreams?.at(-1),
356 isAudioOn,
357 setAudioStatus,
358 isVideoOn,
359 setVideoStatus,
simonf9d78f22022-11-25 15:47:15 -0500360 isChatShown,
361 setIsChatShown,
simon2a5cf142022-11-25 15:47:35 -0500362 isFullscreen,
363 setIsFullscreen,
simonf929a362022-11-18 16:53:45 -0500364 callRole,
365 callStatus,
Gabriel Rochone382a302022-11-23 12:37:04 -0500366 callStartTime,
simonf929a362022-11-18 16:53:45 -0500367 acceptCall,
simonaccd8022022-11-24 15:04:53 -0500368 endCall,
simonf929a362022-11-18 16:53:45 -0500369 }}
370 >
371 {children}
372 </CallContext.Provider>
373 );
374};