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