blob: 111d6e7b4d636b1f4072e3cf9823cb61c267dc1e [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 */
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050018import { CallAction, WebSocketMessageType } from 'jami-web-common';
19import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
simonf929a362022-11-18 16:53:45 -050020import { Navigate } from 'react-router-dom';
21
22import { useUrlParams } from '../hooks/useUrlParams';
23import { CallRouteParams } from '../router';
simonf9d78f22022-11-25 15:47:15 -050024import { SetState, WithChildren } from '../utils/utils';
simonf929a362022-11-18 16:53:45 -050025import { ConversationContext } from './ConversationProvider';
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050026import { WebRtcContext } from './WebRtcProvider';
simonf929a362022-11-18 16:53:45 -050027import { WebSocketContext } from './WebSocketProvider';
28
29export type CallRole = 'caller' | 'receiver';
30
31export enum CallStatus {
simonff1cb352022-11-24 15:15:26 -050032 Default,
simonf929a362022-11-18 16:53:45 -050033 Ringing,
34 Connecting,
35 InCall,
36}
37
38export interface ICallContext {
39 mediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]>;
40
41 localStream: MediaStream | undefined;
42 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
43
44 isAudioOn: boolean;
45 setAudioStatus: (isOn: boolean) => void;
46 isVideoOn: boolean;
47 setVideoStatus: (isOn: boolean) => void;
simonf9d78f22022-11-25 15:47:15 -050048 isChatShown: boolean;
49 setIsChatShown: SetState<boolean>;
simon2a5cf142022-11-25 15:47:35 -050050 isFullscreen: boolean;
51 setIsFullscreen: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050052 callRole: CallRole;
53 callStatus: CallStatus;
Gabriel Rochone382a302022-11-23 12:37:04 -050054 callStartTime: Date | undefined;
simonf929a362022-11-18 16:53:45 -050055
56 acceptCall: () => void;
57}
58
59const defaultCallContext: ICallContext = {
60 mediaDevices: {
61 audioinput: [],
62 audiooutput: [],
63 videoinput: [],
64 },
65
66 localStream: undefined,
67 remoteStream: undefined,
68
69 isAudioOn: false,
70 setAudioStatus: () => {},
71 isVideoOn: false,
72 setVideoStatus: () => {},
simonf9d78f22022-11-25 15:47:15 -050073 isChatShown: false,
74 setIsChatShown: () => {},
simon2a5cf142022-11-25 15:47:35 -050075 isFullscreen: false,
76 setIsFullscreen: () => {},
simonf929a362022-11-18 16:53:45 -050077 callRole: 'caller',
simonff1cb352022-11-24 15:15:26 -050078 callStatus: CallStatus.Default,
Gabriel Rochone382a302022-11-23 12:37:04 -050079 callStartTime: undefined,
simonf929a362022-11-18 16:53:45 -050080
81 acceptCall: () => {},
82};
83
84export const CallContext = createContext<ICallContext>(defaultCallContext);
85
86export default ({ children }: WithChildren) => {
87 const {
88 queryParams: { role: callRole },
simonff1cb352022-11-24 15:15:26 -050089 state: routeState,
simonf929a362022-11-18 16:53:45 -050090 } = useUrlParams<CallRouteParams>();
simonf929a362022-11-18 16:53:45 -050091 const webSocket = useContext(WebSocketContext);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050092 const { webRtcConnection, remoteStreams, sendWebRtcOffer, isConnected } = useContext(WebRtcContext);
93 const { conversationId, conversation } = useContext(ConversationContext);
simonf929a362022-11-18 16:53:45 -050094
95 const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>(
96 defaultCallContext.mediaDevices
97 );
98 const [localStream, setLocalStream] = useState<MediaStream>();
99
100 const [isAudioOn, setIsAudioOn] = useState(false);
101 const [isVideoOn, setIsVideoOn] = useState(false);
simonf9d78f22022-11-25 15:47:15 -0500102 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500103 const [isFullscreen, setIsFullscreen] = useState(false);
simonff1cb352022-11-24 15:15:26 -0500104 const [callStatus, setCallStatus] = useState(routeState?.callStatus);
Gabriel Rochone382a302022-11-23 12:37:04 -0500105 const [callStartTime, setCallStartTime] = useState<Date | undefined>(undefined);
simonf929a362022-11-18 16:53:45 -0500106
simonff1cb352022-11-24 15:15:26 -0500107 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
108 // The client could make a single request with the conversationId, and the server would be tasked with sending
109 // all the individual requests to the members of the conversation.
simonf929a362022-11-18 16:53:45 -0500110 const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
111
112 useEffect(() => {
simonfeaa1db2022-11-26 20:13:18 -0500113 if (!isConnected) {
114 return;
115 }
simonf929a362022-11-18 16:53:45 -0500116
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 }
simonfeaa1db2022-11-26 20:13:18 -0500153 }, [isConnected]);
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
simonff1cb352022-11-24 15:15:26 -0500163 useEffect(() => {
164 if (!webSocket) {
165 return;
166 }
simonf929a362022-11-18 16:53:45 -0500167
simonff1cb352022-11-24 15:15:26 -0500168 if (callRole === 'caller' && callStatus === CallStatus.Default) {
169 const callBegin: CallAction = {
170 contactId: contactUri,
171 conversationId,
172 };
simonf929a362022-11-18 16:53:45 -0500173
simonff1cb352022-11-24 15:15:26 -0500174 console.info('Sending CallBegin', callBegin);
175 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
176 setCallStatus(CallStatus.Ringing);
177 }
178 }, [webSocket, callRole, callStatus, contactUri, conversationId]);
simonf929a362022-11-18 16:53:45 -0500179
180 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500181 const onFullscreenChange = () => {
182 setIsFullscreen(document.fullscreenElement !== null);
183 };
184
185 document.addEventListener('fullscreenchange', onFullscreenChange);
186 return () => {
187 document.removeEventListener('fullscreenchange', onFullscreenChange);
188 };
189 }, []);
190
191 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500192 if (!webSocket || !webRtcConnection) {
simonf929a362022-11-18 16:53:45 -0500193 return;
194 }
195
196 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500197 const callAcceptListener = (_data: CallAction) => {
198 console.info('Received event on CallAccept');
simonf929a362022-11-18 16:53:45 -0500199 setCallStatus(CallStatus.Connecting);
200
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500201 webRtcConnection
simonf929a362022-11-18 16:53:45 -0500202 .createOffer({
203 offerToReceiveAudio: true,
204 offerToReceiveVideo: true,
205 })
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500206 .then((sdp) => {
207 sendWebRtcOffer(sdp);
simonf929a362022-11-18 16:53:45 -0500208 });
209 };
210
211 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
212
213 return () => {
214 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
215 };
216 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500217 }, [callRole, webSocket, webRtcConnection, sendWebRtcOffer, callStatus]);
simonf929a362022-11-18 16:53:45 -0500218
219 useEffect(() => {
220 if (callStatus === CallStatus.Connecting && isConnected) {
221 console.info('Changing call status to InCall');
222 setCallStatus(CallStatus.InCall);
Gabriel Rochone382a302022-11-23 12:37:04 -0500223 setCallStartTime(new Date());
simonf929a362022-11-18 16:53:45 -0500224 }
225 }, [isConnected, callStatus]);
226
227 const acceptCall = useCallback(() => {
228 if (!webSocket) {
229 throw new Error('Could not accept call');
230 }
231
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500232 const callAccept: CallAction = {
233 contactId: contactUri,
234 conversationId,
simonf929a362022-11-18 16:53:45 -0500235 };
236
237 console.info('Sending CallAccept', callAccept);
238 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
239 setCallStatus(CallStatus.Connecting);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500240 }, [webSocket, contactUri, conversationId]);
simonf929a362022-11-18 16:53:45 -0500241
simonff1cb352022-11-24 15:15:26 -0500242 const setAudioStatus = useCallback(
243 (isOn: boolean) => {
244 if (!localStream) {
245 return;
246 }
247
248 for (const track of localStream.getAudioTracks()) {
249 track.enabled = isOn;
250 }
251
252 setIsAudioOn(isOn);
253 },
254 [localStream]
255 );
256
257 const setVideoStatus = useCallback(
258 (isOn: boolean) => {
259 if (!localStream) {
260 return;
261 }
262
263 for (const track of localStream.getVideoTracks()) {
264 track.enabled = isOn;
265 }
266
267 setIsVideoOn(isOn);
268 },
269 [localStream]
270 );
271
272 if (!callRole || callStatus === undefined) {
273 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500274 return <Navigate to={'/'} />;
275 }
276
277 return (
278 <CallContext.Provider
279 value={{
280 mediaDevices,
281 localStream,
282 remoteStream: remoteStreams?.at(-1),
283 isAudioOn,
284 setAudioStatus,
285 isVideoOn,
286 setVideoStatus,
simonf9d78f22022-11-25 15:47:15 -0500287 isChatShown,
288 setIsChatShown,
simon2a5cf142022-11-25 15:47:35 -0500289 isFullscreen,
290 setIsFullscreen,
simonf929a362022-11-18 16:53:45 -0500291 callRole,
292 callStatus,
Gabriel Rochone382a302022-11-23 12:37:04 -0500293 callStartTime,
simonf929a362022-11-18 16:53:45 -0500294 acceptCall,
295 }}
296 >
297 {children}
298 </CallContext.Provider>
299 );
300};