/* * (C) Copyright 2017-2020 OpenVidu (https://openvidu.io) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import freeice = require('freeice'); import { v4 as uuidv4 } from 'uuid'; import { ExceptionEventName } from '../Events/ExceptionEvent'; import { OpenViduLogger } from '../Logger/OpenViduLogger'; import { PlatformUtils } from '../Utils/Platform'; /** * @hidden */ const logger: OpenViduLogger = OpenViduLogger.getInstance(); /** * @hidden */ let platform: PlatformUtils; export interface WebRtcPeerConfiguration { mediaConstraints: { audio: boolean, video: boolean }; simulcast: boolean; onicecandidate: (event: RTCIceCandidate) => void; onexception: (exceptionName: ExceptionEventName, message: string, data?: any) => void; iceServers: RTCIceServer[] | undefined; mediaStream?: MediaStream; mode?: 'sendonly' | 'recvonly' | 'sendrecv'; id?: string; } export class WebRtcPeer { pc: RTCPeerConnection; id: string; remoteCandidatesQueue: RTCIceCandidate[] = []; localCandidatesQueue: RTCIceCandidate[] = []; iceCandidateList: RTCIceCandidate[] = []; constructor(protected configuration: WebRtcPeerConfiguration) { platform = PlatformUtils.getInstance(); this.configuration.iceServers = (!!this.configuration.iceServers && this.configuration.iceServers.length > 0) ? this.configuration.iceServers : freeice(); this.pc = new RTCPeerConnection({ iceServers: this.configuration.iceServers }); this.id = !!configuration.id ? configuration.id : this.generateUniqueId(); this.pc.addEventListener('icecandidate', (event: RTCPeerConnectionIceEvent) => { if (event.candidate != null) { const candidate: RTCIceCandidate = event.candidate; this.configuration.onicecandidate(candidate); if (candidate.candidate !== '') { this.localCandidatesQueue.push({ candidate: candidate.candidate }); } } }); this.pc.addEventListener('signalingstatechange', () => { if (this.pc.signalingState === 'stable') { while (this.iceCandidateList.length > 0) { let candidate = this.iceCandidateList.shift(); this.pc.addIceCandidate(candidate); } } }); this.start(); } /** * This function creates the RTCPeerConnection object taking into account the * properties received in the constructor. It starts the SDP negotiation * process: generates the SDP offer and invokes the onsdpoffer callback. This * callback is expected to send the SDP offer, in order to obtain an SDP * answer from another peer. */ start(): Promise { return new Promise((resolve, reject) => { if (this.pc.signalingState === 'closed') { reject('The peer connection object is in "closed" state. This is most likely due to an invocation of the dispose method before accepting in the dialogue'); } if (!!this.configuration.mediaStream) { for (const track of this.configuration.mediaStream.getTracks()) { this.pc.addTrack(track, this.configuration.mediaStream); } resolve(); } }); } /** * This method frees the resources used by WebRtcPeer */ dispose() { logger.debug('Disposing WebRtcPeer'); if (this.pc) { if (this.pc.signalingState === 'closed') { return; } this.pc.close(); this.remoteCandidatesQueue = []; this.localCandidatesQueue = []; } } /** * Creates an SDP offer from the local RTCPeerConnection to send to the other peer * Only if the negotiation was initiated by the this peer */ createOffer(): Promise { return new Promise((resolve, reject) => { let offerAudio, offerVideo = true; // Constraints must have both blocks if (!!this.configuration.mediaConstraints) { offerAudio = (typeof this.configuration.mediaConstraints.audio === 'boolean') ? this.configuration.mediaConstraints.audio : true; offerVideo = (typeof this.configuration.mediaConstraints.video === 'boolean') ? this.configuration.mediaConstraints.video : true; } if (typeof this.pc['addTransceiver'] === 'function') { // SDP "Unified Plan" supported logger.debug('Unified Plan supported'); if (offerAudio) { this.pc.addTransceiver('audio', { direction: this.configuration.mode, }); } if (offerVideo) { this.pc.addTransceiver('video', { direction: this.configuration.mode, }); } this.pc.createOffer() .then(offer => { logger.debug('Created SDP offer'); resolve(offer); }) .catch(error => reject(error)); } else { // SDP legacy "Plan B" support const constraints: RTCOfferOptions = { offerToReceiveAudio: (this.configuration.mode !== 'sendonly' && offerAudio), offerToReceiveVideo: (this.configuration.mode !== 'sendonly' && offerVideo) }; logger.debug('Unified Plan not supported. Using Plan B. RTCPeerConnection constraints: ' + JSON.stringify(constraints)); this.pc.createOffer(constraints) .then(offer => { logger.debug('Created SDP offer'); resolve(offer); }) .catch(error => reject(error)); } }); } /** * Creates an SDP answer from the local RTCPeerConnection to send to the other peer * Only if the negotiation was initiated by the other peer */ createAnswer(): Promise { return new Promise((resolve, reject) => { let offerAudio, offerVideo = true; if (!!this.configuration.mediaConstraints) { offerAudio = (typeof this.configuration.mediaConstraints.audio === 'boolean') ? this.configuration.mediaConstraints.audio : true; offerVideo = (typeof this.configuration.mediaConstraints.video === 'boolean') ? this.configuration.mediaConstraints.video : true; } const constraints: RTCOfferOptions = { offerToReceiveAudio: offerAudio, offerToReceiveVideo: offerVideo }; this.pc.createAnswer(constraints).then(sdpAnswer => { resolve(sdpAnswer); }).catch(error => { reject(error); }); }); } /** * This peer initiated negotiation. Step 1/4 of SDP offer-answer protocol */ processLocalOffer(offer: RTCSessionDescriptionInit): Promise { return new Promise((resolve, reject) => { this.pc.setLocalDescription(offer) .then(() => { const localDescription = this.pc.localDescription; if (!!localDescription) { logger.debug('Local description set', localDescription.sdp); resolve(); } else { reject('Local description is not defined'); } }) .catch(error => { reject(error); }); }); } /** * Other peer initiated negotiation. Step 2/4 of SDP offer-answer protocol */ processRemoteOffer(sdpOffer: string): Promise { return new Promise((resolve, reject) => { const offer: RTCSessionDescriptionInit = { type: 'offer', sdp: sdpOffer }; logger.debug('SDP offer received, setting remote description', offer); if (this.pc.signalingState === 'closed') { reject('RTCPeerConnection is closed when trying to set remote description'); } this.setRemoteDescription(offer) .then(() => { resolve(); }) .catch(error => { reject(error); }); }); } /** * Other peer initiated negotiation. Step 3/4 of SDP offer-answer protocol */ processLocalAnswer(answer: RTCSessionDescriptionInit): Promise { return new Promise((resolve, reject) => { logger.debug('SDP answer created, setting local description'); if (this.pc.signalingState === 'closed') { reject('RTCPeerConnection is closed when trying to set local description'); } this.pc.setLocalDescription(answer) .then(() => resolve()) .catch(error => reject(error)); }); } /** * This peer initiated negotiation. Step 4/4 of SDP offer-answer protocol */ processRemoteAnswer(sdpAnswer: string): Promise { return new Promise((resolve, reject) => { const answer: RTCSessionDescriptionInit = { type: 'answer', sdp: sdpAnswer }; logger.debug('SDP answer received, setting remote description'); if (this.pc.signalingState === 'closed') { reject('RTCPeerConnection is closed when trying to set remote description'); } this.setRemoteDescription(answer) .then(() => resolve()) .catch(error => reject(error)); }); } /** * @hidden */ async setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise { return this.pc.setRemoteDescription(sdp); } /** * Callback function invoked when an ICE candidate is received */ addIceCandidate(iceCandidate: RTCIceCandidate): Promise { return new Promise((resolve, reject) => { logger.debug('Remote ICE candidate received', iceCandidate); this.remoteCandidatesQueue.push(iceCandidate); switch (this.pc.signalingState) { case 'closed': reject(new Error('PeerConnection object is closed')); break; case 'stable': if (!!this.pc.remoteDescription) { this.pc.addIceCandidate(iceCandidate).then(() => resolve()).catch(error => reject(error)); } else { this.iceCandidateList.push(iceCandidate); resolve(); } break; default: this.iceCandidateList.push(iceCandidate); resolve(); } }); } addIceConnectionStateChangeListener(otherId: string) { this.pc.addEventListener('iceconnectionstatechange', () => { const iceConnectionState: RTCIceConnectionState = this.pc.iceConnectionState; switch (iceConnectionState) { case 'disconnected': // Possible network disconnection logger.warn('IceConnectionState of RTCPeerConnection ' + this.id + ' (' + otherId + ') change to "disconnected". Possible network disconnection'); break; case 'failed': const msg = 'IceConnectionState of RTCPeerConnection ' + this.id + ' (' + otherId + ') to "failed"'; logger.error(msg); this.configuration.onexception(ExceptionEventName.ICE_CONNECTION_FAILED, msg); break; case 'closed': logger.log('IceConnectionState of RTCPeerConnection ' + this.id + ' (' + otherId + ') change to "closed"'); break; case 'new': logger.log('IceConnectionState of RTCPeerConnection ' + this.id + ' (' + otherId + ') change to "new"'); break; case 'checking': logger.log('IceConnectionState of RTCPeerConnection ' + this.id + ' (' + otherId + ') change to "checking"'); break; case 'connected': logger.log('IceConnectionState of RTCPeerConnection ' + this.id + ' (' + otherId + ') change to "connected"'); break; case 'completed': logger.log('IceConnectionState of RTCPeerConnection ' + this.id + ' (' + otherId + ') change to "completed"'); break; } }); } /** * @hidden */ generateUniqueId(): string { return uuidv4(); } } export class WebRtcPeerRecvonly extends WebRtcPeer { constructor(configuration: WebRtcPeerConfiguration) { configuration.mode = 'recvonly'; super(configuration); } } export class WebRtcPeerSendonly extends WebRtcPeer { constructor(configuration: WebRtcPeerConfiguration) { configuration.mode = 'sendonly'; super(configuration); } } export class WebRtcPeerSendrecv extends WebRtcPeer { constructor(configuration: WebRtcPeerConfiguration) { configuration.mode = 'sendrecv'; super(configuration); } }