blob: da16a01ddaa72a11b35f6f26429702201a8bf2e9 [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;
54
55 acceptCall: () => void;
56}
57
58const defaultCallContext: ICallContext = {
59 mediaDevices: {
60 audioinput: [],
61 audiooutput: [],
62 videoinput: [],
63 },
64
65 localStream: undefined,
66 remoteStream: undefined,
67
68 isAudioOn: false,
69 setAudioStatus: () => {},
70 isVideoOn: false,
71 setVideoStatus: () => {},
simonf9d78f22022-11-25 15:47:15 -050072 isChatShown: false,
73 setIsChatShown: () => {},
simon2a5cf142022-11-25 15:47:35 -050074 isFullscreen: false,
75 setIsFullscreen: () => {},
simonf929a362022-11-18 16:53:45 -050076 callRole: 'caller',
simonff1cb352022-11-24 15:15:26 -050077 callStatus: CallStatus.Default,
simonf929a362022-11-18 16:53:45 -050078
79 acceptCall: () => {},
80};
81
82export const CallContext = createContext<ICallContext>(defaultCallContext);
83
84export default ({ children }: WithChildren) => {
85 const {
86 queryParams: { role: callRole },
simonff1cb352022-11-24 15:15:26 -050087 state: routeState,
simonf929a362022-11-18 16:53:45 -050088 } = useUrlParams<CallRouteParams>();
simonf929a362022-11-18 16:53:45 -050089 const webSocket = useContext(WebSocketContext);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050090 const { webRtcConnection, remoteStreams, sendWebRtcOffer, isConnected } = useContext(WebRtcContext);
91 const { conversationId, conversation } = useContext(ConversationContext);
simonf929a362022-11-18 16:53:45 -050092
93 const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>(
94 defaultCallContext.mediaDevices
95 );
96 const [localStream, setLocalStream] = useState<MediaStream>();
97
98 const [isAudioOn, setIsAudioOn] = useState(false);
99 const [isVideoOn, setIsVideoOn] = useState(false);
simonf9d78f22022-11-25 15:47:15 -0500100 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500101 const [isFullscreen, setIsFullscreen] = useState(false);
simonff1cb352022-11-24 15:15:26 -0500102 const [callStatus, setCallStatus] = useState(routeState?.callStatus);
simonf929a362022-11-18 16:53:45 -0500103
simonff1cb352022-11-24 15:15:26 -0500104 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
105 // The client could make a single request with the conversationId, and the server would be tasked with sending
106 // all the individual requests to the members of the conversation.
simonf929a362022-11-18 16:53:45 -0500107 const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
108
109 useEffect(() => {
110 // TODO: Wait until status is `InCall` before getting devices
111 navigator.mediaDevices.enumerateDevices().then((devices) => {
112 const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
113 audioinput: [],
114 audiooutput: [],
115 videoinput: [],
116 };
117
118 for (const device of devices) {
119 newMediaDevices[device.kind].push(device);
120 }
121
122 setMediaDevices(newMediaDevices);
123 });
124 }, []);
125
126 useEffect(() => {
127 // TODO: Only ask media permission once the call has been accepted
128 try {
129 // TODO: When toggling mute on/off, the camera flickers
130 // https://git.jami.net/savoirfairelinux/jami-web/-/issues/90
131 navigator.mediaDevices
132 .getUserMedia({
133 audio: true, // TODO: Set both to false by default
134 video: true,
135 })
136 .then((stream) => {
137 for (const track of stream.getTracks()) {
138 // TODO: Set default from isVideoOn and isMicOn values
139 track.enabled = false;
140 }
141 setLocalStream(stream);
142 });
143 } catch (e) {
144 // TODO: Better handle user denial
145 console.error('Could not get media devices:', e);
146 }
147 }, [setLocalStream]);
148
149 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500150 if (localStream && webRtcConnection) {
simonf929a362022-11-18 16:53:45 -0500151 for (const track of localStream.getTracks()) {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500152 webRtcConnection.addTrack(track, localStream);
simonf929a362022-11-18 16:53:45 -0500153 }
154 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500155 }, [localStream, webRtcConnection]);
simonf929a362022-11-18 16:53:45 -0500156
simonff1cb352022-11-24 15:15:26 -0500157 useEffect(() => {
158 if (!webSocket) {
159 return;
160 }
simonf929a362022-11-18 16:53:45 -0500161
simonff1cb352022-11-24 15:15:26 -0500162 if (callRole === 'caller' && callStatus === CallStatus.Default) {
163 const callBegin: CallAction = {
164 contactId: contactUri,
165 conversationId,
166 };
simonf929a362022-11-18 16:53:45 -0500167
simonff1cb352022-11-24 15:15:26 -0500168 console.info('Sending CallBegin', callBegin);
169 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
170 setCallStatus(CallStatus.Ringing);
171 }
172 }, [webSocket, callRole, callStatus, contactUri, conversationId]);
simonf929a362022-11-18 16:53:45 -0500173
174 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500175 const onFullscreenChange = () => {
176 setIsFullscreen(document.fullscreenElement !== null);
177 };
178
179 document.addEventListener('fullscreenchange', onFullscreenChange);
180 return () => {
181 document.removeEventListener('fullscreenchange', onFullscreenChange);
182 };
183 }, []);
184
185 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500186 if (!webSocket || !webRtcConnection) {
simonf929a362022-11-18 16:53:45 -0500187 return;
188 }
189
190 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500191 const callAcceptListener = (_data: CallAction) => {
192 console.info('Received event on CallAccept');
simonf929a362022-11-18 16:53:45 -0500193 setCallStatus(CallStatus.Connecting);
194
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500195 webRtcConnection
simonf929a362022-11-18 16:53:45 -0500196 .createOffer({
197 offerToReceiveAudio: true,
198 offerToReceiveVideo: true,
199 })
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500200 .then((sdp) => {
201 sendWebRtcOffer(sdp);
simonf929a362022-11-18 16:53:45 -0500202 });
203 };
204
205 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
206
207 return () => {
208 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
209 };
210 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500211 }, [callRole, webSocket, webRtcConnection, sendWebRtcOffer, callStatus]);
simonf929a362022-11-18 16:53:45 -0500212
213 useEffect(() => {
214 if (callStatus === CallStatus.Connecting && isConnected) {
215 console.info('Changing call status to InCall');
216 setCallStatus(CallStatus.InCall);
217 }
218 }, [isConnected, callStatus]);
219
220 const acceptCall = useCallback(() => {
221 if (!webSocket) {
222 throw new Error('Could not accept call');
223 }
224
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500225 const callAccept: CallAction = {
226 contactId: contactUri,
227 conversationId,
simonf929a362022-11-18 16:53:45 -0500228 };
229
230 console.info('Sending CallAccept', callAccept);
231 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
232 setCallStatus(CallStatus.Connecting);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500233 }, [webSocket, contactUri, conversationId]);
simonf929a362022-11-18 16:53:45 -0500234
simonff1cb352022-11-24 15:15:26 -0500235 const setAudioStatus = useCallback(
236 (isOn: boolean) => {
237 if (!localStream) {
238 return;
239 }
240
241 for (const track of localStream.getAudioTracks()) {
242 track.enabled = isOn;
243 }
244
245 setIsAudioOn(isOn);
246 },
247 [localStream]
248 );
249
250 const setVideoStatus = useCallback(
251 (isOn: boolean) => {
252 if (!localStream) {
253 return;
254 }
255
256 for (const track of localStream.getVideoTracks()) {
257 track.enabled = isOn;
258 }
259
260 setIsVideoOn(isOn);
261 },
262 [localStream]
263 );
264
265 if (!callRole || callStatus === undefined) {
266 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500267 return <Navigate to={'/'} />;
268 }
269
270 return (
271 <CallContext.Provider
272 value={{
273 mediaDevices,
274 localStream,
275 remoteStream: remoteStreams?.at(-1),
276 isAudioOn,
277 setAudioStatus,
278 isVideoOn,
279 setVideoStatus,
simonf9d78f22022-11-25 15:47:15 -0500280 isChatShown,
281 setIsChatShown,
simon2a5cf142022-11-25 15:47:35 -0500282 isFullscreen,
283 setIsFullscreen,
simonf929a362022-11-18 16:53:45 -0500284 callRole,
285 callStatus,
286 acceptCall,
287 }}
288 >
289 {children}
290 </CallContext.Provider>
291 );
292};