import { EventEmitter2 } from 'eventemitter2'; import { stopStream, stopTrack, silentConsole, } from '/imports/ui/services/webrtc-base/utils'; export default class WebRtcPeer extends EventEmitter2 { constructor(mode, options = {}) { super({ newListener: true }); this.mode = mode; this.options = options; this.peerConnection = this.options.peerConnection; this.videoStream = this.options.videoStream; this.audioStream = this.options.audioStream; this.mediaConstraints = this.options.mediaConstraints; this.trace = this.options.trace; this.configuration = this.options.configuration; this.onicecandidate = this.options.onicecandidate; this.oncandidategatheringdone = this.options.oncandidategatheringdone; this.candidateGatheringDone = false; this._outboundCandidateQueue = []; this._inboundCandidateQueue = []; this._handleIceCandidate = this._handleIceCandidate.bind(this); this._handleSignalingStateChange = this._handleSignalingStateChange.bind(this); if (this.onicecandidate) this.on('icecandidate', this.onicecandidate); if (this.oncandidategatheringdone) this.on('candidategatheringdone', this.oncandidategatheringdone); } _flushInboundCandidateQueue() { while (this._inboundCandidateQueue.length) { const entry = this._inboundCandidateQueue.shift(); if (entry.candidate && entry.promise) { try { if (this.isPeerConnectionClosed()) { entry.promise.resolve(); } else { this.peerConnection.addIceCandidate(entry.candidate) .then(entry.promise.resolve) .catch(entry.promise.reject); } } catch (error) { entry.promise.reject(error); } } } } _trackQueueFlushEvents() { this.on('newListener', (event) => { if (event === 'icecandidate' || event === 'candidategatheringdone') { while (this._outboundCandidateQueue.length) { const candidate = this._outboundCandidateQueue.shift(); if (!candidate) this._emitCandidateGatheringDone(); } } }); this.peerConnection?.addEventListener('signalingstatechange', this._handleSignalingStateChange); } _emitCandidateGatheringDone() { if (!this.candidateGatheringDone) { this.emit('candidategatheringdone'); this.candidateGatheringDone = true; } } _handleIceCandidate({ candidate }) { if (this.hasListeners('icecandidate') || this.hasListeners('candidategatheringdone')) { if (candidate) { this.emit('icecandidate', candidate); this.candidateGatheringDone = false; } else this._emitCandidateGatheringDone(); } else if (!this.candidateGatheringDone) { this._outboundCandidateQueue.push(candidate); if (!candidate) this.candidateGatheringDone = true; } } _handleSignalingStateChange() { if (this.peerConnection?.signalingState === 'stable') { this._flushInboundCandidateQueue(); } } set peerConnection(pc) { this._pc = pc; } get peerConnection() { return this._pc; } get logger() { if (this.trace) return console; return silentConsole; } getLocalSessionDescriptor() { return this.peerConnection?.localDescription; } getRemoteSessionDescriptor() { return this.peerConnection?.remoteDescription; } getLocalStream() { if (this.localStream) { return this.localStream; } if (this.peerConnection) { this.localStream = new MediaStream(); const senders = this.peerConnection.getSenders(); senders.forEach(({ track }) => { if (track) { this.localStream.addTrack(track); } }); return this.localStream; } return null; } getRemoteStream() { if (this.remoteStream) { return this.remoteStream; } if (this.peerConnection) { this.remoteStream = new MediaStream(); this.peerConnection.getReceivers().forEach(({ track }) => { if (track) { this.remoteStream.addTrack(track); } }); return this.remoteStream; } return null; } isPeerConnectionClosed() { return !this.peerConnection || this.peerConnection.signalingState === 'closed'; } start() { // Init PeerConnection if (!this.peerConnection) { this.peerConnection = new RTCPeerConnection(this.configuration); } if (this.isPeerConnectionClosed()) { this.logger.trace('BBB::WebRtcPeer::start - peer connection closed'); throw new Error('Invalid peer state: closed'); } this.peerConnection.addEventListener('icecandidate', this._handleIceCandidate); this._trackQueueFlushEvents(); if (this.videoStream) { this.videoStream.getTracks().forEach((track) => { this.peerConnection.addTrack(track, this.videoStream); }); } if (this.audioStream) { this.audioStream.getTracks().forEach((track) => { this.peerConnection.addTrack(track, this.audioStream); }); } } addIceCandidate(iceCandidate) { const candidate = new RTCIceCandidate(iceCandidate); switch (this.peerConnection?.signalingState) { case 'closed': this.logger.trace('BBB::WebRtcPeer::addIceCandidate - peer connection closed'); throw new Error('PeerConnection object is closed'); case 'stable': { if (this.peerConnection.remoteDescription) { this.logger.debug('BBB::WebRtcPeer::addIceCandidate - adding candidate', candidate); return this.peerConnection.addIceCandidate(candidate); } } // eslint-ignore-next-line no-fallthrough default: { this.logger.debug('BBB::WebRtcPeer::addIceCandidate - buffering inbound candidate', candidate); const promise = new Promise(); this._inboundCandidateQueue.push({ candidate, promise, }); return promise; } } } generateOffer() { switch (this.mode) { case 'recvonly': { const useAudio = this.mediaConstraints && ((typeof this.mediaConstraints.audio === 'boolean') || (typeof this.mediaConstraints.audio === 'object')); const useVideo = this.mediaConstraints && ((typeof this.mediaConstraints.video === 'boolean') || (typeof this.mediaConstraints.video === 'object')); if (useAudio) { this.peerConnection.addTransceiver('audio', { direction: 'recvonly', }); } if (useVideo) { this.peerConnection.addTransceiver('video', { direction: 'recvonly', }); } break; } case 'sendonly': { this.peerConnection.getTransceivers().forEach((transceiver) => { // eslint-disable-next-line no-param-reassign transceiver.direction = 'sendonly'; }); break; } default: break; } return this.peerConnection.createOffer() .then((offer) => { this.logger.debug('BBB::WebRtcPeer::generateOffer - created offer', offer); return this.peerConnection.setLocalDescription(offer); }) .then(() => { const localDescription = this.getLocalSessionDescriptor(); this.logger.debug('BBB::WebRtcPeer::generateOffer - local description set', localDescription); return localDescription.sdp; }); } processAnswer(sdp) { const answer = new RTCSessionDescription({ type: 'answer', sdp, }); if (this.isPeerConnectionClosed()) { this.logger.error('BBB::WebRtcPeer::processAnswer - peer connection closed'); throw new Error('Peer connection is closed'); } this.logger.debug('BBB::WebRtcPeer::processAnswer - setting remote description'); return this.peerConnection.setRemoteDescription(answer); } processOffer(sdp) { const offer = new RTCSessionDescription({ type: 'offer', sdp, }); if (this.isPeerConnectionClosed()) { this.logger.error('BBB::WebRtcPeer::processOffer - peer connection closed'); throw new Error('Peer connection is closed'); } this.logger.debug('BBB::WebRtcPeer::processOffer - setting remote description', offer); return this.peerConnection.setRemoteDescription(offer) .then(() => this.peerConnection.createAnswer()) .then((answer) => { this.logger.debug('BBB::WebRtcPeer::processOffer - created answer', answer); return this.peerConnection.setLocalDescription(answer); }) .then(() => { const localDescription = this.getLocalSessionDescriptor(); this.logger.debug('BBB::WebRtcPeer::processOffer - local description set', localDescription.sdp); return localDescription.sdp; }); } dispose() { this.logger.debug('BBB::WebRtcPeer::dispose'); try { if (this.peerConnection) { this.peerConnection.getSenders().forEach(({ track }) => stopTrack(track)); if (!this.isPeerConnectionClosed()) this.peerConnection.close(); this.peerConnection = null; } if (this.localStream) { stopStream(this.localStream); this.localStream = null; } if (this.remoteStream) { stopStream(this.remoteStream); this.remoteStream = null; } this._outboundCandidateQueue = []; this.candidateGatheringDone = false; } catch (error) { this.logger.trace('BBB::WebRtcPeer::dispose - failed', error); } this.removeAllListeners(); } }