From a0092b78ca7afcab2cdf317d58fd4db343ac2263 Mon Sep 17 00:00:00 2001 From: George Politis Date: Tue, 11 Nov 2014 15:46:13 +0100 Subject: [PATCH] Fixes desktop sharing when used with simulcast. --- index.html | 6 +- libs/colibri/colibri.focus.js | 336 ++++++++++++++++--------- libs/strophe/strophe.jingle.adapter.js | 6 +- libs/strophe/strophe.jingle.sdp.js | 14 +- simulcast.js | 73 +++--- 5 files changed, 279 insertions(+), 156 deletions(-) diff --git a/index.html b/index.html index ec31263ac..ebd29b67d 100644 --- a/index.html +++ b/index.html @@ -11,8 +11,8 @@ - - + + @@ -22,7 +22,7 @@ - + diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index 87b92ab1b..46c761203 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -551,82 +551,8 @@ ColibriFocus.prototype.createdConference = function (result) { console.log('setLocalDescription succeeded.'); // make sure our presence is updated $(document).trigger('setLocalDescription.jingle', [self.sid]); - var elem = $iq({to: self.bridgejid, type: 'get'}); - elem.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri', id: self.confid}); var localSDP = new SDP(self.peerconnection.localDescription.sdp); - localSDP.media.forEach(function (media, channel) { - var name = SDPUtil.parse_mid(SDPUtil.find_line(media, 'a=mid:')); - elem.c('content', {name: name}); - var mline = SDPUtil.parse_mline(media.split('\r\n')[0]); - if (name !== 'data') - { - elem.c('channel', { - initiator: 'true', - expire: self.channelExpire, - id: self.mychannel[channel].attr('id'), - endpoint: self.myMucResource - }); - - // signal (through COLIBRI) to the bridge - // the SSRC groups of the participant - // that plays the role of the focus - var ssrc_group_lines = SDPUtil.find_lines(media, 'a=ssrc-group:'); - var idx = 0; - ssrc_group_lines.forEach(function(line) { - idx = line.indexOf(' '); - var semantics = line.substr(0, idx).substr(13); - var ssrcs = line.substr(14 + semantics.length).split(' '); - if (ssrcs.length != 0) { - elem.c('ssrc-group', { semantics: semantics, xmlns: 'urn:xmpp:jingle:apps:rtp:ssma:0' }); - ssrcs.forEach(function(ssrc) { - elem.c('source', { ssrc: ssrc }) - .up(); - }); - elem.up(); - } - }); - // FIXME: should reuse code from .toJingle - for (var j = 0; j < mline.fmt.length; j++) - { - var rtpmap = SDPUtil.find_line(media, 'a=rtpmap:' + mline.fmt[j]); - if (rtpmap) - { - elem.c('payload-type', SDPUtil.parse_rtpmap(rtpmap)); - elem.up(); - } - } - } - else - { - var sctpmap = SDPUtil.find_line(media, 'a=sctpmap:' + mline.fmt[0]); - var sctpPort = SDPUtil.parse_sctpmap(sctpmap)[0]; - elem.c("sctpconnection", - { - initiator: 'true', - expire: self.channelExpire, - id: self.mychannel[channel].attr('id'), - endpoint: self.myMucResource, - port: sctpPort - } - ); - } - - localSDP.TransportToJingle(channel, elem); - - elem.up(); // end of channel - elem.up(); // end of content - }); - - self.connection.sendIQ(elem, - function (result) { - // ... - }, - function (error) { - console.error( - "ERROR sending colibri message", - error, elem); - } - ); + self.updateLocalChannel(localSDP); // now initiate sessions for (var i = 0; i < numparticipants; i++) { @@ -659,6 +585,96 @@ ColibriFocus.prototype.createdConference = function (result) { }; +ColibriFocus.prototype.updateLocalChannel = function(localSDP, parts) { + var self = this; + var elem = $iq({to: self.bridgejid, type: 'get'}); + elem.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri', id: self.confid}); + localSDP.media.forEach(function (media, channel) { + var name = SDPUtil.parse_mid(SDPUtil.find_line(media, 'a=mid:')); + elem.c('content', {name: name}); + var mline = SDPUtil.parse_mline(media.split('\r\n')[0]); + if (name !== 'data') { + elem.c('channel', { + initiator: 'true', + expire: self.channelExpire, + id: self.mychannel[channel].attr('id'), + endpoint: self.myMucResource + }); + + if (!parts || parts.indexOf('sources') !== -1) { + // signal (through COLIBRI) to the bridge + // the SSRC groups of the participant + // that plays the role of the focus + var ssrc_group_lines = SDPUtil.find_lines(media, 'a=ssrc-group:'); + var idx = 0; + var hasSIM = false; + ssrc_group_lines.forEach(function (line) { + idx = line.indexOf(' '); + var semantics = line.substr(0, idx).substr(13); + var ssrcs = line.substr(14 + semantics.length).split(' '); + if (ssrcs.length != 0) { + elem.c('ssrc-group', { semantics: semantics, xmlns: 'urn:xmpp:jingle:apps:rtp:ssma:0' }); + ssrcs.forEach(function (ssrc) { + elem.c('source', { ssrc: ssrc }) + .up(); + }); + elem.up(); + } + }); + + if (!hasSIM && name == 'video') { + // disable simulcast with an empty ssrc-group element. + elem.c('ssrc-group', { semantics: 'SIM', xmlns: 'urn:xmpp:jingle:apps:rtp:ssma:0' }); + elem.up(); + } + } + + if (!parts || parts.indexOf('payload-type') !== -1) { + // FIXME: should reuse code from .toJingle + for (var j = 0; j < mline.fmt.length; j++) { + var rtpmap = SDPUtil.find_line(media, 'a=rtpmap:' + mline.fmt[j]); + if (rtpmap) { + elem.c('payload-type', SDPUtil.parse_rtpmap(rtpmap)); + elem.up(); + } + } + } + } + else + { + var sctpmap = SDPUtil.find_line(media, 'a=sctpmap:' + mline.fmt[0]); + var sctpPort = SDPUtil.parse_sctpmap(sctpmap)[0]; + elem.c("sctpconnection", + { + initiator: 'true', + expire: self.channelExpire, + id: self.mychannel[channel].attr('id'), + endpoint: self.myMucResource, + port: sctpPort + } + ); + } + + if (!parts || parts.indexOf('transport') !== -1) { + localSDP.TransportToJingle(channel, elem); + } + + elem.up(); // end of channel + elem.up(); // end of content + }); + + self.connection.sendIQ(elem, + function (result) { + // ... + }, + function (error) { + console.error( + "ERROR sending colibri message", + error, elem); + } + ); +}; + // send a session-initiate to a new participant ColibriFocus.prototype.initiate = function (peer, isInitiator) { var participant = this.peers.indexOf(peer); @@ -894,61 +910,73 @@ ColibriFocus.prototype.addNewParticipant = function (peer) { }; // update the channel description (payload-types + dtls fp) for a participant -ColibriFocus.prototype.updateChannel = function (remoteSDP, participant) { +ColibriFocus.prototype.updateRemoteChannel = function (remoteSDP, participant, parts) { console.log('change allocation for', this.confid); var self = this; var change = $iq({to: this.bridgejid, type: 'set'}); change.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri', id: this.confid}); - for (channel = 0; channel < this.channels[participant].length; channel++) - { + for (channel = 0; channel < this.channels[participant].length; channel++) { if (!remoteSDP.media[channel]) continue; var name = SDPUtil.parse_mid(SDPUtil.find_line(remoteSDP.media[channel], 'a=mid:')); change.c('content', {name: name}); - if (name !== 'data') - { + if (name !== 'data') { change.c('channel', { id: $(this.channels[participant][channel]).attr('id'), endpoint: $(this.channels[participant][channel]).attr('endpoint'), expire: self.channelExpire }); - // signal (throught COLIBRI) to the bridge the SSRC groups of this - // participant - var ssrc_group_lines = SDPUtil.find_lines(remoteSDP.media[channel], 'a=ssrc-group:'); - var idx = 0; - ssrc_group_lines.forEach(function(line) { - idx = line.indexOf(' '); - var semantics = line.substr(0, idx).substr(13); - var ssrcs = line.substr(14 + semantics.length).split(' '); - if (ssrcs.length != 0) { - change.c('ssrc-group', { semantics: semantics, xmlns: 'urn:xmpp:jingle:apps:rtp:ssma:0' }); - ssrcs.forEach(function(ssrc) { - change.c('source', { ssrc: ssrc }) - .up(); - }); + if (!parts || parts.indexOf('sources') !== -1) { + // signal (throught COLIBRI) to the bridge the SSRC groups of this + // participant + var ssrc_group_lines = SDPUtil.find_lines(remoteSDP.media[channel], 'a=ssrc-group:'); + var idx = 0; + var hasSIM = false; + ssrc_group_lines.forEach(function (line) { + idx = line.indexOf(' '); + var semantics = line.substr(0, idx).substr(13); + if (semantics == 'SIM') { + hasSIM = true; + } + var ssrcs = line.substr(14 + semantics.length).split(' '); + if (ssrcs.length != 0) { + change.c('ssrc-group', { semantics: semantics, xmlns: 'urn:xmpp:jingle:apps:rtp:ssma:0' }); + ssrcs.forEach(function (ssrc) { + change.c('source', { ssrc: ssrc }) + .up(); + }); + change.up(); + } + }); + + if (!hasSIM && name == 'video') { + // disable simulcast with an empty ssrc-group element. + change.c('ssrc-group', { semantics: 'SIM', xmlns: 'urn:xmpp:jingle:apps:rtp:ssma:0' }); change.up(); } - }); + } - var rtpmap = SDPUtil.find_lines(remoteSDP.media[channel], 'a=rtpmap:'); - rtpmap.forEach(function (val) { - // TODO: too much copy-paste - var rtpmap = SDPUtil.parse_rtpmap(val); - change.c('payload-type', rtpmap); - // - // put any 'a=fmtp:' + mline.fmt[j] lines into - /* - if (SDPUtil.find_line(remoteSDP.media[channel], 'a=fmtp:' + rtpmap.id)) { - tmp = SDPUtil.parse_fmtp(SDPUtil.find_line(remoteSDP.media[channel], 'a=fmtp:' + rtpmap.id)); - for (var k = 0; k < tmp.length; k++) { - change.c('parameter', tmp[k]).up(); - } - } - */ - change.up(); - }); + if (!parts || parts.indexOf('payload-type') !== -1) { + var rtpmap = SDPUtil.find_lines(remoteSDP.media[channel], 'a=rtpmap:'); + rtpmap.forEach(function (val) { + // TODO: too much copy-paste + var rtpmap = SDPUtil.parse_rtpmap(val); + change.c('payload-type', rtpmap); + // + // put any 'a=fmtp:' + mline.fmt[j] lines into + /* + if (SDPUtil.find_line(remoteSDP.media[channel], 'a=fmtp:' + rtpmap.id)) { + tmp = SDPUtil.parse_fmtp(SDPUtil.find_line(remoteSDP.media[channel], 'a=fmtp:' + rtpmap.id)); + for (var k = 0; k < tmp.length; k++) { + change.c('parameter', tmp[k]).up(); + } + } + */ + change.up(); + }); + } } else { @@ -960,8 +988,11 @@ ColibriFocus.prototype.updateChannel = function (remoteSDP, participant) { port: SDPUtil.parse_sctpmap(sctpmap)[0] }); } - // now add transport - remoteSDP.TransportToJingle(channel, change); + + if (!parts || parts.indexOf('transport') !== -1) { + // now add transport + remoteSDP.TransportToJingle(channel, change); + } change.up(); // end of channel/sctpconnection change.up(); // end of content @@ -976,6 +1007,57 @@ ColibriFocus.prototype.updateChannel = function (remoteSDP, participant) { ); }; +/** + * Switches video streams. + * @param new_stream new stream that will be used as video of this session. + * @param oldStream old video stream of this session. + * @param success_callback callback executed after successful stream switch. + */ +ColibriFocus.prototype.switchStreams = function (new_stream, oldStream, success_callback) { + + var self = this; + + // Stop the stream to trigger onended event for old stream + oldStream.stop(); + + // Remember SDP to figure out added/removed SSRCs + var oldSdp = null; + if(self.peerconnection) { + if(self.peerconnection.localDescription) { + oldSdp = new SDP(self.peerconnection.localDescription.sdp); + } + self.peerconnection.removeStream(oldStream); + self.peerconnection.addStream(new_stream); + } + + self.connection.jingle.localVideo = new_stream; + + self.connection.jingle.localStreams = []; + self.connection.jingle.localStreams.push(self.connection.jingle.localAudio); + self.connection.jingle.localStreams.push(self.connection.jingle.localVideo); + + // Conference is not active + if(!oldSdp || !self.peerconnection) { + success_callback(); + return; + } + + self.peerconnection.switchstreams = true; + self.modifySources(function() { + console.log('modify sources done'); + + var newSdp = new SDP(self.peerconnection.localDescription.sdp); + + // change allocation on bridge + self.updateLocalChannel(newSdp, ['sources']); + + console.log("SDPs", oldSdp, newSdp); + self.notifyMySSRCUpdate(oldSdp, newSdp); + + success_callback(); + }); +}; + // tell everyone about a new participants a=ssrc lines (isadd is true) // or a leaving participants a=ssrc lines ColibriFocus.prototype.sendSSRCUpdate = function (sdpMediaSsrcs, fromJid, isadd) { @@ -1010,7 +1092,7 @@ ColibriFocus.prototype.addSource = function (elem, fromJid) { // FIXME: dirty waiting if (!this.peerconnection.localDescription) { - console.warn("addSource - localDescription not ready yet") + console.warn("addSource - localDescription not ready yet"); setTimeout(function() { self.addSource(elem, fromJid); }, 200); return; } @@ -1031,11 +1113,21 @@ ColibriFocus.prototype.addSource = function (elem, fromJid) { }); var oldRemoteSdp = new SDP(this.peerconnection.remoteDescription.sdp); - this.modifySources(function(){ + this.modifySources(function() { // Notify other participants about added ssrc var remoteSDP = new SDP(self.peerconnection.remoteDescription.sdp); var newSSRCs = oldRemoteSdp.getNewMedia(remoteSDP); self.sendSSRCUpdate(newSSRCs, fromJid, true); + // change allocation on bridge + if (peerSsrc[1] /* video */) { + // If the remote peer has changed its video sources, then we need to + // update the bridge with this information, in order for the + // simulcast manager of the remote peer to update its layers, and + // any associated receivers to adjust to the change. + var videoSDP = new SDP(['v=0', 'm=audio', 'a=mid:audio', peerSsrc[0]].join('\r\n') + ['m=video', 'a=mid:video', peerSsrc[1]].join('\r\n')); + var participant = self.peers.indexOf(fromJid); + self.updateRemoteChannel(videoSDP, participant, ['sources']); + } }); }; @@ -1073,6 +1165,16 @@ ColibriFocus.prototype.removeSource = function (elem, fromJid) { var remoteSDP = new SDP(self.peerconnection.remoteDescription.sdp); var removedSSRCs = remoteSDP.getNewMedia(oldSDP); self.sendSSRCUpdate(removedSSRCs, fromJid, false); + // change allocation on bridge + if (peerSsrc[1] /* video */) { + // If the remote peer has changed its video sources, then we need to + // update the bridge with this information, in order for the + // simulcast manager of the remote peer to update its layers, and + // any associated receivers to adjust to the change. + var videoSDP = new SDP(['v=0', 'm=audio', 'a=mid:audio', peerSsrc[0]].join('\r\n') + ['m=video', 'a=mid:video', peerSsrc[1]].join('\r\n')); + var participant = self.peers.indexOf(fromJid); + self.updateRemoteChannel(videoSDP, participant, ['sources']); + } }); }; @@ -1084,7 +1186,7 @@ ColibriFocus.prototype.setRemoteDescription = function (session, elem, desctype) remoteSDP.fromJingle(elem); // ACT 1: change allocation on bridge - this.updateChannel(remoteSDP, participant); + this.updateRemoteChannel(remoteSDP, participant); // ACT 2: tell anyone else about the new SSRCs this.sendSSRCUpdate(remoteSDP.getMediaSsrcMap(), session.peerjid, true); diff --git a/libs/strophe/strophe.jingle.adapter.js b/libs/strophe/strophe.jingle.adapter.js index f36cb1752..d0f4847f6 100644 --- a/libs/strophe/strophe.jingle.adapter.js +++ b/libs/strophe/strophe.jingle.adapter.js @@ -140,11 +140,13 @@ if (TraceablePeerConnection.prototype.__defineGetter__ !== undefined) { TraceablePeerConnection.prototype.addStream = function (stream) { this.trace('addStream', stream.id); + simulcast.resetSender(); this.peerconnection.addStream(stream); }; TraceablePeerConnection.prototype.removeStream = function (stream) { this.trace('removeStream', stream.id); + simulcast.resetSender(); this.peerconnection.removeStream(stream); }; @@ -204,7 +206,7 @@ TraceablePeerConnection.prototype.enqueueAddSsrc = function(channel, ssrcLines) this.addssrc[channel] = ''; } this.addssrc[channel] += ssrcLines; -} +}; TraceablePeerConnection.prototype.addSource = function (elem) { console.log('addssrc', new Date().getTime()); @@ -260,7 +262,7 @@ TraceablePeerConnection.prototype.enqueueRemoveSsrc = function(channel, ssrcLine this.removessrc[channel] = ''; } this.removessrc[channel] += ssrcLines; -} +}; TraceablePeerConnection.prototype.removeSource = function (elem) { console.log('removessrc', new Date().getTime()); diff --git a/libs/strophe/strophe.jingle.sdp.js b/libs/strophe/strophe.jingle.sdp.js index af99e30e2..a185aebb5 100644 --- a/libs/strophe/strophe.jingle.sdp.js +++ b/libs/strophe/strophe.jingle.sdp.js @@ -42,7 +42,7 @@ SDP.prototype.getMediaSsrcMap = function() { }); } return media_ssrcs; -} +}; /** * Returns true if this SDP contains given SSRC. * @param ssrc the ssrc to check. @@ -59,7 +59,8 @@ SDP.prototype.containsSSRC = function(ssrc) { } }); return contains; -} +}; + /** * Returns map of MediaChannel that contains only media not contained in otherSdp. Mapped by channel idx. * @param otherSdp the other SDP to check ssrc with. @@ -89,7 +90,7 @@ SDP.prototype.getNewMedia = function(otherSdp) { } } return true; - }; + } var myMedia = this.getMediaSsrcMap(); var othersMedia = otherSdp.getMediaSsrcMap(); @@ -111,7 +112,7 @@ SDP.prototype.getNewMedia = function(otherSdp) { } newMedia[channelNum].ssrcs[ssrc] = othersChannel.ssrcs[ssrc]; } - }) + }); // Look for new ssrc groups across the channels othersChannel.ssrcGroups.forEach(function(otherSsrcGroup){ @@ -120,7 +121,7 @@ SDP.prototype.getNewMedia = function(otherSdp) { var matched = false; for (var i = 0; i < myChannel.ssrcGroups.length; i++) { var mySsrcGroup = myChannel.ssrcGroups[i]; - if (otherSsrcGroup.semantics == mySsrcGroup + if (otherSsrcGroup.semantics == mySsrcGroup.semantics && arrayEquals.apply(otherSsrcGroup.ssrcs, [mySsrcGroup.ssrcs])) { matched = true; @@ -140,7 +141,8 @@ SDP.prototype.getNewMedia = function(otherSdp) { }); }); return newMedia; -} +}; + // remove iSAC and CN from SDP SDP.prototype.mangle = function () { var i, j, mline, lines, rtpmap, newdesc; diff --git a/simulcast.js b/simulcast.js index 7bccfe226..860b9fa43 100644 --- a/simulcast.js +++ b/simulcast.js @@ -490,7 +490,6 @@ function SimulcastSender() { this.logger = new SimulcastLogger('SimulcastSender', 1); } -SimulcastSender.prototype._localVideoSourceCache = ''; SimulcastSender.prototype.displayedLocalVideoStream = null; SimulcastSender.prototype._generateGuid = (function () { @@ -506,14 +505,6 @@ SimulcastSender.prototype._generateGuid = (function () { }; }()); -SimulcastSender.prototype._cacheLocalVideoSources = function (lines) { - this._localVideoSourceCache = this.simulcastUtils._getVideoSources(lines); -}; - -SimulcastSender.prototype._restoreLocalVideoSources = function (lines) { - this.simulcastUtils._replaceVideoSources(lines, this._localVideoSourceCache); -}; - // Returns a random integer between min (included) and max (excluded) // Using Math.round() gives a non-uniform distribution! SimulcastSender.prototype._generateRandomSSRC = function () { @@ -521,7 +512,37 @@ SimulcastSender.prototype._generateRandomSSRC = function () { return Math.floor(Math.random() * (max - min)) + min; }; -SimulcastSender.prototype._appendSimulcastGroup = function (lines) { +SimulcastSender.prototype.getLocalVideoStream = function () { + return (this.displayedLocalVideoStream != null) + ? this.displayedLocalVideoStream + // in case we have no simulcast at all, i.e. we didn't perform the GUM + : connection.jingle.localVideo; +}; + +function NativeSimulcastSender() { + SimulcastSender.call(this); // call the super constructor. +} + +NativeSimulcastSender.prototype = Object.create(SimulcastSender.prototype); + +NativeSimulcastSender.prototype._localExplosionMap = {}; +NativeSimulcastSender.prototype._isUsingScreenStream = false; +NativeSimulcastSender.prototype._localVideoSourceCache = ''; + +NativeSimulcastSender.prototype.reset = function () { + this._localExplosionMap = {}; + this._isUsingScreenStream = isUsingScreenStream; +}; + +NativeSimulcastSender.prototype._cacheLocalVideoSources = function (lines) { + this._localVideoSourceCache = this.simulcastUtils._getVideoSources(lines); +}; + +NativeSimulcastSender.prototype._restoreLocalVideoSources = function (lines) { + this.simulcastUtils._replaceVideoSources(lines, this._localVideoSourceCache); +}; + +NativeSimulcastSender.prototype._appendSimulcastGroup = function (lines) { var videoSources, ssrcGroup, simSSRC, numOfSubs = 2, i, sb, msid; this.logger.info('Appending simulcast group...'); @@ -557,7 +578,7 @@ SimulcastSender.prototype._appendSimulcastGroup = function (lines) { }; // Does the actual patching. -SimulcastSender.prototype._ensureSimulcastGroup = function (lines) { +NativeSimulcastSender.prototype._ensureSimulcastGroup = function (lines) { this.logger.info('Ensuring simulcast group...'); @@ -571,21 +592,6 @@ SimulcastSender.prototype._ensureSimulcastGroup = function (lines) { } }; -SimulcastSender.prototype.getLocalVideoStream = function () { - return (this.displayedLocalVideoStream != null) - ? this.displayedLocalVideoStream - // in case we have no simulcast at all, i.e. we didn't perform the GUM - : connection.jingle.localVideo; -}; - -function NativeSimulcastSender() { - SimulcastSender.call(this); // call the super constructor. -} - -NativeSimulcastSender.prototype = Object.create(SimulcastSender.prototype); - -NativeSimulcastSender.prototype._localExplosionMap = {}; - /** * Produces a single stream with multiple tracks for local video sources. * @@ -658,7 +664,7 @@ NativeSimulcastSender.prototype.getUserMedia = function (constraints, success, e NativeSimulcastSender.prototype.reverseTransformLocalDescription = function (desc) { var sb; - if (!desc || desc == null) { + if (!desc || desc == null || this._isUsingScreenStream) { return desc; } @@ -686,6 +692,10 @@ NativeSimulcastSender.prototype.reverseTransformLocalDescription = function (des */ NativeSimulcastSender.prototype.transformAnswer = function (desc) { + if (!desc || desc == null || this._isUsingScreenStream) { + return desc; + } + var sb = desc.sdp.split('\r\n'); // Even if we have enabled native simulcasting previously @@ -734,7 +744,8 @@ SimulcastReceiver.prototype.transformRemoteDescription = function (desc) { this._updateRemoteMaps(sb); this._cacheRemoteVideoSources(sb); - // NOTE(gp) this needs to be called after updateRemoteMaps because we need the simulcast group in the _updateRemoteMaps() method. + // NOTE(gp) this needs to be called after updateRemoteMaps because we + // need the simulcast group in the _updateRemoteMaps() method. this.simulcastUtils._removeSimulcastGroup(sb); if (desc.sdp.indexOf('a=ssrc-group:SIM') !== -1) { @@ -1189,6 +1200,12 @@ SimulcastManager.prototype._setLocalVideoStreamEnabled = function(ssrc, enabled) this.simulcastSender._setLocalVideoStreamEnabled(ssrc, enabled); }; +SimulcastManager.prototype.resetSender = function() { + if (typeof this.simulcastSender.reset === 'function'){ + this.simulcastSender.reset(); + } +}; + /** * * @constructor