diff --git a/app/lib/Client.js b/app/lib/Client.js index c3b60ca..eb1c369 100644 --- a/app/lib/Client.js +++ b/app/lib/Client.js @@ -5,8 +5,8 @@ import browser from 'bowser'; import sdpTransform from 'sdp-transform'; import Logger from './Logger'; import protooClient from 'protoo-client'; -import urlFactory from './urlFactory'; -import utils from './utils'; +import * as urlFactory from './urlFactory'; +import * as utils from './utils'; const logger = new Logger('Client'); diff --git a/app/lib/components/Room.jsx b/app/lib/components/Room.jsx index b2b5263..ab92894 100644 --- a/app/lib/components/Room.jsx +++ b/app/lib/components/Room.jsx @@ -2,12 +2,13 @@ import React from 'react'; import ClipboardButton from 'react-clipboard.js'; +import browser from 'bowser'; import TransitionAppear from './TransitionAppear'; import LocalVideo from './LocalVideo'; import RemoteVideo from './RemoteVideo'; import Stats from './Stats'; import Logger from '../Logger'; -import utils from '../utils'; +import * as utils from '../utils'; import Client from '../Client'; const logger = new Logger('Room'); @@ -285,8 +286,8 @@ export default class Room extends React.Component imageHeight : 80 }); - // Start retrieving WebRTC stats (unless mobile) - if (utils.isDesktop()) + // Start retrieving WebRTC stats (unless mobile or Edge). + if (utils.isDesktop() && !browser.msedge) { this.setState({ showStats: true }); @@ -475,10 +476,10 @@ export default class Room extends React.Component this.setState({ stats: null }); - this._statsTimer = setTimeout(() => - { - getStats.call(this); - }, STATS_INTERVAL); + // this._statsTimer = setTimeout(() => + // { + // getStats.call(this); + // }, STATS_INTERVAL); }); } } diff --git a/app/lib/edge/RTCPeerConnection.js b/app/lib/edge/RTCPeerConnection.js new file mode 100644 index 0000000..55e5338 --- /dev/null +++ b/app/lib/edge/RTCPeerConnection.js @@ -0,0 +1,2480 @@ +/* global __filename, RTCIceGatherer, RTCIceTransport, RTCDtlsTransport, +RTCRtpSender, RTCRtpReceiver */ + +import yaeti from 'yaeti'; +import Logger from '../Logger'; +import RTCSessionDescription from './RTCSessionDescription'; +import * as utils from '../utils'; +import * as ortcUtils from './ortcUtils'; +import { InvalidStateError } from './errors'; + +const logger = new Logger('edge/RTCPeerConnection'); + +const RTCSignalingState = { + stable: 'stable', + haveLocalOffer: 'have-local-offer', + haveRemoteOffer: 'have-remote-offer', + closed: 'closed' +}; + +const RTCIceGatheringState = { + new: 'new', + gathering: 'gathering', + complete: 'complete' +}; + +const CNAME = `jitsi-ortc-cname-${utils.randomNumber()}`; + +/** + * RTCPeerConnection shim for ORTC based endpoints (such as Edge). + * + * The interface is based on the W3C specification of 2015, which matches + * the implementation of Chrome nowadays: + * + * https://www.w3.org/TR/2015/WD-webrtc-20150210/ + * + * It also implements Plan-B for multi-stream, and assumes single BUNDLEd + * transport and rtcp-mux. + */ +export default class ortcRTCPeerConnection extends yaeti.EventTarget { + /** + */ + constructor(pcConfig) { + super(); + + // TODO: TMP + window.PC = this; + + logger.debug('constructor() pcConfig:', pcConfig); + + // Buffered local ICE candidates (in WebRTC format). + // @type {sequence} + this._bufferedIceCandidates = []; + + // Closed flag. + // @type {Boolean} + this._closed = false; + + // RTCDtlsTransport. + // @type {RTCDtlsTransport} + this._dtlsTransport = null; + + // RTCIceGatherer. + // @type {RTCIceGatherer} + this._iceGatherer = null; + + // RTCPeerConnection iceGatheringState. + // NOTE: This should not be needed, but Edge does not implement + // iceGatherer.state. + // @type {RTCIceGatheringState} + this._iceGatheringState = RTCIceGatheringState.new; + + // RTCIceTransport. + // @type {RTCIceTransport} + this._iceTransport = null; + + // Local RTP capabilities (filtered with remote ones). + // @type {RTCRtpCapabilities} + this._localCapabilities = null; + + // Local RTCSessionDescription. + // @type {RTCSessionDescription} + this._localDescription = null; + + // Map with info regarding local media. + // - index: MediaStreamTrack.id + // - value: Object + // - rtpSender: Associated RTCRtpSender instance + // - stream: Associated MediaStream instance + // - ssrc: Provisional or definitive SSRC + // - rtxSsrc: Provisional or definitive SSRC for RTX + // - sending: Boolean indicating whether rtpSender.send() was called. + this._localTrackInfos = new Map(); + + // Ordered Map with MID as key and kind as value. + // @type {map} + this._mids = new Map(); + + // Remote RTCSessionDescription. + // @type {RTCSessionDescription} + this._remoteDescription = null; + + // Map of remote streams. + // - index: MediaStream.jitsiRemoteId (as signaled in remote SDP) + // - value: MediaStream (locally generated so id does not match) + // @type {map} + this._remoteStreams = new Map(); + + // Map with info about receiving media. + // - index: Media SSRC + // - value: Object + // - kind: 'audio' / 'video' + // - ssrc: Media SSRC + // - rtxSsrc: RTX SSRC (may be unset) + // - streamId: MediaStream.jitsiRemoteId + // - trackId: MediaStreamTrack.jitsiRemoteId + // - cname: CNAME + // - stream: MediaStream + // - rtpReceiver: Associated RTCRtpReceiver instance + // @type {map} + this._remoteTrackInfos = new Map(); + + // Local SDP global fields. + this._sdpGlobalFields = { + id: utils.randomNumber(), + version: 0 + }; + + // RTCPeerConnection signalingState. + // @type {RTCSignalingState} + this._signalingState = RTCSignalingState.stable; + + // Create the RTCIceGatherer. + this._setIceGatherer(pcConfig); + + // Create the RTCIceTransport. + this._setIceTransport(this._iceGatherer); + + // Create the RTCDtlsTransport. + this._setDtlsTransport(this._iceTransport); + } + + /** + * Current ICE+DTLS connection state. + * @return {RTCPeerConnectionState} + */ + get connectionState() { + return this._dtlsTransport.state; + } + + /** + * Current ICE connection state. + * @return {RTCIceConnectionState} + */ + get iceConnectionState() { + return this._iceTransport.state; + } + + /** + * Current ICE gathering state. + * @return {RTCIceGatheringState} + */ + get iceGatheringState() { + return this._iceGatheringState; + } + + /** + * Gets the local description. + * @return {RTCSessionDescription} + */ + get localDescription() { + return this._localDescription; + } + + /** + * Gets the remote description. + * @return {RTCSessionDescription} + */ + get remoteDescription() { + return this._remoteDescription; + } + + /** + * Current signaling state. + * @return {RTCSignalingState} + */ + get signalingState() { + return this._signalingState; + } + + /** + * Adds a remote ICE candidate. Implements both the old callbacks based + * signature and the new Promise based style. + * + * Arguments in Promise mode: + * @param {RTCIceCandidate} candidate + * + * Arguments in callbacks mode: + * @param {RTCIceCandidate} candidate + * @param {function()} callback + * @param {function(error)} errback + */ + addIceCandidate(candidate, ...args) { + let usePromise; + let callback; + let errback; + + if (!candidate) { + throw new TypeError('candidate missing'); + } + + if (args.length === 0) { + usePromise = true; + } else { + usePromise = false; + callback = args[0]; + errback = args[1]; + + if (typeof callback !== 'function') { + throw new TypeError('callback missing'); + } + + if (typeof errback !== 'function') { + throw new TypeError('errback missing'); + } + } + + logger.debug('addIceCandidate() candidate:', candidate); + + if (usePromise) { + return this._addIceCandidate(candidate); + } + + this._addIceCandidate(candidate) + .then(() => callback()) + .catch(error => errback(error)); + } + + /** + * Adds a local MediaStream. + * @param {MediaStream} stream. + * NOTE: Deprecated API. + */ + addStream(stream) { + logger.debug('addStream()'); + + this._addStream(stream); + } + + /** + * Closes the RTCPeerConnection and all the underlying ORTC objects. + */ + close() { + if (this._closed) { + return; + } + + this._closed = true; + + logger.debug('close()'); + + this._updateAndEmitSignalingStateChange(RTCSignalingState.closed); + + // Close RTCIceGatherer. + // NOTE: Not yet implemented by Edge. + try { + this._iceGatherer.close(); + } catch (error) { + logger.warn(`iceGatherer.close() failed:${error}`); + } + + // Close RTCIceTransport. + try { + this._iceTransport.stop(); + } catch (error) { + logger.warn(`iceTransport.stop() failed:${error}`); + } + + // Close RTCDtlsTransport. + try { + this._dtlsTransport.stop(); + } catch (error) { + logger.warn(`dtlsTransport.stop() failed:${error}`); + } + + // Close and clear RTCRtpSenders. + for (const info of this._localTrackInfos.values()) { + const rtpSender = info.rtpSender; + + try { + rtpSender.stop(); + } catch (error) { + logger.warn(`rtpSender.stop() failed:${error}`); + } + } + + this._localTrackInfos.clear(); + + // Close and clear RTCRtpReceivers. + for (const info of this._remoteTrackInfos.values()) { + const rtpReceiver = info.rtpReceiver; + + try { + rtpReceiver.stop(); + } catch (error) { + logger.warn(`rtpReceiver.stop() failed:${error}`); + } + } + + this._remoteTrackInfos.clear(); + + // Clear remote streams. + this._remoteStreams.clear(); + } + + /** + * Creates a local answer. Implements both the old callbacks based signature + * and the new Promise based style. + * + * Arguments in Promise mode: + * @param {RTCOfferOptions} [options] + * + * Arguments in callbacks mode: + * @param {function(desc)} callback + * @param {function(error)} errback + * @param {MediaConstraints} [constraints] + */ + createAnswer(...args) { + let usePromise; + let options; + let callback; + let errback; + + if (args.length <= 1) { + usePromise = true; + options = args[0]; + } else { + usePromise = false; + callback = args[0]; + errback = args[1]; + options = args[2]; + + if (typeof callback !== 'function') { + throw new TypeError('callback missing'); + } + + if (typeof errback !== 'function') { + throw new TypeError('errback missing'); + } + } + + logger.debug('createAnswer() options:', options); + + if (usePromise) { + return this._createAnswer(options); + } + + this._createAnswer(options) + .then(desc => callback(desc)) + .catch(error => errback(error)); + } + + /** + * Creates a RTCDataChannel. + */ + createDataChannel() { + logger.debug('createDataChannel()'); + + // NOTE: DataChannels not implemented in Edge. + throw new Error('createDataChannel() not supported in Edge'); + } + + /** + * Creates a local offer. Implements both the old callbacks based signature + * and the new Promise based style. + * + * Arguments in Promise mode: + * @param {RTCOfferOptions} [options] + * + * Arguments in callbacks mode: + * @param {function(desc)} callback + * @param {function(error)} errback + * @param {MediaConstraints} [constraints] + */ + createOffer(...args) { + let usePromise; + let options; + let callback; + let errback; + + if (args.length <= 1) { + usePromise = true; + options = args[0]; + } else { + usePromise = false; + callback = args[0]; + errback = args[1]; + options = args[2]; + + if (typeof callback !== 'function') { + throw new TypeError('callback missing'); + } + + if (typeof errback !== 'function') { + throw new TypeError('errback missing'); + } + } + + logger.debug('createOffer() options:', options); + + if (usePromise) { + return this._createOffer(options); + } + + this._createOffer(options) + .then(desc => callback(desc)) + .catch(error => errback(error)); + } + + /** + * Gets a sequence of local MediaStreams. + * @return {sequence} + */ + getLocalStreams() { + return Array.from(this._localTrackInfos.values()) + .map(info => info.stream) + .filter((elem, pos, arr) => arr.indexOf(elem) === pos); + } + + /** + * Gets a sequence of remote MediaStreams. + * @return {sequence} + */ + getRemoteStreams() { + return Array.from(this._remoteStreams.values()); + } + + /** + * Get RTP statistics. Implements both the old callbacks based signature + * and the new Promise based style. + * + * Arguments in Promise mode: + * @param {MediaStreamTrack} [selector] + * + * Arguments in callbacks mode: + * @param {MediaStreamTrack} [selector] + * @param {function(desc)} callback + * @param {function(error)} errback + */ + getStats(...args) { + let usePromise; + let selector; + let callback; + let errback; + + if (args.length <= 1) { + usePromise = true; + selector = args[0]; + } else { + usePromise = false; + + if (args.length === 2) { + callback = args[0]; + errback = args[1]; + } else { + selector = args[0]; + callback = args[1]; + errback = args[2]; + } + + if (typeof callback !== 'function') { + throw new TypeError('callback missing'); + } + + if (typeof errback !== 'function') { + throw new TypeError('errback missing'); + } + } + + logger.debug('getStats()'); + + if (usePromise) { + return this._getStats(selector); + } + + this._getStats(selector) + .then(stats => callback(stats)) + .catch(error => errback(error)); + } + + /** + * Removes a local MediaStream. + * @param {MediaStream} stream. + * NOTE: Deprecated API. + */ + removeStream(stream) { + logger.debug('removeStream()'); + + this._removeStream(stream); + } + + /** + * Applies a local description. Implements both the old callbacks based + * signature and the new Promise based style. + * + * Arguments in Promise mode: + * @param {RTCSessionDescriptionInit} desc + * + * Arguments in callbacks mode: + * @param {RTCSessionDescription} desc + * @param {function()} callback + * @param {function(error)} errback + */ + setLocalDescription(desc, ...args) { + let usePromise; + let callback; + let errback; + + if (!desc) { + throw new TypeError('description missing'); + } + + if (args.length === 0) { + usePromise = true; + } else { + usePromise = false; + callback = args[0]; + errback = args[1]; + + if (typeof callback !== 'function') { + throw new TypeError('callback missing'); + } + + if (typeof errback !== 'function') { + throw new TypeError('errback missing'); + } + } + + logger.debug('setLocalDescription() desc:', desc); + + if (usePromise) { + return this._setLocalDescription(desc); + } + + this._setLocalDescription(desc) + .then(() => callback()) + .catch(error => errback(error)); + } + + /** + * Applies a remote description. Implements both the old callbacks based + * signature and the new Promise based style. + * + * Arguments in Promise mode: + * @param {RTCSessionDescriptionInit} desc + * + * Arguments in callbacks mode: + * @param {RTCSessionDescription} desc + * @param {function()} callback + * @param {function(error)} errback + */ + setRemoteDescription(desc, ...args) { + let usePromise; + let callback; + let errback; + + if (!desc) { + throw new TypeError('description missing'); + } + + if (args.length === 0) { + usePromise = true; + } else { + usePromise = false; + callback = args[0]; + errback = args[1]; + + if (typeof callback !== 'function') { + throw new TypeError('callback missing'); + } + + if (typeof errback !== 'function') { + throw new TypeError('errback missing'); + } + } + + logger.debug('setRemoteDescription() desc:', desc); + + if (usePromise) { + return this._setRemoteDescription(desc); + } + + this._setRemoteDescription(desc) + .then(() => callback()) + .catch(error => errback(error)); + } + + /** + * Promise based implementation for addIceCandidate(). + * @return {Promise} + * @private + */ + _addIceCandidate(candidate) { // eslint-disable-line no-unused-vars + if (this._closed) { + return Promise.reject( + new InvalidStateError('RTCPeerConnection closed')); + } + + // NOTE: Edge does not support Trickle-ICE so just candidates in the + // remote SDP are applied. Candidates given later would be just + // ignored, so notify the called about that. + return Promise.reject(new Error('addIceCandidate() not supported')); + } + + /** + * Implementation for addStream(). + * @private + */ + _addStream(stream) { + if (this._closed) { + throw new InvalidStateError('RTCPeerConnection closed'); + } + + // Create a RTCRtpSender for each track. + for (const track of stream.getTracks()) { + // Ignore if ended. + if (track.readyState === 'ended') { + logger.warn('ignoring ended MediaStreamTrack'); + + continue; // eslint-disable-line no-continue + } + + // Ignore if track is already present. + if (this._localTrackInfos.has(track.id)) { + logger.warn('ignoring already handled MediaStreamTrack'); + + continue; // eslint-disable-line no-continue + } + + const rtpSender = new RTCRtpSender(track, this._dtlsTransport); + + // Store it in the map. + this._localTrackInfos.set(track.id, { + rtpSender, + stream + }); + } + + // It may need to renegotiate. + this._emitNegotiationNeeded(); + } + + /** + * Promise based implementation for createAnswer(). + * @returns {Promise} + * @private + */ + _createAnswer(options) { // eslint-disable-line no-unused-vars + if (this._closed) { + return Promise.reject( + new InvalidStateError('RTCPeerConnection closed')); + } + + if (this.signalingState !== RTCSignalingState.haveRemoteOffer) { + return Promise.reject(new InvalidStateError( + `invalid signalingState "${this.signalingState}"`)); + } + + // Create an answer. + const localDescription = this._createLocalDescription('answer'); + + // Resolve with it. + return Promise.resolve(localDescription); + } + + /** + * Creates the local RTCSessionDescription. + * @param {String} type - 'offer' / 'answer'. + * @return {RTCSessionDescription} + */ + _createLocalDescription(type) { + const sdpObject = {}; + const localIceParameters = this._iceGatherer.getLocalParameters(); + const localIceCandidates = this._iceGatherer.getLocalCandidates(); + const localDtlsParameters = this._dtlsTransport.getLocalParameters(); + const remoteDtlsParameters = this._dtlsTransport.getRemoteParameters(); + const localCapabilities = this._localCapabilities; + const localTrackInfos = this._localTrackInfos; + + // TODO: TMP + logger.warn(`_createLocalDescription() ICE local [username:${localIceParameters.usernameFragment}, password:${localIceParameters.password}`); + + // Increase SDP version if an offer. + if (type === 'offer') { + this._sdpGlobalFields.version++; + } + + // SDP global fields. + sdpObject.version = 0; + sdpObject.origin = { + address: '127.0.0.1', + ipVer: 4, + netType: 'IN', + sessionId: this._sdpGlobalFields.id, + sessionVersion: this._sdpGlobalFields.version, + username: 'jitsi-ortc-webrtc-shim' + }; + sdpObject.name = '-'; + sdpObject.timing = { + start: 0, + stop: 0 + }; + sdpObject.msidSemantic = { + semantic: 'WMS', + token: '*' + }; + sdpObject.groups = [ + { + mids: Array.from(this._mids.keys()).join(' '), + type: 'BUNDLE' + } + ]; + sdpObject.media = []; + + // DTLS fingerprint. + sdpObject.fingerprint = { + hash: localDtlsParameters.fingerprints[0].value, + type: localDtlsParameters.fingerprints[0].algorithm + }; + + // Let's check whether there is video RTX. + let hasVideoRtx = false; + + for (const codec of localCapabilities.codecs) { + if (codec.kind === 'video' && codec.name === 'rtx') { + hasVideoRtx = true; + break; + } + } + + // Add m= sections. + for (const [ mid, kind ] of this._mids) { + addMediaSection.call(this, mid, kind); + } + + // Create a RTCSessionDescription. + const localDescription = new RTCSessionDescription({ + type, + _sdpObject: sdpObject + }); + + logger.debug('_createLocalDescription():', localDescription); + + return localDescription; + + /** + * Add a m= section. + */ + function addMediaSection(mid, kind) { + const mediaObject = {}; + + // m= line. + mediaObject.type = kind; + + switch (kind) { + case 'audio': + case 'video': + mediaObject.protocol = 'RTP/SAVPF'; + mediaObject.port = 9; + mediaObject.direction = 'sendrecv'; + break; + case 'application': + mediaObject.protocol = 'DTLS/SCTP'; + mediaObject.port = 0; // Reject m section. + mediaObject.payloads = '0'; // Just put something. + mediaObject.direction = 'inactive'; + break; + } + + // c= line. + mediaObject.connection = { + ip: '127.0.0.1', + version: 4 + }; + + // a=mid attribute. + mediaObject.mid = mid; + + // ICE. + mediaObject.iceUfrag = localIceParameters.usernameFragment; + mediaObject.icePwd = localIceParameters.password; + mediaObject.candidates = []; + + for (const candidate of localIceCandidates) { + const candidateObject = {}; + + // rtcp-mux is assumed, so component is always 1 (RTP). + candidateObject.component = 1; + candidateObject.foundation = candidate.foundation; + candidateObject.ip = candidate.ip; + candidateObject.port = candidate.port; + candidateObject.priority = candidate.priority; + candidateObject.transport + = candidate.protocol.toLowerCase(); + candidateObject.type = candidate.type; + if (candidateObject.transport === 'tcp') { + candidateObject.tcptype = candidate.tcpType; + } + + mediaObject.candidates.push(candidateObject); + } + + mediaObject.endOfCandidates = 'end-of-candidates'; + + // DTLS. + // If 'offer' always use 'actpass'. + if (type === 'offer') { + mediaObject.setup = 'actpass'; + } else { + mediaObject.setup = remoteDtlsParameters.role === 'server' + ? 'active' : 'passive'; + } + + if (kind === 'audio' || kind === 'video') { + mediaObject.rtp = []; + mediaObject.rtcpFb = []; + mediaObject.fmtp = []; + + // Array of payload types. + const payloads = []; + + // Add codecs. + for (const codec of localCapabilities.codecs) { + if (codec.kind && codec.kind !== kind) { + continue; // eslint-disable-line no-continue + } + + payloads.push(codec.preferredPayloadType); + + const rtpObject = { + codec: codec.name, + payload: codec.preferredPayloadType, + rate: codec.clockRate + }; + + if (codec.numChannels > 1) { + rtpObject.encoding = codec.numChannels; + } + + mediaObject.rtp.push(rtpObject); + + // If codec has parameters add them into a=fmtp attributes. + if (codec.parameters) { + const paramFmtp = { + config: '', + payload: codec.preferredPayloadType + }; + + for (const name of Object.keys(codec.parameters)) { + /* eslint-disable max-depth */ + if (paramFmtp.config) { + paramFmtp.config += ';'; + } + /* eslint-enable max-depth */ + + paramFmtp.config + += `${name}=${codec.parameters[name]}`; + } + + if (paramFmtp.config) { + mediaObject.fmtp.push(paramFmtp); + } + } + + // Set RTCP feedback. + for (const fb of codec.rtcpFeedback || []) { + mediaObject.rtcpFb.push({ + payload: codec.preferredPayloadType, + subtype: fb.parameter || undefined, + type: fb.type + }); + } + } + + // If there are no codecs, set this m section as unavailable. + if (payloads.length === 0) { + mediaObject.payloads = '9'; // Just put something. + mediaObject.port = 0; + mediaObject.direction = 'inactive'; + } else { + mediaObject.payloads = payloads.join(' '); + } + + // SSRCs. + mediaObject.ssrcs = []; + mediaObject.ssrcGroups = []; + + // Add RTP sending stuff. + for (const info of localTrackInfos.values()) { + const rtpSender = info.rtpSender; + const streamId = info.stream.id; + const track = rtpSender.track; + + // Ignore if ended. + if (track.readyState === 'ended') { + continue; // eslint-disable-line no-continue + } + + if (track.kind !== kind) { + continue; // eslint-disable-line no-continue + } + + // Set a random provisional SSRC if not set. + if (!info.ssrc) { + info.ssrc = utils.randomNumber(); + } + + // Whether RTX should be enabled. + const enableRtx = hasVideoRtx && track.kind === 'video'; + + // Set a random provisional RTX SSRC if not set. + if (enableRtx && !info.rtxSsrc) { + info.rtxSsrc = info.ssrc + 1; + } + + mediaObject.ssrcs.push({ + attribute: 'cname', + id: info.ssrc, + value: CNAME + }); + + mediaObject.ssrcs.push({ + attribute: 'msid', + id: info.ssrc, + value: `${streamId} ${track.id}` + }); + + mediaObject.ssrcs.push({ + attribute: 'mslabel', + id: info.ssrc, + value: streamId + }); + + mediaObject.ssrcs.push({ + attribute: 'label', + id: info.ssrc, + value: track.id + }); + + if (enableRtx) { + mediaObject.ssrcs.push({ + attribute: 'cname', + id: info.rtxSsrc, + value: CNAME + }); + + mediaObject.ssrcs.push({ + attribute: 'msid', + id: info.rtxSsrc, + value: `${streamId} ${track.id}` + }); + + mediaObject.ssrcs.push({ + attribute: 'mslabel', + id: info.rtxSsrc, + value: streamId + }); + + mediaObject.ssrcs.push({ + attribute: 'label', + id: info.rtxSsrc, + value: track.id + }); + + mediaObject.ssrcGroups.push({ + semantics: 'FID', + ssrcs: `${info.ssrc} ${info.rtxSsrc}` + }); + } + } + + // RTP header extensions. + mediaObject.ext = []; + + for (const extension of localCapabilities.headerExtensions) { + if (extension.kind && extension.kind !== kind) { + continue; // eslint-disable-line no-continue + } + + mediaObject.ext.push({ + value: extension.preferredId, + uri: extension.uri + }); + } + + // a=rtcp-mux attribute. + mediaObject.rtcpMux = 'rtcp-mux'; + + // a=rtcp-rsize. + mediaObject.rtcpRsize = 'rtcp-rsize'; + } + + // Add the media section. + sdpObject.media.push(mediaObject); + } + } + + /** + * Promise based implementation for createOffer(). + * @returns {Promise} + * @private + */ + _createOffer(options) { // eslint-disable-line no-unused-vars + if (this._closed) { + return Promise.reject( + new InvalidStateError('RTCPeerConnection closed')); + } + + if (this.signalingState !== RTCSignalingState.stable) { + return Promise.reject(new InvalidStateError( + `invalid signalingState "${this.signalingState}"`)); + } + + // NOTE: P2P mode not yet supported, so createOffer() should never be + // called. + // return Promise.reject(new Error('createoOffer() not yet supported')); + + // HACK: Create an offer assuming this is called before any + // setRemoteDescription() and assuming that setLocalDescription() + // wont be called with this offer. + + const sdpObject = {}; + const localIceParameters = this._iceGatherer.getLocalParameters(); + const localIceCandidates = this._iceGatherer.getLocalCandidates(); + const localDtlsParameters = this._dtlsTransport.getLocalParameters(); + const localCapabilities = RTCRtpReceiver.getCapabilities(); + const localTrackInfos = this._localTrackInfos; + const mids = new Map([ ['audio', 'audio'], ['video', 'video'] ]); + + // SDP global fields. + sdpObject.version = 0; + sdpObject.origin = { + address: '127.0.0.1', + ipVer: 4, + netType: 'IN', + sessionId: this._sdpGlobalFields.id, + sessionVersion: this._sdpGlobalFields.version, + username: 'jitsi-ortc-webrtc-shim' + }; + sdpObject.name = '-'; + sdpObject.timing = { + start: 0, + stop: 0 + }; + sdpObject.msidSemantic = { + semantic: 'WMS', + token: '*' + }; + sdpObject.groups = [ + { + mids: Array.from(mids.keys()).join(' '), + type: 'BUNDLE' + } + ]; + sdpObject.media = []; + + // DTLS fingerprint. + sdpObject.fingerprint = { + hash: localDtlsParameters.fingerprints[0].value, + type: localDtlsParameters.fingerprints[0].algorithm + }; + + // Let's check whether there is video RTX. + let hasVideoRtx = false; + + for (const codec of localCapabilities.codecs) { + if (codec.kind === 'video' && codec.name === 'rtx') { + hasVideoRtx = true; + break; + } + } + + // Add m= sections. + for (const [ mid, kind ] of mids) { + addMediaSection.call(this, mid, kind); + } + + // Create a RTCSessionDescription. + const localDescription = new RTCSessionDescription({ + type: 'offer', + _sdpObject: sdpObject + }); + + logger.debug('_createLocalDescription():', localDescription); + + // Resolve with it. + return Promise.resolve(localDescription); + + /** + * Add a m= section. + */ + function addMediaSection(mid, kind) { + const mediaObject = {}; + + // m= line. + mediaObject.type = kind; + + switch (kind) { + case 'audio': + case 'video': + mediaObject.protocol = 'RTP/SAVPF'; + mediaObject.port = 9; + mediaObject.direction = 'sendrecv'; + break; + case 'application': + mediaObject.protocol = 'DTLS/SCTP'; + mediaObject.port = 0; // Reject m section. + mediaObject.payloads = '0'; // Just put something. + mediaObject.direction = 'inactive'; + break; + } + + // c= line. + mediaObject.connection = { + ip: '127.0.0.1', + version: 4 + }; + + // a=mid attribute. + mediaObject.mid = mid; + + // ICE. + mediaObject.iceUfrag = localIceParameters.usernameFragment; + mediaObject.icePwd = localIceParameters.password; + mediaObject.candidates = []; + + for (const candidate of localIceCandidates) { + const candidateObject = {}; + + // rtcp-mux is assumed, so component is always 1 (RTP). + candidateObject.component = 1; + candidateObject.foundation = candidate.foundation; + candidateObject.ip = candidate.ip; + candidateObject.port = candidate.port; + candidateObject.priority = candidate.priority; + candidateObject.transport + = candidate.protocol.toLowerCase(); + candidateObject.type = candidate.type; + if (candidateObject.transport === 'tcp') { + candidateObject.tcptype = candidate.tcpType; + } + + mediaObject.candidates.push(candidateObject); + } + + mediaObject.endOfCandidates = 'end-of-candidates'; + + // DTLS. + // 'offer' so always use 'actpass'. + mediaObject.setup = 'actpass'; + + if (kind === 'audio' || kind === 'video') { + mediaObject.rtp = []; + mediaObject.rtcpFb = []; + mediaObject.fmtp = []; + + // Array of payload types. + const payloads = []; + + // Add codecs. + for (const codec of localCapabilities.codecs) { + if (codec.kind && codec.kind !== kind) { + continue; // eslint-disable-line no-continue + } + + payloads.push(codec.preferredPayloadType); + + const rtpObject = { + codec: codec.name, + payload: codec.preferredPayloadType, + rate: codec.clockRate + }; + + if (codec.numChannels > 1) { + rtpObject.encoding = codec.numChannels; + } + + mediaObject.rtp.push(rtpObject); + + // If codec has parameters add them into a=fmtp attributes. + if (codec.parameters) { + const paramFmtp = { + config: '', + payload: codec.preferredPayloadType + }; + + for (const name of Object.keys(codec.parameters)) { + /* eslint-disable max-depth */ + if (paramFmtp.config) { + paramFmtp.config += ';'; + } + /* eslint-enable max-depth */ + + paramFmtp.config + += `${name}=${codec.parameters[name]}`; + } + + if (paramFmtp.config) { + mediaObject.fmtp.push(paramFmtp); + } + } + + // Set RTCP feedback. + for (const fb of codec.rtcpFeedback || []) { + mediaObject.rtcpFb.push({ + payload: codec.preferredPayloadType, + subtype: fb.parameter || undefined, + type: fb.type + }); + } + } + + // If there are no codecs, set this m section as unavailable. + if (payloads.length === 0) { + mediaObject.payloads = '9'; // Just put something. + mediaObject.port = 0; + mediaObject.direction = 'inactive'; + } else { + mediaObject.payloads = payloads.join(' '); + } + + // SSRCs. + mediaObject.ssrcs = []; + mediaObject.ssrcGroups = []; + + // Add RTP sending stuff. + for (const info of localTrackInfos.values()) { + const rtpSender = info.rtpSender; + const streamId = info.stream.id; + const track = rtpSender.track; + + // Ignore if ended. + if (track.readyState === 'ended') { + continue; // eslint-disable-line no-continue + } + + if (track.kind !== kind) { + continue; // eslint-disable-line no-continue + } + + // Set a random provisional SSRC if not set. + if (!info.ssrc) { + info.ssrc = utils.randomNumber(); + } + + // Whether RTX should be enabled. + const enableRtx = hasVideoRtx && track.kind === 'video'; + + // Set a random provisional RTX SSRC if not set. + if (enableRtx && !info.rtxSsrc) { + info.rtxSsrc = info.ssrc + 1; + } + + mediaObject.ssrcs.push({ + attribute: 'cname', + id: info.ssrc, + value: CNAME + }); + + mediaObject.ssrcs.push({ + attribute: 'msid', + id: info.ssrc, + value: `${streamId} ${track.id}` + }); + + mediaObject.ssrcs.push({ + attribute: 'mslabel', + id: info.ssrc, + value: streamId + }); + + mediaObject.ssrcs.push({ + attribute: 'label', + id: info.ssrc, + value: track.id + }); + + if (enableRtx) { + mediaObject.ssrcs.push({ + attribute: 'cname', + id: info.rtxSsrc, + value: CNAME + }); + + mediaObject.ssrcs.push({ + attribute: 'msid', + id: info.rtxSsrc, + value: `${streamId} ${track.id}` + }); + + mediaObject.ssrcs.push({ + attribute: 'mslabel', + id: info.rtxSsrc, + value: streamId + }); + + mediaObject.ssrcs.push({ + attribute: 'label', + id: info.rtxSsrc, + value: track.id + }); + + mediaObject.ssrcGroups.push({ + semantics: 'FID', + ssrcs: `${info.ssrc} ${info.rtxSsrc}` + }); + } + } + + // RTP header extensions. + mediaObject.ext = []; + + for (const extension of localCapabilities.headerExtensions) { + if (extension.kind && extension.kind !== kind) { + continue; // eslint-disable-line no-continue + } + + mediaObject.ext.push({ + value: extension.preferredId, + uri: extension.uri + }); + } + + // a=rtcp-mux attribute. + mediaObject.rtcpMux = 'rtcp-mux'; + + // a=rtcp-rsize. + mediaObject.rtcpRsize = 'rtcp-rsize'; + } + + // Add the media section. + sdpObject.media.push(mediaObject); + } + } + + /** + * Emit 'addstream' event. + * @private + */ + _emitAddStream(stream) { + if (this._closed) { + return; + } + + logger.debug('emitting "addstream"'); + + const event = new yaeti.Event('addstream'); + + event.stream = stream; + this.dispatchEvent(event); + } + + /** + * May emit buffered ICE candidates. + * @private + */ + _emitBufferedIceCandidates() { + if (this._closed) { + return; + } + + for (const sdpCandidate of this._bufferedIceCandidates) { + if (!sdpCandidate) { + continue; // eslint-disable-line no-continue + } + + // Now we have set the MID values of the SDP O/A, so let's fill the + // sdpMIndex of the candidate. + sdpCandidate.sdpMIndex = this._mids.keys().next().value; + + logger.debug( + 'emitting buffered "icecandidate", candidate:', sdpCandidate); + + const event = new yaeti.Event('icecandidate'); + + event.candidate = sdpCandidate; + this.dispatchEvent(event); + } + + this._bufferedIceCandidates = []; + } + + /** + * May emit 'connectionstatechange' event. + * @private + */ + _emitConnectionStateChange() { + if (this._closed && this.connectionState !== 'closed') { + return; + } + + logger.debug( + 'emitting "connectionstatechange", connectionState:', + this.connectionState); + + const event = new yaeti.Event('connectionstatechange'); + + this.dispatchEvent(event); + } + + /** + * May emit 'icecandidate' event. + * @private + */ + _emitIceCandidate(candidate) { + if (this._closed) { + return; + } + + let sdpCandidate = null; + + if (candidate) { + // NOTE: We assume BUNDLE so let's just emit candidates for the + // first m= section. + const sdpMIndex = this._mids.keys().next().value; + const sdpMLineIndex = 0; + let sdpAttribute + = `candidate:${candidate.foundation} 1 ${candidate.protocol}` + + ` ${candidate.priority} ${candidate.ip} ${candidate.port}` + + ` typ ${candidate.type}`; + + if (candidate.relatedAddress) { + sdpAttribute += ` raddr ${candidate.relatedAddress}`; + } + if (candidate.relatedPort) { + sdpAttribute += ` rport ${candidate.relatedPort}`; + } + if (candidate.protocol === 'tcp') { + sdpAttribute += ` tcptype ${candidate.tcpType}`; + } + + sdpCandidate = { + candidate: sdpAttribute, + component: 1, // rtcp-mux assumed, so always 1 (RTP). + foundation: candidate.foundation, + ip: candidate.ip, + port: candidate.port, + priority: candidate.priority, + protocol: candidate.protocol, + type: candidate.type, + sdpMIndex, + sdpMLineIndex + }; + + if (candidate.protocol === 'tcp') { + sdpCandidate.tcptype = candidate.tcpType; + } + if (candidate.relatedAddress) { + sdpCandidate.relatedAddress = candidate.relatedAddress; + } + if (candidate.relatedPort) { + sdpCandidate.relatedPort = candidate.relatedPort; + } + } + + // If we don't have yet a local description, buffer the candidate. + if (this._localDescription) { + logger.debug( + 'emitting "icecandidate", candidate:', sdpCandidate); + + const event = new yaeti.Event('icecandidate'); + + event.candidate = sdpCandidate; + this.dispatchEvent(event); + } else { + logger.debug( + 'buffering gathered ICE candidate:', sdpCandidate); + + this._bufferedIceCandidates.push(sdpCandidate); + } + } + + /** + * May emit 'iceconnectionstatechange' event. + * @private + */ + _emitIceConnectionStateChange() { + if (this._closed && this.iceConnectionState !== 'closed') { + return; + } + + logger.debug( + 'emitting "iceconnectionstatechange", iceConnectionState:', + this.iceConnectionState); + + const event = new yaeti.Event('iceconnectionstatechange'); + + this.dispatchEvent(event); + } + + /** + * May emit 'negotiationneeded' event. + * @private + */ + _emitNegotiationNeeded() { + // Ignore if signalingState is not 'stable'. + if (this.signalingState !== RTCSignalingState.stable) { + return; + } + + logger.debug('emitting "negotiationneeded"'); + + const event = new yaeti.Event('negotiationneeded'); + + this.dispatchEvent(event); + } + + /** + * Emit 'removestream' event. + * @private + */ + _emitRemoveStream(stream) { + if (this._closed) { + return; + } + + logger.debug('emitting "removestream"'); + + const event = new yaeti.Event('removestream'); + + event.stream = stream; + this.dispatchEvent(event); + } + + /** + * Get RTP parameters for a RTCRtpReceiver. + * @private + * @return {RTCRtpParameters} + */ + _getParametersForRtpReceiver(kind, data) { + const ssrc = data.ssrc; + const rtxSsrc = data.rtxSsrc; + const cname = data.cname; + const localCapabilities = this._localCapabilities; + const parameters = { + codecs: [], + degradationPreference: 'balanced', + encodings: [], + headerExtensions: [], + muxId: '', + rtcp: { + cname, + compound: true, // NOTE: Implemented in Edge. + mux: true, + reducedSize: true // NOTE: Not yet implemented in Edge. + } + }; + + const codecs = []; + let codecPayloadType; + + for (const codecCapability of localCapabilities.codecs) { + if (codecCapability.kind !== kind + || codecCapability.name === 'rtx') { + continue; // eslint-disable-line no-continue + } + + codecPayloadType = codecCapability.preferredPayloadType; + codecs.push({ + clockRate: codecCapability.clockRate, + maxptime: codecCapability.maxptime, + mimeType: codecCapability.mimeType, + name: codecCapability.name, + numChannels: codecCapability.numChannels, + parameters: codecCapability.parameters, + payloadType: codecCapability.preferredPayloadType, + ptime: codecCapability.ptime, + rtcpFeedback: codecCapability.rtcpFeedback + }); + + break; + } + + if (rtxSsrc) { + for (const codecCapability of localCapabilities.codecs) { + if (codecCapability.kind !== kind + || codecCapability.name !== 'rtx') { + continue; // eslint-disable-line no-continue + } + + codecs.push({ + clockRate: codecCapability.clockRate, + mimeType: codecCapability.mimeType, + name: 'rtx', + parameters: codecCapability.parameters, + payloadType: codecCapability.preferredPayloadType, + rtcpFeedback: codecCapability.rtcpFeedback + }); + + break; + } + } + + parameters.codecs = codecs; + + const encoding = { + active: true, + codecPayloadType, + ssrc + }; + + if (rtxSsrc) { + encoding.rtx = { + ssrc: rtxSsrc + }; + } + + parameters.encodings.push(encoding); + + for (const extension of localCapabilities.headerExtensions) { + if (extension.kind !== kind) { + continue; // eslint-disable-line no-continue + } + + parameters.headerExtensions.push({ + encrypt: extension.preferredEncrypt, + id: extension.preferredId, + uri: extension.uri + }); + } + + return parameters; + } + + /** + * Get RTP parameters for a RTCRtpSender. + * @private + * @return {RTCRtpParameters} + */ + _getParametersForRtpSender(kind, data) { + const ssrc = data.ssrc; + const rtxSsrc = data.rtxSsrc; + const cname = CNAME; + const localCapabilities = this._localCapabilities; + const parameters = { + codecs: [], + degradationPreference: 'balanced', + encodings: [], + headerExtensions: [], + muxId: '', + rtcp: { + cname, + compound: true, // NOTE: Implemented in Edge. + mux: true, + reducedSize: true // NOTE: Not yet implemented in Edge. + } + }; + + const codecs = []; + let codecPayloadType; + + for (const codecCapability of localCapabilities.codecs) { + if (codecCapability.kind !== kind + || codecCapability.name === 'rtx') { + continue; // eslint-disable-line no-continue + } + + codecPayloadType = codecCapability.preferredPayloadType; + codecs.push({ + clockRate: codecCapability.clockRate, + maxptime: codecCapability.maxptime, + mimeType: codecCapability.mimeType, + name: codecCapability.name, + numChannels: codecCapability.numChannels, + parameters: codecCapability.parameters, + payloadType: codecCapability.preferredPayloadType, + ptime: codecCapability.ptime, + rtcpFeedback: codecCapability.rtcpFeedback + }); + + break; + } + + if (rtxSsrc) { + for (const codecCapability of localCapabilities.codecs) { + if (codecCapability.kind !== kind + || codecCapability.name !== 'rtx') { + continue; // eslint-disable-line no-continue + } + + codecs.push({ + clockRate: codecCapability.clockRate, + mimeType: codecCapability.mimeType, + name: 'rtx', + parameters: codecCapability.parameters, + payloadType: codecCapability.preferredPayloadType, + rtcpFeedback: codecCapability.rtcpFeedback + }); + + break; + } + } + + parameters.codecs = codecs; + + const encoding = { + active: true, + codecPayloadType, + ssrc + }; + + if (rtxSsrc) { + encoding.rtx = { + ssrc: rtxSsrc + }; + } + + parameters.encodings.push(encoding); + + for (const extension of localCapabilities.headerExtensions) { + if (extension.kind !== kind) { + continue; // eslint-disable-line no-continue + } + + parameters.headerExtensions.push({ + encrypt: extension.preferredEncrypt, + id: extension.preferredId, + uri: extension.uri + }); + } + + return parameters; + } + + /** + * Promise based implementation for getStats(). + * @return {Promise} + * @private + */ + _getStats(selector) { // eslint-disable-line no-unused-vars + if (this._closed) { + return Promise.reject( + new InvalidStateError('RTCPeerConnection closed')); + } + + // TODO: TBD + return Promise.reject(new Error('getStats() not yet implemented')); + } + + /** + * Handles the local initial answer. + * @return {Promise} + * @private + */ + _handleLocalInitialAnswer(desc) { + logger.debug('_handleLocalInitialAnswer(), desc:', desc); + + const sdpObject = desc.sdpObject; + + // Update local capabilities as decided by the app. + this._localCapabilities = ortcUtils.extractCapabilities(sdpObject); + + logger.debug('local capabilities:', this._localCapabilities); + + // TODO: Should inspect the answer given by the app and update our + // sending RTP parameters if, for example, the app modified SSRC + // values. + } + + /** + * Handles a local re-answer. + * @return {Promise} + * @private + */ + _handleLocalReAnswer(desc) { + logger.debug('_handleLocalReAnswer(), desc:', desc); + + const sdpObject = desc.sdpObject; + + // Update local capabilities as decided by the app. + this._localCapabilities = ortcUtils.extractCapabilities(sdpObject); + + logger.debug('local capabilities:', this._localCapabilities); + + // TODO: Should inspect the answer given by the app and update our + // sending RTP parameters if, for example, the app modified SSRC + // values. + } + + /** + * Handles the remote initial offer. + * @return {Promise} + * @private + */ + _handleRemoteInitialOffer(desc) { + logger.debug('_handleRemoteInitialOffer(), desc:', desc); + + const sdpObject = desc.sdpObject; + + // Set MID values. + this._mids = ortcUtils.extractMids(sdpObject); + + // Get remote RTP capabilities. + const remoteCapabilities = ortcUtils.extractCapabilities(sdpObject); + + logger.debug('remote capabilities:', remoteCapabilities); + + // Get local RTP capabilities (filter them with remote capabilities). + this._localCapabilities + = ortcUtils.getLocalCapabilities(remoteCapabilities); + + // Start ICE and DTLS. + this._startIceAndDtls(desc); + } + + /** + * Handles a remote re-offer. + * @return {Promise} + * @private + */ + _handleRemoteReOffer(desc) { + logger.debug('_handleRemoteReOffer(), desc:', desc); + + const sdpObject = desc.sdpObject; + + // Update MID values (just in case). + this._mids = ortcUtils.extractMids(sdpObject); + + // Get remote RTP capabilities (filter them with remote capabilities). + const remoteCapabilities = ortcUtils.extractCapabilities(sdpObject); + + logger.debug('remote capabilities:', remoteCapabilities); + + // Update local RTP capabilities (just in case). + this._localCapabilities + = ortcUtils.getLocalCapabilities(remoteCapabilities); + } + + /** + * Start receiving remote media. + */ + _receiveMedia() { + logger.debug('_receiveMedia()'); + + const currentRemoteSsrcs = new Set(this._remoteTrackInfos.keys()); + const newRemoteTrackInfos + = ortcUtils.extractTrackInfos(this._remoteDescription.sdpObject); + + // Map of new remote MediaStream indexed by MediaStream.jitsiRemoteId. + const addedRemoteStreams = new Map(); + + // Map of remote MediaStream indexed by added MediaStreamTrack. + // NOTE: Just filled for already existing streams. + const addedRemoteTracks = new Map(); + + // Map of remote MediaStream indexed by removed MediaStreamTrack. + const removedRemoteTracks = new Map(); + + logger.debug( + '_receiveMedia() remote track infos:', newRemoteTrackInfos); + + // Check new tracks. + for (const [ ssrc, info ] of newRemoteTrackInfos) { + // If already handled, ignore it. + if (currentRemoteSsrcs.has(ssrc)) { + continue; // eslint-disable-line no-continue + } + + logger.debug(`_receiveMedia() new remote track, ssrc:${ssrc}`); + + // Otherwise append to the map. + this._remoteTrackInfos.set(ssrc, info); + + const kind = info.kind; + const rtxSsrc = info.rtxSsrc; + const streamRemoteId = info.streamId; + const trackRemoteId = info.trackId; + const cname = info.cname; + const isNewStream = !this._remoteStreams.has(streamRemoteId); + let stream; + + if (isNewStream) { + logger.debug( + `_receiveMedia() new remote stream, id:${streamRemoteId}`); + + // Create a new MediaStream. + stream = new MediaStream(); + + // Set custom property with the remote id. + stream.jitsiRemoteId = streamRemoteId; + + addedRemoteStreams.set(streamRemoteId, stream); + this._remoteStreams.set(streamRemoteId, stream); + } else { + stream = this._remoteStreams.get(streamRemoteId); + } + + const rtpReceiver = new RTCRtpReceiver(this._dtlsTransport, kind); + const parameters = this._getParametersForRtpReceiver(kind, { + ssrc, + rtxSsrc, + cname + }); + + // Fill the info with the stream and rtpReceiver. + info.stream = stream; + info.rtpReceiver = rtpReceiver; + + logger.debug( + 'calling rtpReceiver.receive(), parameters:', parameters); + + // Start receiving media. + try { + rtpReceiver.receive(parameters); + + // Get the associated MediaStreamTrack. + const track = rtpReceiver.track; + + // Set custom property with the remote id. + track.jitsiRemoteId = trackRemoteId; + + // Add the track to the stream. + stream.addTrack(track); + + if (!addedRemoteStreams.has(streamRemoteId)) { + addedRemoteTracks.set(track, stream); + } + } catch (error) { + logger.error(`rtpReceiver.receive() failed:${error.message}`); + logger.error(error); + } + } + + // Check track removal. + for (const ssrc of currentRemoteSsrcs) { + if (newRemoteTrackInfos.has(ssrc)) { + continue; // eslint-disable-line no-continue + } + + logger.debug(`_receiveMedia() remote track removed, ssrc:${ssrc}`); + + const info = this._remoteTrackInfos.get(ssrc); + const stream = info.stream; + const rtpReceiver = info.rtpReceiver; + const track = rtpReceiver.track; + + try { + rtpReceiver.stop(); + } catch (error) { + logger.warn(`rtpReceiver.stop() failed:${error}`); + } + + removedRemoteTracks.set(track, stream); + stream.removeTrack(track); + this._remoteTrackInfos.delete(ssrc); + } + + // Emit MediaStream 'addtrack' for new tracks in already existing + // streams. + for (const [ track, stream ] of addedRemoteTracks) { + const event = new Event('addtrack'); + + event.track = track; + stream.dispatchEvent(event); + } + + // Emit MediaStream 'removetrack' for removed tracks. + for (const [ track, stream ] of removedRemoteTracks) { + const event = new Event('removetrack'); + + event.track = track; + stream.dispatchEvent(event); + } + + // Emit RTCPeerConnection 'addstream' for new remote streams. + for (const stream of addedRemoteStreams.values()) { + // Check whether at least a track was added, otherwise ignore it. + if (stream.getTracks().length === 0) { + logger.warn( + 'ignoring new stream for which no track could be added'); + + addedRemoteStreams.delete(stream.jitsiRemoteId); + this._remoteStreams.delete(stream.jitsiRemoteId); + } else { + this._emitAddStream(stream); + } + } + + // Emit RTCPeerConnection 'removestream' for removed remote streams. + for (const [ streamRemoteId, stream ] of this._remoteStreams) { + if (stream.getTracks().length > 0) { + continue; // eslint-disable-line no-continue + } + + this._remoteStreams.delete(streamRemoteId); + this._emitRemoveStream(stream); + } + } + + /** + * Implementation for removeStream(). + * @private + */ + _removeStream(stream) { + if (this._closed) { + throw new InvalidStateError('RTCPeerConnection closed'); + } + + // Stop and remove the RTCRtpSender associated to each track. + for (const track of stream.getTracks()) { + // Ignore if track not present. + if (!this._localTrackInfos.has(track.id)) { + continue; // eslint-disable-line no-continue + } + + const rtpSender = this._localTrackInfos.get(track.id).rtpSender; + + try { + rtpSender.stop(); + } catch (error) { + logger.warn(`rtpSender.stop() failed:${error}`); + } + + // Remove from the map. + this._localTrackInfos.delete(track.id); + } + + // It may need to renegotiate. + this._emitNegotiationNeeded(); + } + + /** + * Start sending our media to the remote. + */ + _sendMedia() { + logger.debug('_sendMedia()'); + + for (const info of this._localTrackInfos.values()) { + // Ignore if already sending. + if (info.sending) { + continue; // eslint-disable-line no-continue + } + + // Update sending field. + info.sending = true; + + const rtpSender = info.rtpSender; + const ssrc = info.ssrc; + const rtxSsrc = info.rtxSsrc; + const track = rtpSender.track; + const kind = track.kind; + + const parameters = this._getParametersForRtpSender(kind, { + ssrc, + rtxSsrc + }); + + logger.debug( + 'calling rtpSender.send(), parameters:', parameters); + + // Start rsending media. + try { + rtpSender.send(parameters); + } catch (error) { + logger.error(`rtpSender.send() failed:${error.message}`); + logger.error(error); + } + } + } + + /** + * Creates the RTCDtlsTransport. + * @private + */ + _setDtlsTransport(iceTransport) { + const dtlsTransport = new RTCDtlsTransport(iceTransport); + + // NOTE: Not yet implemented by Edge. + dtlsTransport.onstatechange = () => { + logger.debug( + 'dtlsTransport "statechange" event, ' + + `state:${dtlsTransport.state}`); + + this._emitConnectionStateChange(); + }; + + // NOTE: Not standard, but implemented by Edge. + dtlsTransport.ondtlsstatechange = () => { + logger.debug( + 'dtlsTransport "dtlsstatechange" event, ' + + `state:${dtlsTransport.state}`); + + this._emitConnectionStateChange(); + }; + + dtlsTransport.onerror = ev => { + let message; + + if (ev.message) { + message = ev.message; + } else if (ev.error) { + message = ev.error.message; + } + + logger.error(`dtlsTransport "error" event, message:${message}`); + + // TODO: Edge does not set state to 'failed' on error. We should + // hack it. + + this._emitConnectionStateChange(); + }; + + this._dtlsTransport = dtlsTransport; + } + + /** + * Creates the RTCIceGatherer. + * @private + */ + _setIceGatherer(pcConfig) { + const iceGatherOptions = { + gatherPolicy: pcConfig.iceTransportPolicy || 'all', + iceServers: pcConfig.iceServers || [] + }; + const iceGatherer = new RTCIceGatherer(iceGatherOptions); + + // NOTE: Not yet implemented by Edge. + iceGatherer.onstatechange = () => { + logger.debug( + `iceGatherer "statechange" event, state:${iceGatherer.state}`); + + this._updateAndEmitIceGatheringStateChange(iceGatherer.state); + }; + + iceGatherer.onlocalcandidate = ev => { + let candidate = ev.candidate; + + // NOTE: Not yet implemented by Edge. + const complete = ev.complete; + + logger.debug( + 'iceGatherer "localcandidate" event, candidate:', candidate); + + // NOTE: Instead of null candidate or complete:true, current Edge + // signals end of gathering with an empty candidate object. + if (complete + || !candidate + || Object.keys(candidate).length === 0) { + + candidate = null; + + this._updateAndEmitIceGatheringStateChange( + RTCIceGatheringState.complete); + this._emitIceCandidate(null); + } else { + this._emitIceCandidate(candidate); + } + }; + + iceGatherer.onerror = ev => { + const errorCode = ev.errorCode; + const errorText = ev.errorText; + + logger.error( + `iceGatherer "error" event, errorCode:${errorCode}, ` + + `errorText:${errorText}`); + }; + + // NOTE: Not yet implemented by Edge, which starts gathering + // automatically. + try { + iceGatherer.gather(); + } catch (error) { + logger.warn(`iceGatherer.gather() failed:${error}`); + } + + this._iceGatherer = iceGatherer; + } + + /** + * Creates the RTCIceTransport. + * @private + */ + _setIceTransport(iceGatherer) { + const iceTransport = new RTCIceTransport(iceGatherer); + + // NOTE: Not yet implemented by Edge. + iceTransport.onstatechange = () => { + logger.debug( + 'iceTransport "statechange" event, ' + + `state:${iceTransport.state}`); + + this._emitIceConnectionStateChange(); + }; + + // NOTE: Not standard, but implemented by Edge. + iceTransport.onicestatechange = () => { + logger.debug( + 'iceTransport "icestatechange" event, ' + + `state:${iceTransport.state}`); + + if (iceTransport.state === 'completed') { + logger.debug( + 'nominated candidate pair:', + iceTransport.getNominatedCandidatePair()); + + // TODO: TMP + logger.warn( + 'nominated candidate pair:', + iceTransport.getNominatedCandidatePair()); + } + + this._emitIceConnectionStateChange(); + }; + + iceTransport.oncandidatepairchange = ev => { + logger.debug( + 'iceTransport "candidatepairchange" event, ' + + `pair:${ev.pair}`); + }; + + this._iceTransport = iceTransport; + } + + /** + * Promise based implementation for setLocalDescription(). + * @returns {Promise} + * @private + */ + _setLocalDescription(desc) { + if (this._closed) { + return Promise.reject( + new InvalidStateError('RTCPeerConnection closed')); + } + + let localDescription; + + try { + localDescription = new RTCSessionDescription(desc); + } catch (error) { + return Promise.reject(new TypeError( + `invalid RTCSessionDescriptionInit: ${error}`)); + } + + switch (desc.type) { + case 'offer': { + if (this.signalingState !== RTCSignalingState.stable) { + return Promise.reject(new InvalidStateError( + `invalid signalingState "${this.signalingState}"`)); + } + + // NOTE: P2P mode not yet supported, so createOffer() should never + // has been called, neither setLocalDescription() with an offer. + return Promise.reject(new TypeError( + 'setLocalDescription() with type "offer" not supported')); + } + case 'answer': { + if (this.signalingState !== RTCSignalingState.haveRemoteOffer) { + return Promise.reject(new InvalidStateError( + `invalid signalingState "${this.signalingState}"`)); + } + + const isLocalInitialAnswer = Boolean(!this._localDescription); + + return Promise.resolve() + .then(() => { + // Different handling for initial answer and re-answer. + if (isLocalInitialAnswer) { + return this._handleLocalInitialAnswer(localDescription); + } else { // eslint-disable-line no-else-return + return this._handleLocalReAnswer(localDescription); + } + }) + .then(() => { + logger.debug('setLocalDescription() succeed'); + + // Update local description. + this._localDescription = localDescription; + + // Update signaling state. + this._updateAndEmitSignalingStateChange( + RTCSignalingState.stable); + + // If initial answer, emit buffered ICE candidates. + if (isLocalInitialAnswer) { + this._emitBufferedIceCandidates(); + } + + // Send our RTP. + this._sendMedia(); + + // Receive remote RTP. + this._receiveMedia(); + }) + .catch(error => { + logger.error( + `setLocalDescription() failed: ${error.message}`); + logger.error(error); + + throw error; + }); + } + default: + return Promise.reject(new TypeError( + `unsupported description.type "${desc.type}"`)); + } + } + + /** + * Promise based implementation for setRemoteDescription(). + * @returns {Promise} + * @private + */ + _setRemoteDescription(desc) { + if (this._closed) { + return Promise.reject( + new InvalidStateError('RTCPeerConnection closed')); + } + + let remoteDescription; + + try { + remoteDescription = new RTCSessionDescription(desc); + } catch (error) { + return Promise.reject(new TypeError( + `invalid RTCSessionDescriptionInit: ${error}`)); + } + + switch (desc.type) { + case 'offer': { + if (this.signalingState !== RTCSignalingState.stable) { + return Promise.reject(new InvalidStateError( + `invalid signalingState "${this.signalingState}"`)); + } + + const isRemoteInitialOffer = Boolean(!this._remoteDescription); + + return Promise.resolve() + .then(() => { + // Different handling for initial answer and re-answer. + if (isRemoteInitialOffer) { + return this._handleRemoteInitialOffer( + remoteDescription); + } else { // eslint-disable-line no-else-return + return this._handleRemoteReOffer(remoteDescription); + } + }) + .then(() => { + logger.debug('setRemoteDescription() succeed'); + + // Update remote description. + this._remoteDescription = remoteDescription; + + // Update signaling state. + this._updateAndEmitSignalingStateChange( + RTCSignalingState.haveRemoteOffer); + }) + .catch(error => { + logger.error(`setRemoteDescription() failed: ${error}`); + + throw error; + }); + } + case 'answer': { + if (this.signalingState !== RTCSignalingState.haveLocalOffer) { + return Promise.reject(new InvalidStateError( + `invalid signalingState "${this.signalingState}"`)); + } + + // NOTE: P2P mode not yet supported, so createOffer() should never + // has been called, neither setRemoteDescription() with an answer. + return Promise.reject(new TypeError( + 'setRemoteDescription() with type "answer" not supported')); + } + default: + return Promise.reject(new TypeError( + `unsupported description.type "${desc.type}"`)); + } + } + + /** + * Start ICE and DTLS connection procedures. + * @param {RTCSessionDescription} desc - Remote description. + */ + _startIceAndDtls(desc) { + const sdpObject = desc.sdpObject; + const remoteIceParameters + = ortcUtils.extractIceParameters(sdpObject); + const remoteIceCandidates + = ortcUtils.extractIceCandidates(sdpObject); + const remoteDtlsParameters + = ortcUtils.extractDtlsParameters(sdpObject); + + // TODO: TMP + logger.warn(`_startIceAndDtls() ICE remote [username:${remoteIceParameters.usernameFragment}, password:${remoteIceParameters.password}`); + logger.warn('_startIceAndDtls() remoteIceParameters:'); + console.warn(remoteIceParameters); + + // Start the RTCIceTransport. + switch (desc.type) { + case 'offer': + this._iceTransport.start( + this._iceGatherer, remoteIceParameters, 'controlled'); + break; + case 'answer': + this._iceTransport.start( + this._iceGatherer, remoteIceParameters, 'controlling'); + break; + } + + // Add remote ICE candidates. + // NOTE: Remove candidates that Edge doesn't like. + for (const candidate of remoteIceCandidates) { + if (candidate.port === 0 || candidate.port === 9) { + continue; // eslint-disable-line no-continue + } + + this._iceTransport.addRemoteCandidate(candidate); + } + + // Also signal a 'complete' candidate as per spec. + // NOTE: It should be {complete: true} but Edge prefers {}. + // NOTE: We know that addCandidate() is never used so we need to signal + // end of candidates (otherwise the RTCIceTransport never enters the + // 'completed' state). + this._iceTransport.addRemoteCandidate({}); + + // Set desired remote DTLS role (as we receive the offer). + switch (desc.type) { + case 'offer': + remoteDtlsParameters.role = 'server'; + break; + case 'answer': + remoteDtlsParameters.role = 'client'; + break; + } + + // Start RTCDtlsTransport. + this._dtlsTransport.start(remoteDtlsParameters); + } + + /** + * May update iceGatheringState and emit 'icegatheringstatechange' event. + * @private + */ + _updateAndEmitIceGatheringStateChange(state) { + if (this._closed || state === this.iceGatheringState) { + return; + } + + this._iceGatheringState = state; + + logger.debug( + 'emitting "icegatheringstatechange", iceGatheringState:', + this.iceGatheringState); + + const event = new yaeti.Event('icegatheringstatechange'); + + this.dispatchEvent(event); + } + + /** + * May update signalingState and emit 'signalingstatechange' event. + * @private + */ + _updateAndEmitSignalingStateChange(state) { + if (state === this.signalingState) { + return; + } + + this._signalingState = state; + + logger.debug( + 'emitting "signalingstatechange", signalingState:', + this.signalingState); + + const event = new yaeti.Event('signalingstatechange'); + + this.dispatchEvent(event); + } +} diff --git a/app/lib/edge/RTCSessionDescription.js b/app/lib/edge/RTCSessionDescription.js new file mode 100644 index 0000000..62b91f5 --- /dev/null +++ b/app/lib/edge/RTCSessionDescription.js @@ -0,0 +1,105 @@ +import sdpTransform from 'sdp-transform'; + +/** + * RTCSessionDescription implementation. + */ +export default class RTCSessionDescription { + /** + * RTCSessionDescription constructor. + * @param {Object} [data] + * @param {String} [data.type] - 'offer' / 'answer'. + * @param {String} [data.sdp] - SDP string. + * @param {Object} [data._sdpObject] - SDP object generated by the + * sdp-transform library. + */ + constructor(data) { + // @type {String} + this._sdp = null; + + // @type {Object} + this._sdpObject = null; + + // @type {String} + this._type = null; + + switch (data.type) { + case 'offer': + break; + case 'answer': + break; + default: + throw new TypeError(`invalid type "${data.type}"`); + } + + this._type = data.type; + + if (typeof data.sdp === 'string') { + this._sdp = data.sdp; + try { + this._sdpObject = sdpTransform.parse(data.sdp); + } catch (error) { + throw new Error(`invalid sdp: ${error}`); + } + } else if (typeof data._sdpObject === 'object') { + this._sdpObject = data._sdpObject; + try { + this._sdp = sdpTransform.write(data._sdpObject); + } catch (error) { + throw new Error(`invalid sdp object: ${error}`); + } + } else { + throw new TypeError('invalid sdp or _sdpObject'); + } + } + + /** + * Get sdp field. + * @return {String} + */ + get sdp() { + return this._sdp; + } + + /** + * Set sdp field. + * NOTE: This is not allowed per spec, but lib-jitsi-meet uses it. + * @param {String} sdp + */ + set sdp(sdp) { + try { + this._sdpObject = sdpTransform.parse(sdp); + } catch (error) { + throw new Error(`invalid sdp: ${error}`); + } + + this._sdp = sdp; + } + + /** + * Gets the internal sdp object. + * @return {Object} + * @private + */ + get sdpObject() { + return this._sdpObject; + } + + /** + * Get type field. + * @return {String} + */ + get type() { + return this._type; + } + + /** + * Returns an object with type and sdp fields. + * @return {Object} + */ + toJSON() { + return { + sdp: this._sdp, + type: this._type + }; + } +} diff --git a/app/lib/edge/errors.js b/app/lib/edge/errors.js new file mode 100644 index 0000000..99197f9 --- /dev/null +++ b/app/lib/edge/errors.js @@ -0,0 +1,21 @@ +/** + * Create a class inheriting from Error. + */ +function createErrorClass(name) { + const klass = class extends Error { + /** + * Custom error class constructor. + * @param {string} message + */ + constructor(message) { + super(message); + + // Override `name` property value and make it non enumerable. + Object.defineProperty(this, 'name', { value: name }); + } + }; + + return klass; +} + +export const InvalidStateError = createErrorClass('InvalidStateError'); diff --git a/app/lib/edge/ortcUtils.js b/app/lib/edge/ortcUtils.js new file mode 100644 index 0000000..a9f3aaa --- /dev/null +++ b/app/lib/edge/ortcUtils.js @@ -0,0 +1,458 @@ +/* global RTCRtpReceiver */ + +import sdpTransform from 'sdp-transform'; + +/** + * Extract RTP capabilities from remote description. + * @param {Object} sdpObject - Remote SDP object generated by sdp-transform. + * @return {RTCRtpCapabilities} + */ +export function extractCapabilities(sdpObject) { + // Map of RtpCodecParameters indexed by payload type. + const codecsMap = new Map(); + + // Array of RtpHeaderExtensions. + const headerExtensions = []; + + for (const m of sdpObject.media) { + // Media kind. + const kind = m.type; + + if (kind !== 'audio' && kind !== 'video') { + continue; // eslint-disable-line no-continue + } + + // Get codecs. + for (const rtp of m.rtp) { + const codec = { + clockRate: rtp.rate, + kind, + mimeType: `${kind}/${rtp.codec}`, + name: rtp.codec, + numChannels: rtp.encoding || 1, + parameters: {}, + preferredPayloadType: rtp.payload, + rtcpFeedback: [] + }; + + codecsMap.set(codec.preferredPayloadType, codec); + } + + // Get codec parameters. + for (const fmtp of m.fmtp || []) { + const parameters = sdpTransform.parseFmtpConfig(fmtp.config); + const codec = codecsMap.get(fmtp.payload); + + if (!codec) { + continue; // eslint-disable-line no-continue + } + + codec.parameters = parameters; + } + + // Get RTCP feedback for each codec. + for (const fb of m.rtcpFb || []) { + const codec = codecsMap.get(fb.payload); + + if (!codec) { + continue; // eslint-disable-line no-continue + } + + codec.rtcpFeedback.push({ + parameter: fb.subtype || '', + type: fb.type + }); + } + + // Get RTP header extensions. + for (const ext of m.ext || []) { + const preferredId = ext.value; + const uri = ext.uri; + const headerExtension = { + kind, + uri, + preferredId + }; + + // Check if already present. + const duplicated = headerExtensions.find(savedHeaderExtension => + headerExtension.kind === savedHeaderExtension.kind + && headerExtension.uri === savedHeaderExtension.uri + ); + + if (!duplicated) { + headerExtensions.push(headerExtension); + } + } + } + + return { + codecs: Array.from(codecsMap.values()), + fecMechanisms: [], // TODO + headerExtensions + }; +} + +/** + * Extract DTLS parameters from remote description. + * @param {Object} sdpObject - Remote SDP object generated by sdp-transform. + * @return {RTCDtlsParameters} + */ +export function extractDtlsParameters(sdpObject) { + const media = getFirstActiveMediaSection(sdpObject); + const fingerprint = media.fingerprint || sdpObject.fingerprint; + let role; + + switch (media.setup) { + case 'active': + role = 'client'; + break; + case 'passive': + role = 'server'; + break; + case 'actpass': + role = 'auto'; + break; + } + + return { + role, + fingerprints: [ + { + algorithm: fingerprint.type, + value: fingerprint.hash + } + ] + }; +} + +/** + * Extract ICE candidates from remote description. + * NOTE: This implementation assumes a single BUNDLEd transport and rtcp-mux. + * @param {Object} sdpObject - Remote SDP object generated by sdp-transform. + * @return {sequence} + */ +export function extractIceCandidates(sdpObject) { + const media = getFirstActiveMediaSection(sdpObject); + const candidates = []; + + for (const c of media.candidates) { + // Ignore RTCP candidates (we assume rtcp-mux). + if (c.component !== 1) { + continue; // eslint-disable-line no-continue + } + + const candidate = { + foundation: c.foundation, + ip: c.ip, + port: c.port, + priority: c.priority, + protocol: c.transport.toLowerCase(), + type: c.type + }; + + candidates.push(candidate); + } + + return candidates; +} + +/** + * Extract ICE parameters from remote description. + * NOTE: This implementation assumes a single BUNDLEd transport. + * @param {Object} sdpObject - Remote SDP object generated by sdp-transform. + * @return {RTCIceParameters} + */ +export function extractIceParameters(sdpObject) { + const media = getFirstActiveMediaSection(sdpObject); + const usernameFragment = media.iceUfrag; + const password = media.icePwd; + const icelite = sdpObject.icelite === 'ice-lite'; + + return { + icelite, + password, + usernameFragment + }; +} + +/** + * Extract MID values from remote description. + * @param {Object} sdpObject - Remote SDP object generated by sdp-transform. + * @return {map} Ordered Map with MID as key and kind as value. + */ +export function extractMids(sdpObject) { + const midToKind = new Map(); + + // Ignore disabled media sections. + for (const m of sdpObject.media) { + midToKind.set(m.mid, m.type); + } + + return midToKind; +} + +/** + * Extract tracks information. + * @param {Object} sdpObject - Remote SDP object generated by sdp-transform. + * @return {Map} + */ +export function extractTrackInfos(sdpObject) { + // Map with info about receiving media. + // - index: Media SSRC + // - value: Object + // - kind: 'audio' / 'video' + // - ssrc: Media SSRC + // - rtxSsrc: RTX SSRC (may be unset) + // - streamId: MediaStream.jitsiRemoteId + // - trackId: MediaStreamTrack.jitsiRemoteId + // - cname: CNAME + // @type {map} + const infos = new Map(); + + // Map with stream SSRC as index and associated RTX SSRC as value. + // @type {map} + const rtxMap = new Map(); + + // Set of RTX SSRC values. + const rtxSet = new Set(); + + for (const m of sdpObject.media) { + const kind = m.type; + + if (kind !== 'audio' && kind !== 'video') { + continue; // eslint-disable-line no-continue + } + + // Get RTX information. + for (const ssrcGroup of m.ssrcGroups || []) { + // Just consider FID. + if (ssrcGroup.semantics !== 'FID') { + continue; // eslint-disable-line no-continue + } + + const ssrcs + = ssrcGroup.ssrcs.split(' ').map(ssrc => Number(ssrc)); + const ssrc = ssrcs[0]; + const rtxSsrc = ssrcs[1]; + + rtxMap.set(ssrc, rtxSsrc); + rtxSet.add(rtxSsrc); + } + + for (const ssrcObject of m.ssrcs || []) { + const ssrc = ssrcObject.id; + + // Ignore RTX. + if (rtxSet.has(ssrc)) { + continue; // eslint-disable-line no-continue + } + + let info = infos.get(ssrc); + + if (!info) { + info = { + kind, + rtxSsrc: rtxMap.get(ssrc), + ssrc + }; + + infos.set(ssrc, info); + } + + switch (ssrcObject.attribute) { + case 'cname': { + info.cname = ssrcObject.value; + break; + } + case 'msid': { + const values = ssrcObject.value.split(' '); + const streamId = values[0]; + const trackId = values[1]; + + info.streamId = streamId; + info.trackId = trackId; + break; + } + case 'mslabel': { + const streamId = ssrcObject.value; + + info.streamId = streamId; + break; + } + case 'label': { + const trackId = ssrcObject.value; + + info.trackId = trackId; + break; + } + } + } + } + + return infos; +} + +/** + * Get local ORTC RTP capabilities filtered and adapted to the given remote RTP + * capabilities. + * @param {RTCRtpCapabilities} filterWithCapabilities - RTP capabilities to + * filter with. + * @return {RTCRtpCapabilities} + */ +export function getLocalCapabilities(filterWithCapabilities) { + const localFullCapabilities = RTCRtpReceiver.getCapabilities(); + const localCapabilities = { + codecs: [], + fecMechanisms: [], + headerExtensions: [] + }; + + // Map of RTX and codec payloads. + // - index: Codec payloadType + // - value: Associated RTX payloadType + // @type {map} + const remoteRtxMap = new Map(); + + // Set codecs. + for (const remoteCodec of filterWithCapabilities.codecs) { + const remoteCodecName = remoteCodec.name.toLowerCase(); + + if (remoteCodecName === 'rtx') { + remoteRtxMap.set( + remoteCodec.parameters.apt, remoteCodec.preferredPayloadType); + + continue; // eslint-disable-line no-continue + } + + const localCodec = localFullCapabilities.codecs.find(codec => + codec.name.toLowerCase() === remoteCodecName + && codec.kind === remoteCodec.kind + && codec.clockRate === remoteCodec.clockRate + ); + + if (!localCodec) { + continue; // eslint-disable-line no-continue + } + + const codec = { + clockRate: localCodec.clockRate, + kind: localCodec.kind, + mimeType: `${localCodec.kind}/${localCodec.name}`, + name: localCodec.name, + numChannels: localCodec.numChannels || 1, + parameters: {}, + preferredPayloadType: remoteCodec.preferredPayloadType, + rtcpFeedback: [] + }; + + for (const remoteParamName of Object.keys(remoteCodec.parameters)) { + const remoteParamValue + = remoteCodec.parameters[remoteParamName]; + + for (const localParamName of Object.keys(localCodec.parameters)) { + const localParamValue + = localCodec.parameters[localParamName]; + + if (localParamName !== remoteParamName) { + continue; // eslint-disable-line no-continue + } + + // TODO: We should consider much more cases here, but Edge + // does not support many codec parameters. + if (localParamValue === remoteParamValue) { + // Use this RTP parameter. + codec.parameters[localParamName] = localParamValue; + break; + } + } + } + + for (const remoteFb of remoteCodec.rtcpFeedback) { + const localFb = localCodec.rtcpFeedback.find(fb => + fb.type === remoteFb.type + && fb.parameter === remoteFb.parameter + ); + + if (localFb) { + // Use this RTCP feedback. + codec.rtcpFeedback.push(localFb); + } + } + + // Use this codec. + localCapabilities.codecs.push(codec); + } + + // Add RTX for video codecs. + for (const codec of localCapabilities.codecs) { + const payloadType = codec.preferredPayloadType; + + if (!remoteRtxMap.has(payloadType)) { + continue; // eslint-disable-line no-continue + } + + const rtxCodec = { + clockRate: codec.clockRate, + kind: codec.kind, + mimeType: `${codec.kind}/rtx`, + name: 'rtx', + parameters: { + apt: payloadType + }, + preferredPayloadType: remoteRtxMap.get(payloadType), + rtcpFeedback: [] + }; + + // Add RTX codec. + localCapabilities.codecs.push(rtxCodec); + } + + // Add RTP header extensions. + for (const remoteExtension of filterWithCapabilities.headerExtensions) { + const localExtension + = localFullCapabilities.headerExtensions.find(extension => + extension.kind === remoteExtension.kind + && extension.uri === remoteExtension.uri + ); + + if (localExtension) { + const extension = { + kind: localExtension.kind, + preferredEncrypt: Boolean(remoteExtension.preferredEncrypt), + preferredId: remoteExtension.preferredId, + uri: localExtension.uri + }; + + // Use this RTP header extension. + localCapabilities.headerExtensions.push(extension); + } + } + + // Add FEC mechanisms. + // NOTE: We don't support FEC yet and, in fact, neither does Edge. + for (const remoteFecMechanism of filterWithCapabilities.fecMechanisms) { + const localFecMechanism + = localFullCapabilities.fecMechanisms.find(fec => + fec === remoteFecMechanism + ); + + if (localFecMechanism) { + // Use this FEC mechanism. + localCapabilities.fecMechanisms.push(localFecMechanism); + } + } + + return localCapabilities; +} + +/** + * Get the first acive media section. + * @param {Object} sdpObject - SDP object generated by sdp-transform. + * @return {Object} SDP media section as parsed by sdp-transform. + */ +function getFirstActiveMediaSection(sdpObject) { + return sdpObject.media.find(m => + m.iceUfrag && m.port !== 0 + ); +} diff --git a/app/lib/index.jsx b/app/lib/index.jsx index 959c7a6..acb7d84 100644 --- a/app/lib/index.jsx +++ b/app/lib/index.jsx @@ -9,13 +9,26 @@ import ReactDOM from 'react-dom'; import injectTapEventPlugin from 'react-tap-event-plugin'; import randomString from 'random-string'; import Logger from './Logger'; -import utils from './utils'; +import * as utils from './utils'; +import edgeRTCPeerConnection from './edge/RTCPeerConnection'; +import edgeRTCSessionDescription from './edge/RTCSessionDescription'; import App from './components/App'; +// TODO: TMP +global.BROWSER = browser; + const REGEXP_FRAGMENT_ROOM_ID = new RegExp('^#room-id=([0-9a-zA-Z_\-]+)$'); const logger = new Logger(); -logger.debug('detected browser [name:"%s", version:%s]', browser.name, browser.version); +logger.warn('detected browser [name:"%s", version:%s]', browser.name, browser.version); + +if (browser.msedge) +{ + logger.warn('EDGE detected, overriding WebRTC global classes'); + + window.RTCPeerConnection = edgeRTCPeerConnection; + window.RTCSessionDescription = edgeRTCSessionDescription; +} injectTapEventPlugin(); diff --git a/app/lib/urlFactory.js b/app/lib/urlFactory.js index 818fc33..94593a8 100644 --- a/app/lib/urlFactory.js +++ b/app/lib/urlFactory.js @@ -2,14 +2,11 @@ const config = require('../config'); -module.exports = +export function getProtooUrl(peerId, roomId) { - getProtooUrl(peerId, roomId) - { - let hostname = window.location.hostname; - let port = config.protoo.listenPort; - let url = `wss://${hostname}:${port}/?peer-id=${peerId}&room-id=${roomId}`; + let hostname = window.location.hostname; + let port = config.protoo.listenPort; + let url = `wss://${hostname}:${port}/?peer-id=${peerId}&room-id=${roomId}`; - return url; - } -}; + return url; +} diff --git a/app/lib/utils.js b/app/lib/utils.js index 3b92897..8c0a6ad 100644 --- a/app/lib/utils.js +++ b/app/lib/utils.js @@ -1,52 +1,61 @@ 'use strict'; import browser from 'bowser'; +import randomNumberLib from 'random-number'; import Logger from './Logger'; const logger = new Logger('utils'); +const randomNumberGenerator = randomNumberLib.generator( + { + min : 10000000, + max : 99999999, + integer : true + }); let mediaQueryDetectorElem; -module.exports = +export function initialize() { - initialize() + logger.debug('initialize()'); + + // Media query detector stuff + mediaQueryDetectorElem = document.getElementById('mediasoup-demo-app-media-query-detector'); + + return Promise.resolve(); +} + +export function isDesktop() +{ + return !!mediaQueryDetectorElem.offsetParent; +} + +export function isMobile() +{ + return !mediaQueryDetectorElem.offsetParent; +} + +export function isPlanB() +{ + if (browser.chrome || browser.chromium || browser.opera || browser.msedge) + return true; + else + return false; +} + +export function randomNumber() +{ + return randomNumberGenerator(); +} + +export function closeMediaStream(stream) +{ + if (!stream) + return; + + let tracks = stream.getTracks(); + + for (let i=0, len=tracks.length; i < len; i++) { - logger.debug('initialize()'); - - // Media query detector stuff - mediaQueryDetectorElem = document.getElementById('mediasoup-demo-app-media-query-detector'); - - return Promise.resolve(); - }, - - isDesktop() - { - return !!mediaQueryDetectorElem.offsetParent; - }, - - isMobile() - { - return !mediaQueryDetectorElem.offsetParent; - }, - - isPlanB() - { - if (browser.chrome || browser.chromium || browser.opera) - return true; - else - return false; - }, - - closeMediaStream(stream) - { - if (!stream) - return; - - let tracks = stream.getTracks(); - - for (let i=0, len=tracks.length; i < len; i++) - { - tracks[i].stop(); - } + tracks[i].stop(); } -}; +} diff --git a/app/package.json b/app/package.json index fafcb0f..a515f5b 100644 --- a/app/package.json +++ b/app/package.json @@ -15,6 +15,7 @@ "hark": "ibc/hark#main-with-raf", "material-ui": "^0.17.4", "protoo-client": "^1.1.4", + "random-number": "0.0.7", "random-string": "^0.2.0", "react": "^15.5.4", "react-addons-css-transition-group": "^15.5.2", @@ -24,7 +25,8 @@ "react-tap-event-plugin": "^2.0.1", "sdp-transform": "^2.3.0", "url-parse": "^1.1.8", - "webrtc-adapter": "^3.3.3" + "webrtc-adapter": "^3.3.3", + "yaeti": "^1.0.1" }, "devDependencies": { "babel-plugin-transform-object-assign": "^6.22.0", diff --git a/server/package.json b/server/package.json index 605dc9c..d827b6e 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,7 @@ "colors": "^1.1.2", "debug": "^2.6.4", "express": "^4.15.2", - "mediasoup": "^1.0.1", + "mediasoup": "^1.2.3", "protoo-server": "^1.1.4" }, "devDependencies": {