blob: 4cd626334fb4a65e4d1dcd9ecb1d5c4f30126a7d [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
simon492e8402022-11-29 16:48:37 -050028export type MediaDevicesInfo = Record<MediaDeviceKind, MediaDeviceInfo[]>;
29export type MediaInputKind = 'audio' | 'video';
30export type MediaInputIds = Record<MediaInputKind, string | false | undefined>;
31
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050032interface IWebRtcContext {
Charlieb837e8f2022-11-28 19:18:46 -050033 iceConnectionState: RTCIceConnectionState | undefined;
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050034
simon9076a9a2022-11-29 17:13:01 -050035 localStream: MediaStream | undefined;
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050036 remoteStreams: readonly MediaStream[] | undefined;
simon492e8402022-11-29 16:48:37 -050037 getMediaDevices: () => Promise<MediaDevicesInfo>;
38 updateLocalStream: (mediaDeviceIds?: MediaInputIds) => Promise<void>;
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050039
simon9076a9a2022-11-29 17:13:01 -050040 sendWebRtcOffer: () => Promise<void>;
41 closeConnection: () => void;
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050042}
43
44const defaultWebRtcContext: IWebRtcContext = {
Charlieb837e8f2022-11-28 19:18:46 -050045 iceConnectionState: undefined,
simon9076a9a2022-11-29 17:13:01 -050046 localStream: undefined,
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050047 remoteStreams: undefined,
simon492e8402022-11-29 16:48:37 -050048 getMediaDevices: async () => Promise.reject(),
49 updateLocalStream: async () => Promise.reject(),
50 sendWebRtcOffer: async () => Promise.reject(),
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>();
simon492e8402022-11-29 16:48:37 -0500106
107 const [audioRtcRtpSenders, setAudioRtcRtpSenders] = useState<RTCRtpSender[]>();
108 const [videoRtcRtpSenders, setVideoRtcRtpSenders] = useState<RTCRtpSender[]>();
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
simon492e8402022-11-29 16:48:37 -0500123 const getMediaDevices = useCallback(async (): Promise<MediaDevicesInfo> => {
simon9076a9a2022-11-29 17:13:01 -0500124 try {
125 const devices = await navigator.mediaDevices.enumerateDevices();
simon492e8402022-11-29 16:48:37 -0500126
127 // TODO: On Firefox, some devices can sometime be duplicated (2 devices can share the same deviceId). Using a map
128 // and then converting it to an array makes it so that there is no duplicate. If we find a way to prevent
129 // Firefox from listing 2 devices with the same deviceId, we can remove this logic.
130 const newMediaDevices: Record<MediaDeviceKind, Record<string, MediaDeviceInfo>> = {
131 audioinput: {},
132 audiooutput: {},
133 videoinput: {},
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500134 };
135
simon9076a9a2022-11-29 17:13:01 -0500136 for (const device of devices) {
simon492e8402022-11-29 16:48:37 -0500137 newMediaDevices[device.kind][device.deviceId] = device;
simon9076a9a2022-11-29 17:13:01 -0500138 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500139
simon492e8402022-11-29 16:48:37 -0500140 return {
141 audioinput: Object.values(newMediaDevices.audioinput),
142 audiooutput: Object.values(newMediaDevices.audiooutput),
143 videoinput: Object.values(newMediaDevices.videoinput),
144 };
simon9076a9a2022-11-29 17:13:01 -0500145 } catch (e) {
146 throw new Error('Could not get media devices', { cause: e });
147 }
148 }, []);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500149
simon492e8402022-11-29 16:48:37 -0500150 const updateLocalStream = useCallback(
151 async (mediaDeviceIds?: MediaInputIds) => {
152 const devices = await getMediaDevices();
simon9076a9a2022-11-29 17:13:01 -0500153
simon492e8402022-11-29 16:48:37 -0500154 let audioConstraint: MediaTrackConstraints | boolean = devices.audioinput.length !== 0;
155 let videoConstraint: MediaTrackConstraints | boolean = devices.videoinput.length !== 0;
156
157 if (!audioConstraint && !videoConstraint) {
158 return;
159 }
160
161 if (mediaDeviceIds?.audio !== undefined) {
162 audioConstraint = mediaDeviceIds.audio !== false ? { deviceId: mediaDeviceIds.audio } : false;
163 }
164 if (mediaDeviceIds?.video !== undefined) {
165 videoConstraint = mediaDeviceIds.video !== false ? { deviceId: mediaDeviceIds.video } : false;
166 }
167
simon9076a9a2022-11-29 17:13:01 -0500168 try {
simon492e8402022-11-29 16:48:37 -0500169 const stream = await navigator.mediaDevices.getUserMedia({
170 audio: audioConstraint,
171 video: videoConstraint,
172 });
173
174 for (const track of stream.getTracks()) {
175 track.enabled = false;
176 }
177
178 setLocalStream(stream);
simon9076a9a2022-11-29 17:13:01 -0500179 } catch (e) {
simon492e8402022-11-29 16:48:37 -0500180 throw new Error('Could not get media devices', { cause: e });
simon9076a9a2022-11-29 17:13:01 -0500181 }
simon492e8402022-11-29 16:48:37 -0500182 },
183 [getMediaDevices]
184 );
simon9076a9a2022-11-29 17:13:01 -0500185
simon492e8402022-11-29 16:48:37 -0500186 useEffect(() => {
187 if (!localStream || !webRtcConnection) {
simon9076a9a2022-11-29 17:13:01 -0500188 return;
189 }
190
simon492e8402022-11-29 16:48:37 -0500191 const updateTracks = async (kind: 'audio' | 'video') => {
192 const senders = kind === 'audio' ? audioRtcRtpSenders : videoRtcRtpSenders;
193 const tracks = kind === 'audio' ? localStream.getAudioTracks() : localStream.getVideoTracks();
194 if (senders) {
195 const promises: Promise<void>[] = [];
196 for (let i = 0; i < senders.length; i++) {
197 // TODO: There is a bug where calling multiple times `addTrack` when changing an input device doesn't work.
198 // Calling `addTrack` doesn't trigger the `track` event listener for the other user.
199 // This workaround makes it possible to replace a track, but it could be improved by figuring out the
200 // proper way of changing a track.
201 promises.push(
202 senders[i].replaceTrack(tracks[i]).catch((e) => {
203 console.error('Error replacing track:', e);
204 })
205 );
206 }
207 return Promise.all(promises);
simon9076a9a2022-11-29 17:13:01 -0500208 }
209
simon492e8402022-11-29 16:48:37 -0500210 // TODO: Currently, we do not support adding new devices. To enable this feature, we would need to implement
211 // the "Perfect negotiation" pattern to renegotiate after `addTrack`.
212 // https://blog.mozilla.org/webrtc/perfect-negotiation-in-webrtc/
213 const newSenders = tracks.map((track) => webRtcConnection.addTrack(track, localStream));
214 if (kind === 'audio') {
215 setAudioRtcRtpSenders(newSenders);
216 } else {
217 setVideoRtcRtpSenders(newSenders);
218 }
219 };
220
221 updateTracks('audio');
222 updateTracks('video');
223 }, [localStream, webRtcConnection, audioRtcRtpSenders, videoRtcRtpSenders]);
simon9076a9a2022-11-29 17:13:01 -0500224
225 const sendWebRtcOffer = useCallback(async () => {
226 const sdp = await webRtcConnection.createOffer({
227 offerToReceiveAudio: true,
228 offerToReceiveVideo: true,
229 });
230
231 const webRtcOffer: WebRtcSdp = {
232 contactId: contactUri,
233 conversationId: conversationId,
234 sdp,
235 };
236
237 await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
238 console.info('Sending WebRtcOffer', webRtcOffer);
239 webSocket.send(WebSocketMessageType.WebRtcOffer, webRtcOffer);
240 }, [webRtcConnection, webSocket, conversationId, contactUri]);
241
242 const sendWebRtcAnswer = useCallback(async () => {
243 const sdp = await webRtcConnection.createAnswer({
244 offerToReceiveAudio: true,
245 offerToReceiveVideo: true,
246 });
247
248 const webRtcAnswer: WebRtcSdp = {
249 contactId: contactUri,
250 conversationId: conversationId,
251 sdp,
252 };
253
254 await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
255 console.info('Sending WebRtcAnswer', webRtcAnswer);
256 webSocket.send(WebSocketMessageType.WebRtcAnswer, webRtcAnswer);
257 }, [contactUri, conversationId, webRtcConnection, webSocket]);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500258
simonf353ef42022-11-28 23:14:53 -0500259 /* WebSocket Listeners */
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500260
simonf353ef42022-11-28 23:14:53 -0500261 useEffect(() => {
simon25bfee82022-11-28 19:41:24 -0500262 const addQueuedIceCandidates = async () => {
263 console.info('WebRTC remote description has been set. Ready to receive ICE candidates');
264 setIsReadyForIceCandidates(true);
265 if (iceCandidateQueue.length !== 0) {
simon492e8402022-11-29 16:48:37 -0500266 console.warn(
267 'Found queued ICE candidates that were added before `setRemoteDescription` was called. ' +
268 'Adding queued ICE candidates...',
269 iceCandidateQueue
270 );
simon25bfee82022-11-28 19:41:24 -0500271
272 await Promise.all(iceCandidateQueue.map((iceCandidate) => webRtcConnection.addIceCandidate(iceCandidate)));
273 }
274 };
275
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500276 const webRtcOfferListener = async (data: WebRtcSdp) => {
277 console.info('Received event on WebRtcOffer', data);
Charliec18d6402022-11-27 13:01:04 -0500278 if (data.conversationId !== conversationId) {
279 console.warn('Wrong incoming conversationId, ignoring action');
280 return;
281 }
282
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500283 await webRtcConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
simon9076a9a2022-11-29 17:13:01 -0500284 await sendWebRtcAnswer();
simon25bfee82022-11-28 19:41:24 -0500285 await addQueuedIceCandidates();
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500286 };
287
288 const webRtcAnswerListener = async (data: WebRtcSdp) => {
289 console.info('Received event on WebRtcAnswer', data);
Charliec18d6402022-11-27 13:01:04 -0500290 if (data.conversationId !== conversationId) {
291 console.warn('Wrong incoming conversationId, ignoring action');
292 return;
293 }
294
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500295 await webRtcConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
simon25bfee82022-11-28 19:41:24 -0500296 await addQueuedIceCandidates();
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500297 };
simon9076a9a2022-11-29 17:13:01 -0500298
simonf353ef42022-11-28 23:14:53 -0500299 webSocket.bind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
300 webSocket.bind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500301
simonf353ef42022-11-28 23:14:53 -0500302 return () => {
303 webSocket.unbind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
304 webSocket.unbind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
305 };
simon25bfee82022-11-28 19:41:24 -0500306 }, [webSocket, webRtcConnection, sendWebRtcAnswer, conversationId, iceCandidateQueue]);
simonf353ef42022-11-28 23:14:53 -0500307
308 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500309 const webRtcIceCandidateListener = async (data: WebRtcIceCandidate) => {
Charliec18d6402022-11-27 13:01:04 -0500310 if (data.conversationId !== conversationId) {
311 console.warn('Wrong incoming conversationId, ignoring action');
312 return;
313 }
314
simon25bfee82022-11-28 19:41:24 -0500315 if (!data.candidate) {
316 return;
317 }
318
319 if (isReadyForIceCandidates) {
320 await webRtcConnection.addIceCandidate(data.candidate);
321 } else {
simon25bfee82022-11-28 19:41:24 -0500322 setIceCandidateQueue((v) => {
323 v.push(data.candidate);
324 return v;
325 });
326 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500327 };
328
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500329 webSocket.bind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
330
331 return () => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500332 webSocket.unbind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
333 };
simon25bfee82022-11-28 19:41:24 -0500334 }, [webRtcConnection, webSocket, conversationId, isReadyForIceCandidates]);
simonf353ef42022-11-28 23:14:53 -0500335
336 /* WebRTC Listeners */
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500337
338 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500339 const iceCandidateEventListener = (event: RTCPeerConnectionIceEvent) => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500340 if (event.candidate) {
341 const webRtcIceCandidate: WebRtcIceCandidate = {
342 contactId: contactUri,
Charliec18d6402022-11-27 13:01:04 -0500343 conversationId: conversationId,
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500344 candidate: event.candidate,
345 };
346
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500347 webSocket.send(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidate);
348 }
349 };
simonf353ef42022-11-28 23:14:53 -0500350 webRtcConnection.addEventListener('icecandidate', iceCandidateEventListener);
351
352 return () => {
353 webRtcConnection.removeEventListener('icecandidate', iceCandidateEventListener);
354 };
355 }, [webRtcConnection, webSocket, contactUri, conversationId]);
356
357 useEffect(() => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500358 const trackEventListener = (event: RTCTrackEvent) => {
359 console.info('Received WebRTC event on track', event);
360 setRemoteStreams(event.streams);
361 };
362
simon9076a9a2022-11-29 17:13:01 -0500363 const iceConnectionStateChangeEventListener = (event: Event) => {
364 console.info(`Received WebRTC event on iceconnectionstatechange: ${webRtcConnection.iceConnectionState}`, event);
Charlieb837e8f2022-11-28 19:18:46 -0500365 setIceConnectionState(webRtcConnection.iceConnectionState);
simonfeaa1db2022-11-26 20:13:18 -0500366 };
367
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500368 webRtcConnection.addEventListener('track', trackEventListener);
MichelleSS55164202022-11-25 18:36:14 -0500369 webRtcConnection.addEventListener('iceconnectionstatechange', iceConnectionStateChangeEventListener);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500370
371 return () => {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500372 webRtcConnection.removeEventListener('track', trackEventListener);
MichelleSS55164202022-11-25 18:36:14 -0500373 webRtcConnection.removeEventListener('iceconnectionstatechange', iceConnectionStateChangeEventListener);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500374 };
simonf353ef42022-11-28 23:14:53 -0500375 }, [webRtcConnection]);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500376
simon9076a9a2022-11-29 17:13:01 -0500377 const closeConnection = useCallback(() => {
378 const localTracks = localStream?.getTracks();
379 if (localTracks) {
380 for (const track of localTracks) {
381 track.stop();
382 }
383 }
384
385 webRtcConnection.close();
386 }, [webRtcConnection, localStream]);
387
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500388 return (
389 <WebRtcContext.Provider
390 value={{
Charlieb837e8f2022-11-28 19:18:46 -0500391 iceConnectionState,
simon9076a9a2022-11-29 17:13:01 -0500392 localStream,
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500393 remoteStreams,
simon492e8402022-11-29 16:48:37 -0500394 getMediaDevices,
395 updateLocalStream,
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500396 sendWebRtcOffer,
simon9076a9a2022-11-29 17:13:01 -0500397 closeConnection,
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500398 }}
399 >
400 {children}
401 </WebRtcContext.Provider>
402 );
403};