Move WebRtc logic to WebRtcManager
- This is intended to reduce the quantity of useEffect, which causes "cascading" updates. The end goal is to reintroduce StrictMode (soon), and hopefully to make the code easier to understand.
- Some logic to support calls with more than one peer has been added. This is intended to test the new architecture will be scalable with React hooks.
Change-Id: Id76c4061b06a759f55957a48a1283d46afc3f73a
diff --git a/client/src/contexts/WebRtcProvider.tsx b/client/src/contexts/WebRtcProvider.tsx
index e35df0c..f6bc81e 100644
--- a/client/src/contexts/WebRtcProvider.tsx
+++ b/client/src/contexts/WebRtcProvider.tsx
@@ -16,14 +16,14 @@
* <https://www.gnu.org/licenses/>.
*/
-import { WebRtcIceCandidate, WebRtcSdp, WebSocketMessageType } from 'jami-web-common';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createOptionalContext } from '../hooks/createOptionalContext';
import { ConversationMember } from '../models/conversation-member';
import { WithChildren } from '../utils/utils';
+import { useWebRtcManager } from '../webrtc/WebRtcManager';
import { useAuthContext } from './AuthProvider';
-import { CallManagerContext } from './CallManagerProvider';
+import { CallData, CallManagerContext } from './CallManagerProvider';
import ConditionalContextProvider from './ConditionalContextProvider';
import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
@@ -36,7 +36,7 @@
localStream: MediaStream | undefined;
screenShareLocalStream: MediaStream | undefined;
- remoteStreams: readonly MediaStream[] | undefined;
+ remoteStreams: readonly MediaStream[];
getMediaDevices: () => Promise<MediaDevicesInfo>;
updateLocalStream: (mediaDeviceIds?: MediaInputIds) => Promise<void>;
updateScreenShare: (active: boolean) => Promise<MediaStream | undefined>;
@@ -49,47 +49,17 @@
export const useWebRtcContext = optionalWebRtcContext.useOptionalContext;
export default ({ children }: WithChildren) => {
- const { account } = useAuthContext();
- const [webRtcConnection, setWebRtcConnection] = useState<RTCPeerConnection | undefined>();
const webSocket = useContext(WebSocketContext);
const { callConversationInfos, callMembers, callData } = useContext(CallManagerContext);
- useEffect(() => {
- if (webRtcConnection && !callData) {
- setWebRtcConnection(undefined);
- return;
- }
-
- if (!webRtcConnection && account && callData) {
- const iceServers: RTCIceServer[] = [];
-
- if (account.details['TURN.enable'] === 'true') {
- iceServers.push({
- urls: 'turn:' + account.details['TURN.server'],
- username: account.details['TURN.username'],
- credential: account.details['TURN.password'],
- });
- }
-
- if (account.details['STUN.enable'] === 'true') {
- iceServers.push({
- urls: 'stun:' + account.details['STUN.server'],
- });
- }
-
- setWebRtcConnection(new RTCPeerConnection({ iceServers }));
- }
- }, [account, webRtcConnection, callData]);
-
const dependencies = useMemo(
() => ({
- webRtcConnection,
webSocket,
conversationInfos: callConversationInfos,
members: callMembers,
- conversationId: callData?.conversationId,
+ callData: callData,
}),
- [webRtcConnection, webSocket, callConversationInfos, callMembers, callData?.conversationId]
+ [webSocket, callConversationInfos, callMembers, callData]
);
return (
@@ -106,35 +76,30 @@
const useWebRtcContextValue = ({
members,
- conversationId,
- webRtcConnection,
+ callData,
webSocket,
}: {
- webRtcConnection: RTCPeerConnection;
webSocket: IWebSocketContext;
members: ConversationMember[];
- conversationId: string;
+ callData: CallData;
}) => {
const [localStream, setLocalStream] = useState<MediaStream>();
const [screenShareLocalStream, setScreenShareLocalStream] = useState<MediaStream>();
- const [remoteStreams, setRemoteStreams] = useState<readonly MediaStream[]>();
- const [iceConnectionState, setIceConnectionState] = useState<RTCIceConnectionState | undefined>();
-
- const [audioRtcRtpSenders, setAudioRtcRtpSenders] = useState<RTCRtpSender[]>();
- const [videoRtcRtpSenders, setVideoRtcRtpSenders] = useState<RTCRtpSender[]>();
-
- // TODO: The ICE candidate queue is used to cache candidates that were received before `setRemoteDescription` was
- // called. This is currently necessary, because the jami-daemon is unreliable as a WebRTC signaling channel,
- // because messages can be received with a delay or out of order. This queue is a temporary workaround that
- // should be replaced if there is a better way to send messages with the daemon.
- // Relevant links:
- // - https://github.com/w3c/webrtc-pc/issues/2519#issuecomment-622055440
- // - https://stackoverflow.com/questions/57256828/how-to-fix-invalidstateerror-cannot-add-ice-candidate-when-there-is-no-remote-s
- const [isReadyForIceCandidates, setIsReadyForIceCandidates] = useState(false);
- const [iceCandidateQueue, setIceCandidateQueue] = useState<RTCIceCandidate[]>([]);
+ const { account } = useAuthContext();
+ const webRtcManager = useWebRtcManager();
// TODO: This logic will have to change to support multiple people in a call
- const contactUri = useMemo(() => members[0]?.contact.uri, [members]);
+ const contactUri = members[0]?.contact.uri;
+ const connectionInfos = webRtcManager.connectionsInfos[contactUri];
+ const remoteStreams = connectionInfos?.remoteStreams;
+ const iceConnectionState = connectionInfos?.iceConnectionState;
+
+ // TODO: Replace this by a callback
+ useEffect(() => {
+ if (callData.role === 'receiver' && contactUri && localStream) {
+ webRtcManager.addConnection(webSocket, account, contactUri, callData, localStream, screenShareLocalStream);
+ }
+ }, [account, callData, contactUri, localStream, screenShareLocalStream, webRtcManager, webSocket]);
const getMediaDevices = useCallback(async (): Promise<MediaDevicesInfo> => {
try {
@@ -222,202 +187,21 @@
[screenShareLocalStream]
);
+ // TODO: Transform the effect into a callback
+ const updateLocalStreams = webRtcManager.updateLocalStreams;
useEffect(() => {
- if ((!localStream && !screenShareLocalStream) || !webRtcConnection) {
+ if ((!localStream && !screenShareLocalStream) || !updateLocalStreams) {
return;
}
- const updateTracks = async (stream: MediaStream, kind: 'audio' | 'video') => {
- const senders = kind === 'audio' ? audioRtcRtpSenders : videoRtcRtpSenders;
- const tracks = kind === 'audio' ? stream.getAudioTracks() : stream.getVideoTracks();
- if (senders) {
- const promises: Promise<void>[] = [];
- for (let i = 0; i < senders.length; i++) {
- // TODO: There is a bug where calling multiple times `addTrack` when changing an input device doesn't work.
- // Calling `addTrack` doesn't trigger the `track` event listener for the other user.
- // This workaround makes it possible to replace a track, but it could be improved by figuring out the
- // proper way of changing a track.
- promises.push(
- senders[i].replaceTrack(tracks[i]).catch((e) => {
- console.error('Error replacing track:', e);
- })
- );
- }
- return Promise.all(promises);
- }
-
- // TODO: Currently, we do not support adding new devices. To enable this feature, we would need to implement
- // the "Perfect negotiation" pattern to renegotiate after `addTrack`.
- // https://blog.mozilla.org/webrtc/perfect-negotiation-in-webrtc/
- const newSenders = tracks.map((track) => webRtcConnection.addTrack(track, stream));
- if (kind === 'audio') {
- setAudioRtcRtpSenders(newSenders);
- } else {
- setVideoRtcRtpSenders(newSenders);
- }
- };
-
- if (localStream) {
- updateTracks(localStream, 'audio');
- updateTracks(localStream, 'video');
- }
-
- if (screenShareLocalStream) {
- updateTracks(screenShareLocalStream, 'video');
- }
- }, [localStream, screenShareLocalStream, webRtcConnection, audioRtcRtpSenders, videoRtcRtpSenders]);
+ updateLocalStreams(localStream, screenShareLocalStream);
+ }, [localStream, screenShareLocalStream, updateLocalStreams]);
const sendWebRtcOffer = useCallback(async () => {
- const sdp = await webRtcConnection.createOffer({
- offerToReceiveAudio: true,
- offerToReceiveVideo: true,
- });
-
- const webRtcOffer: WebRtcSdp = {
- contactId: contactUri,
- conversationId: conversationId,
- sdp,
- };
-
- await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
- console.info('Sending WebRtcOffer', webRtcOffer);
- webSocket.send(WebSocketMessageType.WebRtcOffer, webRtcOffer);
- }, [webRtcConnection, webSocket, conversationId, contactUri]);
-
- const sendWebRtcAnswer = useCallback(async () => {
- const sdp = await webRtcConnection.createAnswer({
- offerToReceiveAudio: true,
- offerToReceiveVideo: true,
- });
-
- const webRtcAnswer: WebRtcSdp = {
- contactId: contactUri,
- conversationId: conversationId,
- sdp,
- };
-
- await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
- console.info('Sending WebRtcAnswer', webRtcAnswer);
- webSocket.send(WebSocketMessageType.WebRtcAnswer, webRtcAnswer);
- }, [contactUri, conversationId, webRtcConnection, webSocket]);
-
- /* WebSocket Listeners */
-
- useEffect(() => {
- const addQueuedIceCandidates = async () => {
- console.info('WebRTC remote description has been set. Ready to receive ICE candidates');
- setIsReadyForIceCandidates(true);
- if (iceCandidateQueue.length !== 0) {
- console.warn(
- 'Found queued ICE candidates that were added before `setRemoteDescription` was called. ' +
- 'Adding queued ICE candidates...',
- iceCandidateQueue
- );
-
- await Promise.all(iceCandidateQueue.map((iceCandidate) => webRtcConnection.addIceCandidate(iceCandidate)));
- }
- };
-
- const webRtcOfferListener = async (data: WebRtcSdp) => {
- console.info('Received event on WebRtcOffer', data);
- if (data.conversationId !== conversationId) {
- console.warn('Wrong incoming conversationId, ignoring action');
- return;
- }
-
- await webRtcConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
- await sendWebRtcAnswer();
- await addQueuedIceCandidates();
- };
-
- const webRtcAnswerListener = async (data: WebRtcSdp) => {
- console.info('Received event on WebRtcAnswer', data);
- if (data.conversationId !== conversationId) {
- console.warn('Wrong incoming conversationId, ignoring action');
- return;
- }
-
- await webRtcConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
- await addQueuedIceCandidates();
- };
-
- webSocket.bind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
- webSocket.bind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
-
- return () => {
- webSocket.unbind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
- webSocket.unbind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
- };
- }, [webSocket, webRtcConnection, sendWebRtcAnswer, conversationId, iceCandidateQueue]);
-
- useEffect(() => {
- const webRtcIceCandidateListener = async (data: WebRtcIceCandidate) => {
- if (data.conversationId !== conversationId) {
- console.warn('Wrong incoming conversationId, ignoring action');
- return;
- }
-
- if (!data.candidate) {
- return;
- }
-
- if (isReadyForIceCandidates) {
- await webRtcConnection.addIceCandidate(data.candidate);
- } else {
- setIceCandidateQueue((v) => {
- v.push(data.candidate);
- return v;
- });
- }
- };
-
- webSocket.bind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
-
- return () => {
- webSocket.unbind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
- };
- }, [webRtcConnection, webSocket, conversationId, isReadyForIceCandidates]);
-
- /* WebRTC Listeners */
-
- useEffect(() => {
- const iceCandidateEventListener = (event: RTCPeerConnectionIceEvent) => {
- if (event.candidate) {
- const webRtcIceCandidate: WebRtcIceCandidate = {
- contactId: contactUri,
- conversationId: conversationId,
- candidate: event.candidate,
- };
-
- webSocket.send(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidate);
- }
- };
- webRtcConnection.addEventListener('icecandidate', iceCandidateEventListener);
-
- return () => {
- webRtcConnection.removeEventListener('icecandidate', iceCandidateEventListener);
- };
- }, [webRtcConnection, webSocket, contactUri, conversationId]);
-
- useEffect(() => {
- const trackEventListener = (event: RTCTrackEvent) => {
- console.info('Received WebRTC event on track', event);
- setRemoteStreams(event.streams);
- };
-
- const iceConnectionStateChangeEventListener = (event: Event) => {
- console.info(`Received WebRTC event on iceconnectionstatechange: ${webRtcConnection.iceConnectionState}`, event);
- setIceConnectionState(webRtcConnection.iceConnectionState);
- };
-
- webRtcConnection.addEventListener('track', trackEventListener);
- webRtcConnection.addEventListener('iceconnectionstatechange', iceConnectionStateChangeEventListener);
-
- return () => {
- webRtcConnection.removeEventListener('track', trackEventListener);
- webRtcConnection.removeEventListener('iceconnectionstatechange', iceConnectionStateChangeEventListener);
- };
- }, [webRtcConnection]);
+ if (contactUri) {
+ webRtcManager.addConnection(webSocket, account, contactUri, callData, localStream, screenShareLocalStream);
+ }
+ }, [account, callData, contactUri, localStream, screenShareLocalStream, webRtcManager, webSocket]);
const closeConnection = useCallback(() => {
const stopStream = (stream: MediaStream) => {
@@ -436,8 +220,8 @@
stopStream(screenShareLocalStream);
}
- webRtcConnection.close();
- }, [webRtcConnection, localStream, screenShareLocalStream]);
+ webRtcManager.clean();
+ }, [localStream, screenShareLocalStream, webRtcManager]);
return useMemo(
() => ({
diff --git a/client/src/utils/utils.ts b/client/src/utils/utils.ts
index 794fb92..53b8a02 100644
--- a/client/src/utils/utils.ts
+++ b/client/src/utils/utils.ts
@@ -43,3 +43,5 @@
export const isRequired = <T>(obj: Partial<T>): obj is T => {
return Object.values(obj).every((v) => v !== undefined);
};
+
+export type Listener = () => void;
diff --git a/client/src/webrtc/RtcPeerConnectionHandler.ts b/client/src/webrtc/RtcPeerConnectionHandler.ts
new file mode 100644
index 0000000..8d83e5d
--- /dev/null
+++ b/client/src/webrtc/RtcPeerConnectionHandler.ts
@@ -0,0 +1,287 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+
+import { WebRtcIceCandidate, WebRtcSdp, WebSocketMessageType } from 'jami-web-common';
+
+import { CallData } from '../contexts/CallManagerProvider';
+import { IWebSocketContext } from '../contexts/WebSocketProvider';
+import { Account } from '../models/account';
+import { Listener } from '../utils/utils';
+
+export type RTCPeerConnectionInfos = {
+ remoteStreams: readonly MediaStream[];
+ iceConnectionState: RTCIceConnectionState;
+};
+
+export class RTCPeerConnectionHandler {
+ private readonly connection: RTCPeerConnection;
+ private remoteStreams: readonly MediaStream[] = [];
+ private iceConnectionState: RTCIceConnectionState = 'new';
+ private isReadyForIceCandidates = false;
+
+ private audioRtcRtpSenders: RTCRtpSender[] | null = null;
+ private videoRtcRtpSenders: RTCRtpSender[] | null = null;
+
+ // TODO: The ICE candidate queue is used to cache candidates that were received before `setRemoteDescription` was
+ // called. This is currently necessary, because the jami-daemon is unreliable as a WebRTC signaling channel,
+ // because messages can be received with a delay or out of order. This queue is a temporary workaround that
+ // should be replaced if there is a better way to send messages with the daemon.
+ // Relevant links:
+ // - https://github.com/w3c/webrtc-pc/issues/2519#issuecomment-622055440
+ // - https://stackoverflow.com/questions/57256828/how-to-fix-invalidstateerror-cannot-add-ice-candidate-when-there-is-no-remote-s
+ private iceCandidateQueue: RTCIceCandidate[] = [];
+
+ private cleaningFunctions: (() => void)[] = [];
+ private listener: Listener;
+
+ getInfos(): RTCPeerConnectionInfos {
+ return {
+ remoteStreams: this.remoteStreams,
+ iceConnectionState: this.iceConnectionState,
+ };
+ }
+
+ constructor(
+ webSocket: IWebSocketContext,
+ account: Account,
+ contactUri: string,
+ callData: CallData,
+ localStream: MediaStream | undefined,
+ screenShareLocalStream: MediaStream | undefined,
+ listener: Listener
+ ) {
+ this.listener = listener;
+ const iceServers = this.getIceServers(account);
+ this.connection = new RTCPeerConnection({ iceServers });
+ this.setConnectionListeners(webSocket, callData.conversationId, contactUri);
+ this.setWebSocketListeners(webSocket, callData.conversationId, contactUri);
+ this.updateLocalStreams(localStream, screenShareLocalStream);
+
+ if (callData.role === 'caller') {
+ this.startNegociation(webSocket, contactUri, callData.conversationId);
+ }
+ }
+
+ updateLocalStreams(localStream: MediaStream | undefined, screenShareLocalStream: MediaStream | undefined) {
+ if (this.connection.iceConnectionState === 'closed') {
+ return;
+ }
+
+ const updateTracks = async (stream: MediaStream, kind: 'audio' | 'video') => {
+ const senders = kind === 'audio' ? this.audioRtcRtpSenders : this.videoRtcRtpSenders;
+ const tracks = kind === 'audio' ? stream.getAudioTracks() : stream.getVideoTracks();
+ if (senders) {
+ const promises: Promise<void>[] = [];
+ for (let i = 0; i < senders.length; i++) {
+ // TODO: There is a bug where calling multiple times `addTrack` when changing an input device doesn't work.
+ // Calling `addTrack` doesn't trigger the `track` event listener for the other user.
+ // This workaround makes it possible to replace a track, but it could be improved by figuring out the
+ // proper way of changing a track.
+ promises.push(
+ senders[i].replaceTrack(tracks[i]).catch((e) => {
+ console.error('Error replacing track:', e);
+ })
+ );
+ }
+ return Promise.all(promises);
+ }
+
+ // TODO: Currently, we do not support adding new devices. To enable this feature, we would need to implement
+ // the "Perfect negotiation" pattern to renegotiate after `addTrack`.
+ // https://blog.mozilla.org/webrtc/perfect-negotiation-in-webrtc/
+ const newSenders = tracks.map((track) => this.connection.addTrack(track, stream));
+ if (kind === 'audio') {
+ this.audioRtcRtpSenders = newSenders;
+ } else {
+ this.videoRtcRtpSenders = newSenders;
+ }
+ };
+
+ if (localStream) {
+ updateTracks(localStream, 'audio');
+ updateTracks(localStream, 'video');
+ }
+
+ if (screenShareLocalStream) {
+ updateTracks(screenShareLocalStream, 'video');
+ }
+ }
+
+ disconnect() {
+ this.connection?.close();
+ this.cleaningFunctions.forEach((func) => func());
+ this.cleaningFunctions = [];
+ this.emitChange();
+ }
+
+ private getIceServers(account: Account) {
+ const iceServers: RTCIceServer[] = [];
+
+ if (account.details['TURN.enable'] === 'true') {
+ iceServers.push({
+ urls: 'turn:' + account.details['TURN.server'],
+ username: account.details['TURN.username'],
+ credential: account.details['TURN.password'],
+ });
+ }
+
+ if (account.details['STUN.enable'] === 'true') {
+ iceServers.push({
+ urls: 'stun:' + account.details['STUN.server'],
+ });
+ }
+
+ return iceServers;
+ }
+
+ private startNegociation(webSocket: IWebSocketContext, contactUri: string, conversationId: string) {
+ this.sendWebRtcOffer(webSocket, contactUri, conversationId);
+ }
+
+ private async sendWebRtcOffer(webSocket: IWebSocketContext, contactUri: string, conversationId: string) {
+ const sdp = await this.connection.createOffer({
+ offerToReceiveAudio: true,
+ offerToReceiveVideo: true,
+ });
+
+ const webRtcOffer: WebRtcSdp = {
+ contactId: contactUri,
+ conversationId: conversationId,
+ sdp,
+ };
+
+ await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
+ console.info('Sending WebRtcOffer', webRtcOffer);
+ webSocket.send(WebSocketMessageType.WebRtcOffer, webRtcOffer);
+ }
+
+ private setWebSocketListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
+ const sendWebRtcAnswer = async () => {
+ const sdp = await this.connection.createAnswer({
+ offerToReceiveAudio: true,
+ offerToReceiveVideo: true,
+ });
+
+ const webRtcAnswer: WebRtcSdp = {
+ contactId: contactUri,
+ conversationId: conversationId,
+ sdp,
+ };
+
+ await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
+ console.info('Sending WebRtcAnswer', webRtcAnswer);
+ webSocket.send(WebSocketMessageType.WebRtcAnswer, webRtcAnswer);
+ };
+
+ const addQueuedIceCandidates = async () => {
+ console.info('WebRTC remote description has been set. Ready to receive ICE candidates');
+ this.isReadyForIceCandidates = true;
+ if (this.iceCandidateQueue.length !== 0) {
+ console.warn(
+ 'Found queued ICE candidates that were added before `setRemoteDescription` was called. ' +
+ 'Adding queued ICE candidates...',
+ this.iceCandidateQueue
+ );
+
+ await Promise.all(this.iceCandidateQueue.map((iceCandidate) => this.connection.addIceCandidate(iceCandidate)));
+ }
+ };
+
+ const webRtcOfferListener = async (data: WebRtcSdp) => {
+ console.debug('receive webrtcoffer');
+ console.info('Received event on WebRtcOffer', data);
+ if (data.conversationId !== conversationId) {
+ console.warn('Wrong incoming conversationId, ignoring action');
+ return;
+ }
+
+ await this.connection.setRemoteDescription(new RTCSessionDescription(data.sdp));
+ await sendWebRtcAnswer();
+ await addQueuedIceCandidates();
+ };
+
+ const webRtcAnswerListener = async (data: WebRtcSdp) => {
+ console.info('Received event on WebRtcAnswer', data);
+ if (data.conversationId !== conversationId) {
+ console.warn('Wrong incoming conversationId, ignoring action');
+ return;
+ }
+
+ await this.connection.setRemoteDescription(new RTCSessionDescription(data.sdp));
+ await addQueuedIceCandidates();
+ };
+
+ const webRtcIceCandidateListener = async (data: WebRtcIceCandidate) => {
+ if (data.conversationId !== conversationId) {
+ console.warn('Wrong incoming conversationId, ignoring action');
+ return;
+ }
+
+ if (!data.candidate) {
+ return;
+ }
+
+ if (this.isReadyForIceCandidates) {
+ await this.connection.addIceCandidate(data.candidate);
+ } else {
+ this.iceCandidateQueue.push(data.candidate);
+ }
+ };
+
+ webSocket.bind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
+ webSocket.bind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
+ webSocket.bind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
+
+ this.cleaningFunctions.push(() => {
+ webSocket.unbind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
+ webSocket.unbind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
+ webSocket.unbind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
+ });
+ }
+
+ private setConnectionListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
+ this.connection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
+ if (event.candidate) {
+ const webRtcIceCandidate: WebRtcIceCandidate = {
+ contactId: contactUri,
+ conversationId: conversationId,
+ candidate: event.candidate,
+ };
+
+ // Send ice candidates as soon as they're found. This is called "trickle ice"
+ webSocket.send(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidate);
+ }
+ };
+
+ this.connection.ontrack = (event: RTCTrackEvent) => {
+ console.info('Received WebRTC event on track', event);
+ this.remoteStreams = event.streams;
+ this.emitChange();
+ };
+
+ this.connection.oniceconnectionstatechange = (event: Event) => {
+ console.info(`Received WebRTC event on iceconnectionstatechange: ${this.connection.iceConnectionState}`, event);
+ this.iceConnectionState = this.connection.iceConnectionState;
+ this.emitChange();
+ };
+ }
+
+ private emitChange() {
+ this.listener();
+ }
+}
diff --git a/client/src/webrtc/WebRtcManager.ts b/client/src/webrtc/WebRtcManager.ts
new file mode 100644
index 0000000..afae8a0
--- /dev/null
+++ b/client/src/webrtc/WebRtcManager.ts
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { useMemo, useRef, useSyncExternalStore } from 'react';
+
+import { CallData } from '../contexts/CallManagerProvider';
+import { IWebSocketContext } from '../contexts/WebSocketProvider';
+import { Account } from '../models/account';
+import { Listener } from '../utils/utils';
+import { RTCPeerConnectionHandler, RTCPeerConnectionInfos } from './RtcPeerConnectionHandler';
+
+export const useWebRtcManager = () => {
+ const webRtcManagerRef = useRef(new WebRtcManager());
+ const connectionsInfos = useSyncExternalStore(
+ webRtcManagerRef.current.subscribe.bind(webRtcManagerRef.current),
+ webRtcManagerRef.current.getSnapshot.bind(webRtcManagerRef.current)
+ );
+
+ return useMemo(
+ () => ({
+ addConnection: webRtcManagerRef.current.addConnection.bind(webRtcManagerRef.current),
+ removeConnection: webRtcManagerRef.current.removeConnection.bind(webRtcManagerRef.current),
+ updateLocalStreams: webRtcManagerRef.current.updateLocalStreams.bind(webRtcManagerRef.current),
+ clean: webRtcManagerRef.current.clean.bind(webRtcManagerRef.current),
+ connectionsInfos: connectionsInfos,
+ }),
+ [connectionsInfos]
+ );
+};
+
+class WebRtcManager {
+ private connections: Record<string, RTCPeerConnectionHandler> = {}; // key is contactUri
+
+ private listeners: Listener[] = [];
+ private snapshot: Record<string, RTCPeerConnectionInfos> = {}; // key is contactUri
+
+ addConnection(
+ webSocket: IWebSocketContext,
+ account: Account,
+ contactUri: string,
+ callData: CallData,
+ localStream: MediaStream | undefined,
+ screenShareLocalStream: MediaStream | undefined
+ ) {
+ if (this.connections[contactUri]) {
+ console.debug('Attempted to establish an WebRTC connection with the same peer more than once');
+ return;
+ }
+
+ const connection = new RTCPeerConnectionHandler(
+ webSocket,
+ account,
+ contactUri,
+ callData,
+ localStream,
+ screenShareLocalStream,
+ this.emitChange.bind(this)
+ );
+ this.connections[contactUri] = connection;
+ }
+
+ removeConnection(contactUri: string) {
+ const connection = this.connections[contactUri];
+ connection.disconnect();
+ delete this.connections[contactUri];
+ }
+
+ updateLocalStreams(localStream: MediaStream | undefined, screenShareLocalStream: MediaStream | undefined) {
+ Object.values(this.connections).forEach((connection) =>
+ connection.updateLocalStreams(localStream, screenShareLocalStream)
+ );
+ }
+
+ subscribe(listener: Listener) {
+ this.listeners.push(listener);
+ return () => {
+ this.listeners.filter((otherListener) => otherListener !== listener);
+ };
+ }
+
+ getSnapshot(): Record<string, RTCPeerConnectionInfos> {
+ return this.snapshot;
+ }
+
+ emitChange() {
+ this.snapshot = Object.entries(this.connections).reduce((acc, [contactUri, connection]) => {
+ acc[contactUri] = connection.getInfos();
+ return acc;
+ }, {} as Record<string, RTCPeerConnectionInfos>);
+
+ this.listeners.forEach((listener) => listener());
+ }
+
+ clean() {
+ Object.values(this.connections).forEach((connection) => connection.disconnect());
+ }
+}