blob: de2a6d7aaf393e94c1e873c7b2f642c4353c4f75 [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';
24import { 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;
48 callRole: CallRole;
49 callStatus: CallStatus;
50
51 acceptCall: () => void;
52}
53
54const defaultCallContext: ICallContext = {
55 mediaDevices: {
56 audioinput: [],
57 audiooutput: [],
58 videoinput: [],
59 },
60
61 localStream: undefined,
62 remoteStream: undefined,
63
64 isAudioOn: false,
65 setAudioStatus: () => {},
66 isVideoOn: false,
67 setVideoStatus: () => {},
68 callRole: 'caller',
simonff1cb352022-11-24 15:15:26 -050069 callStatus: CallStatus.Default,
simonf929a362022-11-18 16:53:45 -050070
71 acceptCall: () => {},
72};
73
74export const CallContext = createContext<ICallContext>(defaultCallContext);
75
76export default ({ children }: WithChildren) => {
77 const {
78 queryParams: { role: callRole },
simonff1cb352022-11-24 15:15:26 -050079 state: routeState,
simonf929a362022-11-18 16:53:45 -050080 } = useUrlParams<CallRouteParams>();
simonf929a362022-11-18 16:53:45 -050081 const webSocket = useContext(WebSocketContext);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050082 const { webRtcConnection, remoteStreams, sendWebRtcOffer, isConnected } = useContext(WebRtcContext);
83 const { conversationId, conversation } = useContext(ConversationContext);
simonf929a362022-11-18 16:53:45 -050084
85 const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>(
86 defaultCallContext.mediaDevices
87 );
88 const [localStream, setLocalStream] = useState<MediaStream>();
89
90 const [isAudioOn, setIsAudioOn] = useState(false);
91 const [isVideoOn, setIsVideoOn] = useState(false);
simonff1cb352022-11-24 15:15:26 -050092 const [callStatus, setCallStatus] = useState(routeState?.callStatus);
simonf929a362022-11-18 16:53:45 -050093
simonff1cb352022-11-24 15:15:26 -050094 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
95 // The client could make a single request with the conversationId, and the server would be tasked with sending
96 // all the individual requests to the members of the conversation.
simonf929a362022-11-18 16:53:45 -050097 const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
98
99 useEffect(() => {
100 // TODO: Wait until status is `InCall` before getting devices
101 navigator.mediaDevices.enumerateDevices().then((devices) => {
102 const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
103 audioinput: [],
104 audiooutput: [],
105 videoinput: [],
106 };
107
108 for (const device of devices) {
109 newMediaDevices[device.kind].push(device);
110 }
111
112 setMediaDevices(newMediaDevices);
113 });
114 }, []);
115
116 useEffect(() => {
117 // TODO: Only ask media permission once the call has been accepted
118 try {
119 // TODO: When toggling mute on/off, the camera flickers
120 // https://git.jami.net/savoirfairelinux/jami-web/-/issues/90
121 navigator.mediaDevices
122 .getUserMedia({
123 audio: true, // TODO: Set both to false by default
124 video: true,
125 })
126 .then((stream) => {
127 for (const track of stream.getTracks()) {
128 // TODO: Set default from isVideoOn and isMicOn values
129 track.enabled = false;
130 }
131 setLocalStream(stream);
132 });
133 } catch (e) {
134 // TODO: Better handle user denial
135 console.error('Could not get media devices:', e);
136 }
137 }, [setLocalStream]);
138
139 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500140 if (localStream && webRtcConnection) {
simonf929a362022-11-18 16:53:45 -0500141 for (const track of localStream.getTracks()) {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500142 webRtcConnection.addTrack(track, localStream);
simonf929a362022-11-18 16:53:45 -0500143 }
144 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500145 }, [localStream, webRtcConnection]);
simonf929a362022-11-18 16:53:45 -0500146
simonff1cb352022-11-24 15:15:26 -0500147 useEffect(() => {
148 if (!webSocket) {
149 return;
150 }
simonf929a362022-11-18 16:53:45 -0500151
simonff1cb352022-11-24 15:15:26 -0500152 if (callRole === 'caller' && callStatus === CallStatus.Default) {
153 const callBegin: CallAction = {
154 contactId: contactUri,
155 conversationId,
156 };
simonf929a362022-11-18 16:53:45 -0500157
simonff1cb352022-11-24 15:15:26 -0500158 console.info('Sending CallBegin', callBegin);
159 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
160 setCallStatus(CallStatus.Ringing);
161 }
162 }, [webSocket, callRole, callStatus, contactUri, conversationId]);
simonf929a362022-11-18 16:53:45 -0500163
164 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500165 if (!webSocket || !webRtcConnection) {
simonf929a362022-11-18 16:53:45 -0500166 return;
167 }
168
169 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500170 const callAcceptListener = (_data: CallAction) => {
171 console.info('Received event on CallAccept');
simonf929a362022-11-18 16:53:45 -0500172 setCallStatus(CallStatus.Connecting);
173
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500174 webRtcConnection
simonf929a362022-11-18 16:53:45 -0500175 .createOffer({
176 offerToReceiveAudio: true,
177 offerToReceiveVideo: true,
178 })
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500179 .then((sdp) => {
180 sendWebRtcOffer(sdp);
simonf929a362022-11-18 16:53:45 -0500181 });
182 };
183
184 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
185
186 return () => {
187 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
188 };
189 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500190 }, [callRole, webSocket, webRtcConnection, sendWebRtcOffer, callStatus]);
simonf929a362022-11-18 16:53:45 -0500191
192 useEffect(() => {
193 if (callStatus === CallStatus.Connecting && isConnected) {
194 console.info('Changing call status to InCall');
195 setCallStatus(CallStatus.InCall);
196 }
197 }, [isConnected, callStatus]);
198
199 const acceptCall = useCallback(() => {
200 if (!webSocket) {
201 throw new Error('Could not accept call');
202 }
203
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500204 const callAccept: CallAction = {
205 contactId: contactUri,
206 conversationId,
simonf929a362022-11-18 16:53:45 -0500207 };
208
209 console.info('Sending CallAccept', callAccept);
210 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
211 setCallStatus(CallStatus.Connecting);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500212 }, [webSocket, contactUri, conversationId]);
simonf929a362022-11-18 16:53:45 -0500213
simonff1cb352022-11-24 15:15:26 -0500214 const setAudioStatus = useCallback(
215 (isOn: boolean) => {
216 if (!localStream) {
217 return;
218 }
219
220 for (const track of localStream.getAudioTracks()) {
221 track.enabled = isOn;
222 }
223
224 setIsAudioOn(isOn);
225 },
226 [localStream]
227 );
228
229 const setVideoStatus = useCallback(
230 (isOn: boolean) => {
231 if (!localStream) {
232 return;
233 }
234
235 for (const track of localStream.getVideoTracks()) {
236 track.enabled = isOn;
237 }
238
239 setIsVideoOn(isOn);
240 },
241 [localStream]
242 );
243
244 if (!callRole || callStatus === undefined) {
245 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500246 return <Navigate to={'/'} />;
247 }
248
249 return (
250 <CallContext.Provider
251 value={{
252 mediaDevices,
253 localStream,
254 remoteStream: remoteStreams?.at(-1),
255 isAudioOn,
256 setAudioStatus,
257 isVideoOn,
258 setVideoStatus,
259 callRole,
260 callStatus,
261 acceptCall,
262 }}
263 >
264 {children}
265 </CallContext.Provider>
266 );
267};