blob: 7517133f943b496f8665cebd5ac5378e97c05f25 [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';
simon5c677962022-12-02 16:51:54 -050019import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
simonf929a362022-11-18 16:53:45 -050020
simon5c677962022-12-02 16:51:54 -050021import { createOptionalContext } from '../hooks/createOptionalContext';
simone35acc22022-12-02 16:51:12 -050022import { Conversation } from '../models/conversation';
MichelleSS55164202022-11-25 18:36:14 -050023import { callTimeoutMs } from '../utils/constants';
simon1e2bf342022-12-02 12:19:40 -050024import { AsyncSetState, SetState, WithChildren } from '../utils/utils';
simon5c677962022-12-02 16:51:54 -050025import { CallData, CallManagerContext } from './CallManagerProvider';
26import ConditionalContextProvider from './ConditionalContextProvider';
27import { IWebRtcContext, MediaDevicesInfo, MediaInputKind, useWebRtcContext } 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
simon5c677962022-12-02 16:51:54 -050073const optionalCallContext = createOptionalContext<ICallContext>('CallContext');
74export const useCallContext = optionalCallContext.useOptionalContext;
simonf929a362022-11-18 16:53:45 -050075
76export default ({ children }: WithChildren) => {
simonf353ef42022-11-28 23:14:53 -050077 const webSocket = useContext(WebSocketContext);
simon5c677962022-12-02 16:51:54 -050078 const { callConversation, callData, exitCall } = useContext(CallManagerContext);
79 const webRtcContext = useWebRtcContext(true);
simonf353ef42022-11-28 23:14:53 -050080
simon5c677962022-12-02 16:51:54 -050081 const dependencies = useMemo(
82 () => ({
83 webSocket,
84 webRtcContext,
85 callConversation,
86 callData,
87 exitCall,
88 conversationId: callData?.conversationId,
89 }),
90 [webSocket, webRtcContext, callConversation, callData, exitCall]
91 );
simonf353ef42022-11-28 23:14:53 -050092
simone35acc22022-12-02 16:51:12 -050093 return (
simon5c677962022-12-02 16:51:54 -050094 <ConditionalContextProvider
95 Context={optionalCallContext.Context}
96 initialValue={undefined}
97 dependencies={dependencies}
98 useProviderValue={CallProvider}
99 >
simone35acc22022-12-02 16:51:12 -0500100 {children}
simon5c677962022-12-02 16:51:54 -0500101 </ConditionalContextProvider>
simone35acc22022-12-02 16:51:12 -0500102 );
simonf353ef42022-11-28 23:14:53 -0500103};
104
105const CallProvider = ({
simon5c677962022-12-02 16:51:54 -0500106 webRtcContext,
107 callConversation,
108 callData,
109 exitCall,
simone35acc22022-12-02 16:51:12 -0500110 conversationId,
simonf353ef42022-11-28 23:14:53 -0500111 webSocket,
simon5c677962022-12-02 16:51:54 -0500112}: {
simonf353ef42022-11-28 23:14:53 -0500113 webSocket: IWebSocketContext;
simon5c677962022-12-02 16:51:54 -0500114 webRtcContext: IWebRtcContext;
115 callConversation: Conversation;
116 callData: CallData;
117 exitCall: () => void;
simone35acc22022-12-02 16:51:12 -0500118 conversationId: string;
simon5c677962022-12-02 16:51:54 -0500119}): ICallContext => {
simon1e2bf342022-12-02 12:19:40 -0500120 const {
121 localStream,
122 updateScreenShare,
123 sendWebRtcOffer,
124 iceConnectionState,
125 closeConnection,
126 getMediaDevices,
127 updateLocalStream,
simon5c677962022-12-02 16:51:54 -0500128 } = webRtcContext;
simonf929a362022-11-18 16:53:45 -0500129
simon5c677962022-12-02 16:51:54 -0500130 const [mediaDevices, setMediaDevices] = useState<MediaDevicesInfo>({
131 audioinput: [],
132 audiooutput: [],
133 videoinput: [],
134 });
simon492e8402022-11-29 16:48:37 -0500135 const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
136 const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
137 const [videoDeviceId, setVideoDeviceId] = useState<string>();
138
simonf929a362022-11-18 16:53:45 -0500139 const [isAudioOn, setIsAudioOn] = useState(false);
simon1e2bf342022-12-02 12:19:40 -0500140 const [videoStatus, setVideoStatus] = useState(VideoStatus.Off);
simonf9d78f22022-11-25 15:47:15 -0500141 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500142 const [isFullscreen, setIsFullscreen] = useState(false);
simone35acc22022-12-02 16:51:12 -0500143 const [callStatus, setCallStatus] = useState(CallStatus.Default);
144 const [callRole] = useState(callData?.role);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500145 const [callStartTime, setCallStartTime] = useState<number | undefined>(undefined);
simonf929a362022-11-18 16:53:45 -0500146
simonff1cb352022-11-24 15:15:26 -0500147 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
148 // The client could make a single request with the conversationId, and the server would be tasked with sending
149 // all the individual requests to the members of the conversation.
simon5c677962022-12-02 16:51:54 -0500150 const contactUri = useMemo(() => callConversation.getFirstMember().contact.uri, [callConversation]);
simonf929a362022-11-18 16:53:45 -0500151
152 useEffect(() => {
simon492e8402022-11-29 16:48:37 -0500153 if (callStatus !== CallStatus.InCall) {
154 return;
155 }
156
157 const updateMediaDevices = async () => {
158 try {
159 const newMediaDevices = await getMediaDevices();
160
161 if (newMediaDevices.audiooutput.length !== 0 && !audioOutputDeviceId) {
162 setAudioOutputDeviceId(newMediaDevices.audiooutput[0].deviceId);
163 }
164
165 setMediaDevices(newMediaDevices);
166 } catch (e) {
167 console.error('Could not update media devices:', e);
168 }
169 };
170
171 navigator.mediaDevices.addEventListener('devicechange', updateMediaDevices);
172 updateMediaDevices();
173
174 return () => {
175 navigator.mediaDevices.removeEventListener('devicechange', updateMediaDevices);
176 };
177 }, [callStatus, getMediaDevices, audioOutputDeviceId]);
178
179 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500180 if (localStream) {
181 for (const track of localStream.getAudioTracks()) {
182 track.enabled = isAudioOn;
simon492e8402022-11-29 16:48:37 -0500183 const deviceId = track.getSettings().deviceId;
184 if (deviceId) {
185 setAudioInputDeviceId(deviceId);
186 }
simon9076a9a2022-11-29 17:13:01 -0500187 }
simonfeaa1db2022-11-26 20:13:18 -0500188 }
simon9076a9a2022-11-29 17:13:01 -0500189 }, [isAudioOn, localStream]);
simonf929a362022-11-18 16:53:45 -0500190
191 useEffect(() => {
simonf353ef42022-11-28 23:14:53 -0500192 if (localStream) {
MichelleSS55164202022-11-25 18:36:14 -0500193 for (const track of localStream.getVideoTracks()) {
simon1e2bf342022-12-02 12:19:40 -0500194 track.enabled = videoStatus === VideoStatus.Camera;
simon492e8402022-11-29 16:48:37 -0500195 const deviceId = track.getSettings().deviceId;
196 if (deviceId) {
197 setVideoDeviceId(deviceId);
198 }
MichelleSS55164202022-11-25 18:36:14 -0500199 }
simonff1cb352022-11-24 15:15:26 -0500200 }
simon1e2bf342022-12-02 12:19:40 -0500201 }, [videoStatus, localStream]);
202
203 const updateVideoStatus = useCallback(
204 async (newStatus: ((prevState: VideoStatus) => VideoStatus) | VideoStatus) => {
205 if (typeof newStatus === 'function') {
206 newStatus = newStatus(videoStatus);
207 }
208
209 const stream = await updateScreenShare(newStatus === VideoStatus.ScreenShare);
210 if (stream) {
211 for (const track of stream.getTracks()) {
212 track.addEventListener('ended', () => {
213 console.warn('Browser ended screen sharing');
214 updateVideoStatus(VideoStatus.Off);
215 });
216 }
217 }
218
219 setVideoStatus(newStatus);
220 },
221 [videoStatus, updateScreenShare]
222 );
simonf929a362022-11-18 16:53:45 -0500223
224 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500225 const onFullscreenChange = () => {
226 setIsFullscreen(document.fullscreenElement !== null);
227 };
228
229 document.addEventListener('fullscreenchange', onFullscreenChange);
230 return () => {
231 document.removeEventListener('fullscreenchange', onFullscreenChange);
232 };
233 }, []);
234
235 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500236 if (callRole === 'caller' && callStatus === CallStatus.Default) {
simone35acc22022-12-02 16:51:12 -0500237 const withVideoOn = callData?.withVideoOn ?? false;
simon9076a9a2022-11-29 17:13:01 -0500238 setCallStatus(CallStatus.Loading);
simon492e8402022-11-29 16:48:37 -0500239 updateLocalStream()
simon9076a9a2022-11-29 17:13:01 -0500240 .then(() => {
241 const callBegin: CallBegin = {
242 contactId: contactUri,
243 conversationId,
simon492e8402022-11-29 16:48:37 -0500244 withVideoOn,
simon9076a9a2022-11-29 17:13:01 -0500245 };
246
247 setCallStatus(CallStatus.Ringing);
simon1e2bf342022-12-02 12:19:40 -0500248 setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off);
simon9076a9a2022-11-29 17:13:01 -0500249 console.info('Sending CallBegin', callBegin);
250 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
251 })
252 .catch((e) => {
253 console.error(e);
254 setCallStatus(CallStatus.PermissionsDenied);
255 });
256 }
simone35acc22022-12-02 16:51:12 -0500257 }, [webSocket, updateLocalStream, callRole, callStatus, contactUri, conversationId, callData]);
simon9076a9a2022-11-29 17:13:01 -0500258
259 const acceptCall = useCallback(
260 (withVideoOn: boolean) => {
261 setCallStatus(CallStatus.Loading);
simon492e8402022-11-29 16:48:37 -0500262 updateLocalStream()
simon9076a9a2022-11-29 17:13:01 -0500263 .then(() => {
264 const callAccept: CallAction = {
265 contactId: contactUri,
266 conversationId,
267 };
268
simon1e2bf342022-12-02 12:19:40 -0500269 setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off);
simon9076a9a2022-11-29 17:13:01 -0500270 setCallStatus(CallStatus.Connecting);
271 console.info('Sending CallAccept', callAccept);
272 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
273 })
274 .catch((e) => {
275 console.error(e);
276 setCallStatus(CallStatus.PermissionsDenied);
277 });
278 },
simon492e8402022-11-29 16:48:37 -0500279 [webSocket, updateLocalStream, contactUri, conversationId]
simon9076a9a2022-11-29 17:13:01 -0500280 );
281
282 useEffect(() => {
simonf929a362022-11-18 16:53:45 -0500283 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
simonaccd8022022-11-24 15:04:53 -0500284 const callAcceptListener = (data: CallAction) => {
285 console.info('Received event on CallAccept', data);
Charliec18d6402022-11-27 13:01:04 -0500286 if (data.conversationId !== conversationId) {
287 console.warn('Wrong incoming conversationId, ignoring action');
288 return;
289 }
290
simonf929a362022-11-18 16:53:45 -0500291 setCallStatus(CallStatus.Connecting);
292
simon9076a9a2022-11-29 17:13:01 -0500293 sendWebRtcOffer();
simonf929a362022-11-18 16:53:45 -0500294 };
295
296 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
297
298 return () => {
299 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
300 };
301 }
simon9076a9a2022-11-29 17:13:01 -0500302 }, [callRole, webSocket, sendWebRtcOffer, callStatus, conversationId]);
simonf929a362022-11-18 16:53:45 -0500303
simon9076a9a2022-11-29 17:13:01 -0500304 const endCall = useCallback(() => {
305 const callEnd: CallAction = {
306 contactId: contactUri,
307 conversationId,
308 };
MichelleSS55164202022-11-25 18:36:14 -0500309
simon9076a9a2022-11-29 17:13:01 -0500310 console.info('Sending CallEnd', callEnd);
311 closeConnection();
312 webSocket.send(WebSocketMessageType.CallEnd, callEnd);
simone35acc22022-12-02 16:51:12 -0500313 exitCall();
simon9076a9a2022-11-29 17:13:01 -0500314 // TODO: write in chat that the call ended
simone35acc22022-12-02 16:51:12 -0500315 }, [webSocket, contactUri, conversationId, closeConnection, exitCall]);
simonaccd8022022-11-24 15:04:53 -0500316
317 useEffect(() => {
simonaccd8022022-11-24 15:04:53 -0500318 const callEndListener = (data: CallAction) => {
319 console.info('Received event on CallEnd', data);
Charliec18d6402022-11-27 13:01:04 -0500320 if (data.conversationId !== conversationId) {
321 console.warn('Wrong incoming conversationId, ignoring action');
322 return;
323 }
324
simon9076a9a2022-11-29 17:13:01 -0500325 closeConnection();
simone35acc22022-12-02 16:51:12 -0500326 exitCall();
simonaccd8022022-11-24 15:04:53 -0500327 // TODO: write in chat that the call ended
328 };
329
330 webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
331 return () => {
332 webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
333 };
simone35acc22022-12-02 16:51:12 -0500334 }, [webSocket, exitCall, conversationId, closeConnection]);
simonaccd8022022-11-24 15:04:53 -0500335
simonf929a362022-11-18 16:53:45 -0500336 useEffect(() => {
Charlieb837e8f2022-11-28 19:18:46 -0500337 if (
338 callStatus === CallStatus.Connecting &&
339 (iceConnectionState === 'connected' || iceConnectionState === 'completed')
340 ) {
simonf929a362022-11-18 16:53:45 -0500341 console.info('Changing call status to InCall');
342 setCallStatus(CallStatus.InCall);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500343 setCallStartTime(Date.now());
simonf929a362022-11-18 16:53:45 -0500344 }
simon9076a9a2022-11-29 17:13:01 -0500345 }, [iceConnectionState, callStatus]);
simonaccd8022022-11-24 15:04:53 -0500346
MichelleSS55164202022-11-25 18:36:14 -0500347 useEffect(() => {
Charlie380dc5e2022-11-29 16:51:42 -0500348 if (iceConnectionState === 'disconnected' || iceConnectionState === 'failed') {
349 console.info('ICE connection disconnected or failed, ending call');
Charlieb837e8f2022-11-28 19:18:46 -0500350 endCall();
351 }
simon1e2bf342022-12-02 12:19:40 -0500352 }, [iceConnectionState, callStatus, videoStatus, endCall]);
Charlieb837e8f2022-11-28 19:18:46 -0500353
354 useEffect(() => {
MichelleSS55164202022-11-25 18:36:14 -0500355 const checkStatusTimeout = () => {
356 if (callStatus !== CallStatus.InCall) {
357 endCall();
simonff1cb352022-11-24 15:15:26 -0500358 }
MichelleSS55164202022-11-25 18:36:14 -0500359 };
360 const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);
simonff1cb352022-11-24 15:15:26 -0500361
MichelleSS55164202022-11-25 18:36:14 -0500362 return () => {
363 clearTimeout(timeoutId);
364 };
365 }, [callStatus, endCall]);
simonff1cb352022-11-24 15:15:26 -0500366
simon492e8402022-11-29 16:48:37 -0500367 const currentMediaDeviceIds: CurrentMediaDeviceIds = useMemo(() => {
368 const createSetIdForDeviceKind = (mediaInputKind: MediaInputKind) => async (id: string | undefined) => {
369 const mediaDeviceIds = {
370 audio: audioInputDeviceId,
371 video: videoDeviceId,
372 };
373
374 mediaDeviceIds[mediaInputKind] = id;
375
376 await updateLocalStream(mediaDeviceIds);
377 };
378
379 return {
380 audioinput: {
381 id: audioInputDeviceId,
382 setId: createSetIdForDeviceKind('audio'),
383 },
384 audiooutput: {
385 id: audioOutputDeviceId,
386 setId: setAudioOutputDeviceId,
387 },
388 videoinput: {
389 id: videoDeviceId,
390 setId: createSetIdForDeviceKind('video'),
391 },
392 };
393 }, [updateLocalStream, audioInputDeviceId, audioOutputDeviceId, videoDeviceId]);
394
simon5c677962022-12-02 16:51:54 -0500395 return useMemo(
396 () => ({
397 mediaDevices,
398 currentMediaDeviceIds,
399 isAudioOn,
400 setIsAudioOn,
401 videoStatus,
402 updateVideoStatus,
403 isChatShown,
404 setIsChatShown,
405 isFullscreen,
406 setIsFullscreen,
407 callRole,
408 callStatus,
409 callStartTime,
410 acceptCall,
411 endCall,
412 }),
413 [
414 mediaDevices,
415 currentMediaDeviceIds,
416 isAudioOn,
simon5c677962022-12-02 16:51:54 -0500417 videoStatus,
418 updateVideoStatus,
419 isChatShown,
simon5c677962022-12-02 16:51:54 -0500420 isFullscreen,
simon5c677962022-12-02 16:51:54 -0500421 callRole,
422 callStatus,
423 callStartTime,
424 acceptCall,
425 endCall,
426 ]
simonf929a362022-11-18 16:53:45 -0500427 );
428};