/* * (C) Copyright 2017-2018 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 uuid = require('uuid'); import platform = require('platform'); export interface WebRtcPeerConfiguration { mediaConstraints: { audio: boolean, video: boolean }; simulcast: boolean; onicecandidate: (event) => void; iceServers: RTCIceServer[] | undefined; mediaStream?: MediaStream; mode?: string; // sendonly, reconly, sendrecv id?: string; } export class WebRtcPeer { pc: RTCPeerConnection; id: string; private candidategatheringdone = false; private candidatesQueue: RTCIceCandidate[] = []; constructor(private configuration: WebRtcPeerConfiguration) { 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 : uuid.v4(); this.pc.onicecandidate = event => { const candidate = event.candidate; if (candidate) { this.candidategatheringdone = false; this.configuration.onicecandidate(event.candidate); } else if (!this.candidategatheringdone) { this.candidategatheringdone = true; } }; this.pc.onsignalingstatechange = () => { if (this.pc.signalingState === 'stable') { while (this.candidatesQueue.length > 0) { this.pc.addIceCandidate(this.candidatesQueue.shift()); } } }; 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) { this.pc.addStream(this.configuration.mediaStream); } // [Hack] https://code.google.com/p/chromium/issues/detail?id=443558 if (this.configuration.mode === 'sendonly' && (platform.name === 'Chrome' && platform.version!.toString().substring(0, 2) === '39')) { this.configuration.mode = 'sendrecv'; } resolve(); }); } /** * This method frees the resources used by WebRtcPeer */ dispose() { console.debug('Disposing WebRtcPeer'); try { if (this.pc) { if (this.pc.signalingState === 'closed') { return; } this.pc.getLocalStreams().forEach(str => { this.streamStop(str); }); // FIXME This is not yet implemented in firefox // if(videoStream) pc.removeStream(videoStream); // if(audioStream) pc.removeStream(audioStream); this.pc.close(); } } catch (err) { console.warn('Exception disposing webrtc peer ' + err); } } /** * 1) Function that creates an offer, sets it as local description and returns the offer param * to send to OpenVidu Server (will be the remote description of other peer) */ generateOffer(): 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; } const constraints = { offerToReceiveAudio: + (this.configuration.mode !== 'sendonly' && offerAudio), offerToReceiveVideo: + (this.configuration.mode !== 'sendonly' && offerVideo) }; console.debug('RTCPeerConnection constraints: ' + JSON.stringify(constraints)); this.pc.createOffer(constraints).then(offer => { console.debug('Created SDP offer'); offer = this.mangleSdpToAddSimulcast(offer); return this.pc.setLocalDescription(offer); }).then(() => { const localDescription = this.pc.localDescription; if (!!localDescription) { console.debug('Local description set', localDescription.sdp); resolve(localDescription.sdp); } else { reject('Local description is not defined'); } }).catch(error => reject(error)); }); } /** * 2) Function to invoke when a SDP offer is received. Sets it as remote description, * generates and answer and returns it to send it to OpenVidu Server */ processOffer(sdpOffer: string): Promise { return new Promise((resolve, reject) => { const offer: RTCSessionDescriptionInit = { type: 'offer', sdp: sdpOffer }; console.debug('SDP offer received, setting remote description'); if (this.pc.signalingState === 'closed') { reject('PeerConnection is closed'); } this.pc.setRemoteDescription(offer) .then(() => { return this.pc.createAnswer(); }).then(answer => { answer = this.mangleSdpToAddSimulcast(answer); console.debug('Created SDP answer'); return this.pc.setLocalDescription(answer); }).then(() => { const localDescription = this.pc.localDescription; if (!!localDescription) { console.debug('Local description set', localDescription.sdp); resolve(localDescription.sdp); } else { reject('Local description is not defined'); } }).catch(error => reject(error)); }); } /** * 3) Function invoked when a SDP answer is received. Final step in SDP negotiation, the peer * just needs to set the answer as its remote description */ processAnswer(sdpAnswer: string): Promise { return new Promise((resolve, reject) => { const answer: RTCSessionDescriptionInit = { type: 'answer', sdp: sdpAnswer }; console.debug('SDP answer received, setting remote description'); if (this.pc.signalingState === 'closed') { reject('RTCPeerConnection is closed'); } this.pc.setRemoteDescription(answer).then(() => resolve()).catch(error => reject(error)); }); } /** * Callback function invoked when an ICE candidate is received */ addIceCandidate(iceCandidate: RTCIceCandidate): Promise { return new Promise((resolve, reject) => { console.debug('Remote ICE candidate received', 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)); } break; default: this.candidatesQueue.push(iceCandidate); resolve(); } }); } private streamStop(stream: MediaStream): void { stream.getTracks().forEach(track => { track.stop(); stream.removeTrack(track); }); } /* Simulcast utilities */ private mangleSdpToAddSimulcast(answer) { if (this.configuration.simulcast && !!this.configuration.mediaStream) { if (platform.name === 'Chrome' || platform.name === 'Chrome Mobile') { console.debug('Adding multicast info'); answer = new RTCSessionDescription({ type: answer.type, sdp: this.removeFIDFromOffer(answer.sdp) + this.getSimulcastInfo(this.configuration.mediaStream) }); } else { console.warn('Simulcast is only available in Chrome browser'); } } return answer; } private removeFIDFromOffer(sdp) { const n = sdp.indexOf('a=ssrc-group:FID'); if (n > 0) { return sdp.slice(0, n); } else { return sdp; } } private getSimulcastInfo(videoStream: MediaStream) { const videoTracks = videoStream.getVideoTracks(); if (!videoTracks.length) { console.warn('No video tracks available in the video stream'); return ''; } const lines = [ 'a=x-google-flag:conference', 'a=ssrc-group:SIM 1 2 3', 'a=ssrc:1 cname:localVideo', 'a=ssrc:1 msid:' + videoStream.id + ' ' + videoTracks[0].id, 'a=ssrc:1 mslabel:' + videoStream.id, 'a=ssrc:1 label:' + videoTracks[0].id, 'a=ssrc:2 cname:localVideo', 'a=ssrc:2 msid:' + videoStream.id + ' ' + videoTracks[0].id, 'a=ssrc:2 mslabel:' + videoStream.id, 'a=ssrc:2 label:' + videoTracks[0].id, 'a=ssrc:3 cname:localVideo', 'a=ssrc:3 msid:' + videoStream.id + ' ' + videoTracks[0].id, 'a=ssrc:3 mslabel:' + videoStream.id, 'a=ssrc:3 label:' + videoTracks[0].id ]; lines.push(''); return lines.join('\n'); } } 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); } }