blob: 0cc667aeee9f1e07a60dd187b0407b8665e02b48 [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 */
18import { AccountTextMessage, WebSocketMessageType } from 'jami-web-common';
19import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
20import { Navigate } from 'react-router-dom';
21
22import { useUrlParams } from '../hooks/useUrlParams';
23import { CallRouteParams } from '../router';
24import { WithChildren } from '../utils/utils';
25import { useAuthContext } from './AuthProvider';
26import { ConversationContext } from './ConversationProvider';
27import { WebRTCContext } from './WebRTCProvider';
28import { WebSocketContext } from './WebSocketProvider';
29
30export type CallRole = 'caller' | 'receiver';
31
32export enum CallStatus {
33 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',
69 callStatus: CallStatus.Ringing,
70
71 acceptCall: () => {},
72};
73
74export const CallContext = createContext<ICallContext>(defaultCallContext);
75
76export default ({ children }: WithChildren) => {
77 const {
78 queryParams: { role: callRole },
79 } = useUrlParams<CallRouteParams>();
80 const { account } = useAuthContext();
81 const webSocket = useContext(WebSocketContext);
82 const { webRTCConnection, remoteStreams, sendWebRTCOffer, isConnected } = useContext(WebRTCContext);
83 const { conversation } = useContext(ConversationContext);
84
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);
92 const [callStatus, setCallStatus] = useState(CallStatus.Ringing);
93
94 // TODO: This logic will have to change to support multiple people in a call
95 const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
96
97 useEffect(() => {
98 // TODO: Wait until status is `InCall` before getting devices
99 navigator.mediaDevices.enumerateDevices().then((devices) => {
100 const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
101 audioinput: [],
102 audiooutput: [],
103 videoinput: [],
104 };
105
106 for (const device of devices) {
107 newMediaDevices[device.kind].push(device);
108 }
109
110 setMediaDevices(newMediaDevices);
111 });
112 }, []);
113
114 useEffect(() => {
115 // TODO: Only ask media permission once the call has been accepted
116 try {
117 // TODO: When toggling mute on/off, the camera flickers
118 // https://git.jami.net/savoirfairelinux/jami-web/-/issues/90
119 navigator.mediaDevices
120 .getUserMedia({
121 audio: true, // TODO: Set both to false by default
122 video: true,
123 })
124 .then((stream) => {
125 for (const track of stream.getTracks()) {
126 // TODO: Set default from isVideoOn and isMicOn values
127 track.enabled = false;
128 }
129 setLocalStream(stream);
130 });
131 } catch (e) {
132 // TODO: Better handle user denial
133 console.error('Could not get media devices:', e);
134 }
135 }, [setLocalStream]);
136
137 useEffect(() => {
138 if (localStream && webRTCConnection) {
139 for (const track of localStream.getTracks()) {
140 webRTCConnection.addTrack(track, localStream);
141 }
142 }
143 }, [localStream, webRTCConnection]);
144
145 const setAudioStatus = useCallback(
146 (isOn: boolean) => {
147 if (!localStream) {
148 return;
149 }
150
151 for (const track of localStream.getAudioTracks()) {
152 track.enabled = isOn;
153 }
154
155 setIsAudioOn(isOn);
156 },
157 [localStream]
158 );
159
160 const setVideoStatus = useCallback(
161 (isOn: boolean) => {
162 if (!localStream) {
163 return;
164 }
165
166 for (const track of localStream.getVideoTracks()) {
167 track.enabled = isOn;
168 }
169
170 setIsVideoOn(isOn);
171 },
172 [localStream]
173 );
174
175 useEffect(() => {
176 if (!webSocket || !webRTCConnection) {
177 return;
178 }
179
180 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
181 const callAcceptListener = (data: AccountTextMessage<undefined>) => {
182 console.info('Received event on CallAccept', data);
183 setCallStatus(CallStatus.Connecting);
184
185 webRTCConnection
186 .createOffer({
187 offerToReceiveAudio: true,
188 offerToReceiveVideo: true,
189 })
190 .then((offerSDP) => {
191 sendWebRTCOffer(offerSDP);
192 });
193 };
194
195 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
196
197 return () => {
198 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
199 };
200 }
201 }, [callRole, webSocket, webRTCConnection, sendWebRTCOffer, callStatus]);
202
203 useEffect(() => {
204 if (callStatus === CallStatus.Connecting && isConnected) {
205 console.info('Changing call status to InCall');
206 setCallStatus(CallStatus.InCall);
207 }
208 }, [isConnected, callStatus]);
209
210 const acceptCall = useCallback(() => {
211 if (!webSocket) {
212 throw new Error('Could not accept call');
213 }
214
215 const callAccept = {
216 from: account.getId(),
217 to: contactUri,
218 message: undefined,
219 };
220
221 console.info('Sending CallAccept', callAccept);
222 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
223 setCallStatus(CallStatus.Connecting);
224 }, [webSocket, account, contactUri]);
225
226 if (!callRole) {
227 console.error('Call role not defined. Redirecting...');
228 return <Navigate to={'/'} />;
229 }
230
231 return (
232 <CallContext.Provider
233 value={{
234 mediaDevices,
235 localStream,
236 remoteStream: remoteStreams?.at(-1),
237 isAudioOn,
238 setAudioStatus,
239 isVideoOn,
240 setVideoStatus,
241 callRole,
242 callStatus,
243 acceptCall,
244 }}
245 >
246 {children}
247 </CallContext.Provider>
248 );
249};