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/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();
+ }
+}