blob: 9bf7a9a5b112b35e6053950b2049ef3395474b84 [file] [log] [blame]
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -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 */
18
19import { WebRtcIceCandidate, WebRtcSdp, WebSocketMessageType } from 'jami-web-common';
20import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
21
simonf353ef42022-11-28 23:14:53 -050022import LoadingPage from '../components/Loading';
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050023import { WithChildren } from '../utils/utils';
simon71d1c0a2022-11-24 15:28:33 -050024import { useAuthContext } from './AuthProvider';
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050025import { ConversationContext } from './ConversationProvider';
simonf353ef42022-11-28 23:14:53 -050026import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050027
28interface IWebRtcContext {
Charlieb837e8f2022-11-28 19:18:46 -050029 iceConnectionState: RTCIceConnectionState | undefined;
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050030
simon9076a9a2022-11-29 17:13:01 -050031 mediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]>;
32 localStream: MediaStream | undefined;
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050033 remoteStreams: readonly MediaStream[] | undefined;
simon9076a9a2022-11-29 17:13:01 -050034 getUserMedia: () => Promise<void>;
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050035
simon9076a9a2022-11-29 17:13:01 -050036 sendWebRtcOffer: () => Promise<void>;
37 closeConnection: () => void;
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050038}
39
40const defaultWebRtcContext: IWebRtcContext = {
Charlieb837e8f2022-11-28 19:18:46 -050041 iceConnectionState: undefined,
simon9076a9a2022-11-29 17:13:01 -050042 mediaDevices: {
43 audioinput: [],
44 audiooutput: [],
45 videoinput: [],
46 },
47 localStream: undefined,
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050048 remoteStreams: undefined,
simon9076a9a2022-11-29 17:13:01 -050049 getUserMedia: async () => {},
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050050 sendWebRtcOffer: async () => {},
simon9076a9a2022-11-29 17:13:01 -050051 closeConnection: () => {},
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050052};
53
54export const WebRtcContext = createContext<IWebRtcContext>(defaultWebRtcContext);
55
56export default ({ children }: WithChildren) => {
simon71d1c0a2022-11-24 15:28:33 -050057 const { account } = useAuthContext();
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050058 const [webRtcConnection, setWebRtcConnection] = useState<RTCPeerConnection | undefined>();
simonf353ef42022-11-28 23:14:53 -050059 const webSocket = useContext(WebSocketContext);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050060
61 useEffect(() => {
simon71d1c0a2022-11-24 15:28:33 -050062 if (!webRtcConnection && account) {
63 const iceServers: RTCIceServer[] = [];
64
65 if (account.getDetails()['TURN.enable'] === 'true') {
66 iceServers.push({
67 urls: 'turn:' + account.getDetails()['TURN.server'],
68 username: account.getDetails()['TURN.username'],
69 credential: account.getDetails()['TURN.password'],
70 });
71 }
72
73 if (account.getDetails()['STUN.enable'] === 'true') {
74 iceServers.push({
75 urls: 'stun:' + account.getDetails()['STUN.server'],
76 });
77 }
78
simon9076a9a2022-11-29 17:13:01 -050079 setWebRtcConnection(new RTCPeerConnection({ iceServers }));
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050080 }
simon71d1c0a2022-11-24 15:28:33 -050081 }, [account, webRtcConnection]);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050082
simonf353ef42022-11-28 23:14:53 -050083 if (!webRtcConnection || !webSocket) {
84 return <LoadingPage />;
85 }
86
87 return (
88 <WebRtcProvider webRtcConnection={webRtcConnection} webSocket={webSocket}>
89 {children}
90 </WebRtcProvider>
91 );
92};
93
94const WebRtcProvider = ({
95 children,
96 webRtcConnection,
97 webSocket,
98}: WithChildren & {
99 webRtcConnection: RTCPeerConnection;
100 webSocket: IWebSocketContext;
101}) => {
102 const { conversation, conversationId } = useContext(ConversationContext);
simon9076a9a2022-11-29 17:13:01 -0500103 const [localStream, setLocalStream] = useState<MediaStream>();
simonf353ef42022-11-28 23:14:53 -0500104 const [remoteStreams, setRemoteStreams] = useState<readonly MediaStream[]>();
Charlieb837e8f2022-11-28 19:18:46 -0500105 const [iceConnectionState, setIceConnectionState] = useState<RTCIceConnectionState | undefined>();
simon9076a9a2022-11-29 17:13:01 -0500106 const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>(
107 defaultWebRtcContext.mediaDevices
108 );
simonf353ef42022-11-28 23:14:53 -0500109
simon25bfee82022-11-28 19:41:24 -0500110 // TODO: The ICE candidate queue is used to cache candidates that were received before `setRemoteDescription` was
111 // called. This is currently necessary, because the jami-daemon is unreliable as a WebRTC signaling channel,
112 // because messages can be received with a delay or out of order. This queue is a temporary workaround that
113 // should be replaced if there is a better way to send messages with the daemon.
114 // Relevant links:
115 // - https://github.com/w3c/webrtc-pc/issues/2519#issuecomment-622055440
116 // - https://stackoverflow.com/questions/57256828/how-to-fix-invalidstateerror-cannot-add-ice-candidate-when-there-is-no-remote-s
117 const [isReadyForIceCandidates, setIsReadyForIceCandidates] = useState(false);
118 const [iceCandidateQueue, setIceCandidateQueue] = useState<RTCIceCandidate[]>([]);
119
simonf353ef42022-11-28 23:14:53 -0500120 // TODO: This logic will have to change to support multiple people in a call
121 const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
122
simon9076a9a2022-11-29 17:13:01 -0500123 const getMediaDevices = useCallback(async () => {
124 try {
125 const devices = await navigator.mediaDevices.enumerateDevices();
126 const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
127 audioinput: [],
128 audiooutput: [],
129 videoinput: [],
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500130 };
131
simon9076a9a2022-11-29 17:13:01 -0500132 for (const device of devices) {
133 newMediaDevices[device.kind].push(device);
134 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500135
simon9076a9a2022-11-29 17:13:01 -0500136 return newMediaDevices;
137 } catch (e) {
138 throw new Error('Could not get media devices', { cause: e });
139 }
140 }, []);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500141
simon9076a9a2022-11-29 17:13:01 -0500142 useEffect(() => {
143 if (iceConnectionState !== 'connected' && iceConnectionState !== 'completed') {
144 return;
145 }
146
147 const updateMediaDevices = async () => {
148 try {
149 const newMediaDevices = await getMediaDevices();
150 setMediaDevices(newMediaDevices);
151 } catch (e) {
152 console.error('Could not update media devices:', e);
153 }
154 };
155
156 navigator.mediaDevices.addEventListener('devicechange', updateMediaDevices);
157 updateMediaDevices();
158
159 return () => {
160 navigator.mediaDevices.removeEventListener('devicechange', updateMediaDevices);
161 };
162 }, [getMediaDevices, iceConnectionState]);
163
164 const getUserMedia = useCallback(async () => {
165 const devices = await getMediaDevices();
166
167 const shouldGetAudio = devices.audioinput.length !== 0;
168 const shouldGetVideo = devices.videoinput.length !== 0;
169
170 if (!shouldGetAudio && !shouldGetVideo) {
171 return;
172 }
173
174 try {
175 const stream = await navigator.mediaDevices.getUserMedia({
176 audio: shouldGetAudio,
177 video: shouldGetVideo,
178 });
179
180 for (const track of stream.getTracks()) {
181 track.enabled = false;
182 webRtcConnection.addTrack(track, stream);
183 }
184
185 setLocalStream(stream);
186 } catch (e) {
187 throw new Error('Could not get media devices', { cause: e });
188 }
189 }, [webRtcConnection, getMediaDevices]);
190
191 const sendWebRtcOffer = useCallback(async () => {
192 const sdp = await webRtcConnection.createOffer({
193 offerToReceiveAudio: true,
194 offerToReceiveVideo: true,
195 });
196
197 const webRtcOffer: WebRtcSdp = {
198 contactId: contactUri,
199 conversationId: conversationId,
200 sdp,
201 };
202
203 await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
204 console.info('Sending WebRtcOffer', webRtcOffer);
205 webSocket.send(WebSocketMessageType.WebRtcOffer, webRtcOffer);
206 }, [webRtcConnection, webSocket, conversationId, contactUri]);
207
208 const sendWebRtcAnswer = useCallback(async () => {
209 const sdp = await webRtcConnection.createAnswer({
210 offerToReceiveAudio: true,
211 offerToReceiveVideo: true,
212 });
213
214 const webRtcAnswer: WebRtcSdp = {
215 contactId: contactUri,
216 conversationId: conversationId,
217 sdp,
218 };
219
220 await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
221 console.info('Sending WebRtcAnswer', webRtcAnswer);
222 webSocket.send(WebSocketMessageType.WebRtcAnswer, webRtcAnswer);
223 }, [contactUri, conversationId, webRtcConnection, webSocket]);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500224
simonf353ef42022-11-28 23:14:53 -0500225 /* WebSocket Listeners */
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500226
simonf353ef42022-11-28 23:14:53 -0500227 useEffect(() => {
simon25bfee82022-11-28 19:41:24 -0500228 const addQueuedIceCandidates = async () => {
229 console.info('WebRTC remote description has been set. Ready to receive ICE candidates');
230 setIsReadyForIceCandidates(true);
231 if (iceCandidateQueue.length !== 0) {
232 console.warn('Adding queued ICE candidates...', iceCandidateQueue);
233
234 await Promise.all(iceCandidateQueue.map((iceCandidate) => webRtcConnection.addIceCandidate(iceCandidate)));
235 }
236 };
237
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500238 const webRtcOfferListener = async (data: WebRtcSdp) => {
239 console.info('Received event on WebRtcOffer', data);
Charliec18d6402022-11-27 13:01:04 -0500240 if (data.conversationId !== conversationId) {
241 console.warn('Wrong incoming conversationId, ignoring action');
242 return;
243 }
244
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500245 await webRtcConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
simon9076a9a2022-11-29 17:13:01 -0500246 await sendWebRtcAnswer();
simon25bfee82022-11-28 19:41:24 -0500247 await addQueuedIceCandidates();
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500248 };
249
250 const webRtcAnswerListener = async (data: WebRtcSdp) => {
251 console.info('Received event on WebRtcAnswer', data);
Charliec18d6402022-11-27 13:01:04 -0500252 if (data.conversationId !== conversationId) {
253 console.warn('Wrong incoming conversationId, ignoring action');
254 return;
255 }
256
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500257 await webRtcConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
simon25bfee82022-11-28 19:41:24 -0500258 await addQueuedIceCandidates();
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500259 };
simon9076a9a2022-11-29 17:13:01 -0500260
simonf353ef42022-11-28 23:14:53 -0500261 webSocket.bind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
262 webSocket.bind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500263
simonf353ef42022-11-28 23:14:53 -0500264 return () => {
265 webSocket.unbind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
266 webSocket.unbind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
267 };
simon25bfee82022-11-28 19:41:24 -0500268 }, [webSocket, webRtcConnection, sendWebRtcAnswer, conversationId, iceCandidateQueue]);
simonf353ef42022-11-28 23:14:53 -0500269
270 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500271 const webRtcIceCandidateListener = async (data: WebRtcIceCandidate) => {
Charliec18d6402022-11-27 13:01:04 -0500272 if (data.conversationId !== conversationId) {
273 console.warn('Wrong incoming conversationId, ignoring action');
274 return;
275 }
276
simon25bfee82022-11-28 19:41:24 -0500277 if (!data.candidate) {
278 return;
279 }
280
281 if (isReadyForIceCandidates) {
282 await webRtcConnection.addIceCandidate(data.candidate);
283 } else {
284 console.warn(
285 "Received event on WebRtcIceCandidate before 'setRemoteDescription' was called. Pushing to ICE candidates queue...",
286 data
287 );
288 setIceCandidateQueue((v) => {
289 v.push(data.candidate);
290 return v;
291 });
292 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500293 };
294
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500295 webSocket.bind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
296
297 return () => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500298 webSocket.unbind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
299 };
simon25bfee82022-11-28 19:41:24 -0500300 }, [webRtcConnection, webSocket, conversationId, isReadyForIceCandidates]);
simonf353ef42022-11-28 23:14:53 -0500301
302 /* WebRTC Listeners */
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500303
304 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500305 const iceCandidateEventListener = (event: RTCPeerConnectionIceEvent) => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500306 if (event.candidate) {
307 const webRtcIceCandidate: WebRtcIceCandidate = {
308 contactId: contactUri,
Charliec18d6402022-11-27 13:01:04 -0500309 conversationId: conversationId,
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500310 candidate: event.candidate,
311 };
312
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500313 webSocket.send(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidate);
314 }
315 };
simonf353ef42022-11-28 23:14:53 -0500316 webRtcConnection.addEventListener('icecandidate', iceCandidateEventListener);
317
318 return () => {
319 webRtcConnection.removeEventListener('icecandidate', iceCandidateEventListener);
320 };
321 }, [webRtcConnection, webSocket, contactUri, conversationId]);
322
323 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500324 const trackEventListener = (event: RTCTrackEvent) => {
325 console.info('Received WebRTC event on track', event);
326 setRemoteStreams(event.streams);
327 };
328
simon9076a9a2022-11-29 17:13:01 -0500329 const iceConnectionStateChangeEventListener = (event: Event) => {
330 console.info(`Received WebRTC event on iceconnectionstatechange: ${webRtcConnection.iceConnectionState}`, event);
Charlieb837e8f2022-11-28 19:18:46 -0500331 setIceConnectionState(webRtcConnection.iceConnectionState);
simonfeaa1db2022-11-26 20:13:18 -0500332 };
333
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500334 webRtcConnection.addEventListener('track', trackEventListener);
MichelleSS55164202022-11-25 18:36:14 -0500335 webRtcConnection.addEventListener('iceconnectionstatechange', iceConnectionStateChangeEventListener);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500336
337 return () => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500338 webRtcConnection.removeEventListener('track', trackEventListener);
MichelleSS55164202022-11-25 18:36:14 -0500339 webRtcConnection.removeEventListener('iceconnectionstatechange', iceConnectionStateChangeEventListener);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500340 };
simonf353ef42022-11-28 23:14:53 -0500341 }, [webRtcConnection]);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500342
simon9076a9a2022-11-29 17:13:01 -0500343 const closeConnection = useCallback(() => {
344 const localTracks = localStream?.getTracks();
345 if (localTracks) {
346 for (const track of localTracks) {
347 track.stop();
348 }
349 }
350
351 webRtcConnection.close();
352 }, [webRtcConnection, localStream]);
353
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500354 return (
355 <WebRtcContext.Provider
356 value={{
Charlieb837e8f2022-11-28 19:18:46 -0500357 iceConnectionState,
simon9076a9a2022-11-29 17:13:01 -0500358 mediaDevices,
359 localStream,
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500360 remoteStreams,
simon9076a9a2022-11-29 17:13:01 -0500361 getUserMedia,
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500362 sendWebRtcOffer,
simon9076a9a2022-11-29 17:13:01 -0500363 closeConnection,
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500364 }}
365 >
366 {children}
367 </WebRtcContext.Provider>
368 );
369};