From 0509b8e3c483a849d67f56f5080fd90a2169c68c Mon Sep 17 00:00:00 2001 From: paweldomas Date: Thu, 8 May 2014 11:26:15 +0200 Subject: [PATCH 1/6] Adds SCTP data channels. --- app.js | 17 +- config.js | 3 +- data_channels.js | 63 +++++ index.html | 1 + libs/colibri/colibri.focus.js | 356 ++++++++++++++++++------ libs/strophe/strophe.jingle.adapter.js | 8 +- libs/strophe/strophe.jingle.sdp.js | 56 +++- libs/strophe/strophe.jingle.sdp.util.js | 10 + 8 files changed, 420 insertions(+), 94 deletions(-) create mode 100644 data_channels.js diff --git a/app.js b/app.js index db471bd9b..8584d50d5 100644 --- a/app.js +++ b/app.js @@ -470,6 +470,12 @@ $(document).bind('callincoming.jingle', function (event, sid) { // TODO: do we check activecall == null? activecall = sess; + // Bind data channel listener in case we're a regular participant + if (config.openSctp) + { + bindDataChannelListener(sess.peerconnection); + } + // TODO: check affiliation and/or role console.log('emuc data for', sess.peerjid, connection.emuc.members[sess.peerjid]); sess.usedrip = true; // not-so-naive trickle ice @@ -478,6 +484,15 @@ $(document).bind('callincoming.jingle', function (event, sid) { }); +$(document).bind('conferenceCreated.jingle', function (event, focus) +{ + // Bind data channel listener in case we're the focus + if (config.openSctp) + { + bindDataChannelListener(focus.peerconnection); + } +}); + $(document).bind('callactive.jingle', function (event, videoelem, sid) { if (videoelem.attr('id').indexOf('mixedmslabel') === -1) { // ignore mixedmslabela0 and v0 @@ -507,7 +522,7 @@ $(document).bind('setLocalDescription.jingle', function (event, sid) { var directions = {}; var localSDP = new SDP(sess.peerconnection.localDescription.sdp); localSDP.media.forEach(function (media) { - var type = SDPUtil.parse_mline(media.split('\r\n')[0]).media; + var type = SDPUtil.parse_mid(SDPUtil.find_line(media, 'a=mid:')); if (SDPUtil.find_line(media, 'a=ssrc:')) { // assumes a single local ssrc diff --git a/config.js b/config.js index be326bf83..c34a2bfd5 100644 --- a/config.js +++ b/config.js @@ -11,5 +11,6 @@ var config = { bosh: '//lambada.jitsi.net/http-bind', // FIXME: use xep-0156 for that desktopSharing: 'ext', // Desktop sharing method. Can be set to 'ext', 'webrtc' or false to disable. chromeExtensionId: 'diibjkoicjeejcmhdnailmkgecihlobk', // Id of desktop streamer Chrome extension - minChromeExtVersion: '0.1' // Required version of Chrome extension + minChromeExtVersion: '0.1', // Required version of Chrome extension + openSctp: true //Toggle to enable/disable SCTP channels }; \ No newline at end of file diff --git a/data_channels.js b/data_channels.js new file mode 100644 index 000000000..38d70741e --- /dev/null +++ b/data_channels.js @@ -0,0 +1,63 @@ +/** + * Callback triggered by PeerConnection when new data channel is opened + * on the bridge. + * @param event the event info object. + */ +function onDataChannel(event) +{ + var dataChannel = event.channel; + + dataChannel.onopen = function () + { + console.info("Data channel opened by the bridge !!!", dataChannel); + + // Sends String message to the bridge + dataChannel.send("Hello bridge!"); + + // Sends 12 bytes binary message to the bridge + dataChannel.send(new ArrayBuffer(12)); + }; + + dataChannel.onerror = function (error) + { + console.error("Data Channel Error:", error, dataChannel); + }; + + dataChannel.onmessage = function (event) + { + var msgData = event.data; + console.info("Got Data Channel Message:", msgData, dataChannel); + }; + + dataChannel.onclose = function () + { + console.info("The Data Channel closed", dataChannel); + }; +} + +/** + * Binds "ondatachannel" event listener to given PeerConnection instance. + * @param peerConnection WebRTC peer connection instance. + */ +function bindDataChannelListener(peerConnection) +{ + peerConnection.ondatachannel = onDataChannel; + + // Sample code for opening new data channel from Jitsi Meet to the bridge. + // Although it's not a requirement to open separate channels from both bridge + // and peer as single channel can be used for sending and receiving data. + // So either channel opened by the bridge or the one opened here is enough + // for communication with the bridge. + var dataChannelOptions = + { + reliable: true + }; + var dataChannel + = peerConnection.createDataChannel("myChannel", dataChannelOptions); + + // Can be used only when is in open state + dataChannel.onopen = function () + { + dataChannel.send("My channel !!!"); + }; +} \ No newline at end of file diff --git a/index.html b/index.html index d323dd1de..b11b1e23f 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,7 @@ + diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index 7643f4a24..2fac9aaf7 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -44,8 +44,21 @@ function ColibriFocus(connection, bridgejid) { this.peers = []; this.confid = null; + /** + * Default channel expire value in seconds. + * @type {number} + */ + this.channelExpire = 60; + // media types of the conference - this.media = ['audio', 'video']; + if (config.openSctp) + { + this.media = ['audio', 'video', 'data']; + } + else + { + this.media = ['audio', 'video']; + } this.connection.jingle.sessions[this.sid] = this; this.mychannel = []; @@ -151,17 +164,29 @@ ColibriFocus.prototype._makeConference = function () { elem.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri'}); this.media.forEach(function (name) { + var isData = name === 'data'; + var channel = isData ? 'sctpconnection' : 'channel'; + elem.c('content', {name: name}); - elem.c('channel', { + + elem.c(channel, { initiator: 'true', expire: '15', - endpoint: 'fix_me_focus_endpoint'}).up(); + endpoint: 'fix_me_focus_endpoint' + }); + if (isData) + elem.attrs({port: 5000}); + elem.up();// end of channel + for (var j = 0; j < self.peers.length; j++) { - elem.c('channel', { + elem.c(channel, { initiator: 'true', expire: '15', endpoint: self.peers[j].substr(1 + self.peers[j].lastIndexOf('/')) - }).up(); + }); + if (isData) + elem.attrs({port: 5000}); + elem.up(); // end of channel } elem.up(); // end of content }); @@ -209,8 +234,13 @@ ColibriFocus.prototype.createdConference = function (result) { this.confid = $(result).find('>conference').attr('id'); var remotecontents = $(result).find('>conference>content').get(); var numparticipants = 0; - for (var i = 0; i < remotecontents.length; i++) { - tmp = $(remotecontents[i]).find('>channel').get(); + for (var i = 0; i < remotecontents.length; i++) + { + var contentName = $(remotecontents[i]).attr('name'); + var channelName + = contentName !== 'data' ? '>channel' : '>sctpconnection'; + + tmp = $(remotecontents[i]).find(channelName).get(); this.mychannel.push($(tmp.shift())); numparticipants = tmp.length; for (j = 0; j < tmp.length; j++) { @@ -223,7 +253,55 @@ ColibriFocus.prototype.createdConference = function (result) { console.log('remote channels', this.channels); - var bridgeSDP = new SDP('v=0\r\no=- 5151055458874951233 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=audio 1 RTP/SAVPF 111 103 104 0 8 106 105 13 126\r\nc=IN IP4 0.0.0.0\r\na=rtcp:1 IN IP4 0.0.0.0\r\na=mid:audio\r\na=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=sendrecv\r\na=rtpmap:111 opus/48000/2\r\na=fmtp:111 minptime=10\r\na=rtpmap:103 ISAC/16000\r\na=rtpmap:104 ISAC/32000\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:106 CN/32000\r\na=rtpmap:105 CN/16000\r\na=rtpmap:13 CN/8000\r\na=rtpmap:126 telephone-event/8000\r\na=maxptime:60\r\nm=video 1 RTP/SAVPF 100 116 117\r\nc=IN IP4 0.0.0.0\r\na=rtcp:1 IN IP4 0.0.0.0\r\na=mid:video\r\na=extmap:2 urn:ietf:params:rtp-hdrext:toffset\r\na=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=sendrecv\r\na=rtpmap:100 VP8/90000\r\na=rtcp-fb:100 ccm fir\r\na=rtcp-fb:100 nack\r\na=rtcp-fb:100 goog-remb\r\na=rtpmap:116 red/90000\r\na=rtpmap:117 ulpfec/90000\r\n'); + // Notify that the focus has created the conference on the bridge + $(document).trigger('conferenceCreated.jingle', [self]); + + var bridgeSDP = new SDP( + 'v=0\r\n' + + 'o=- 5151055458874951233 2 IN IP4 127.0.0.1\r\n' + + 's=-\r\n' + + 't=0 0\r\n' + + /* Audio */ + 'm=audio 1 RTP/SAVPF 111 103 104 0 8 106 105 13 126\r\n' + + 'c=IN IP4 0.0.0.0\r\n' + + 'a=rtcp:1 IN IP4 0.0.0.0\r\n' + + 'a=mid:audio\r\n' + + 'a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n' + + 'a=sendrecv\r\n' + + 'a=rtpmap:111 opus/48000/2\r\n' + + 'a=fmtp:111 minptime=10\r\n' + + 'a=rtpmap:103 ISAC/16000\r\n' + + 'a=rtpmap:104 ISAC/32000\r\n' + + 'a=rtpmap:0 PCMU/8000\r\n' + + 'a=rtpmap:8 PCMA/8000\r\n' + + 'a=rtpmap:106 CN/32000\r\n' + + 'a=rtpmap:105 CN/16000\r\n' + + 'a=rtpmap:13 CN/8000\r\n' + + 'a=rtpmap:126 telephone-event/8000\r\n' + + 'a=maxptime:60\r\n' + + /* Video */ + 'm=video 1 RTP/SAVPF 100 116 117\r\n' + + 'c=IN IP4 0.0.0.0\r\n' + + 'a=rtcp:1 IN IP4 0.0.0.0\r\n' + + 'a=mid:video\r\n' + + 'a=extmap:2 urn:ietf:params:rtp-hdrext:toffset\r\n' + + 'a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n' + + 'a=sendrecv\r\n' + + 'a=rtpmap:100 VP8/90000\r\n' + + 'a=rtcp-fb:100 ccm fir\r\n' + + 'a=rtcp-fb:100 nack\r\n' + + 'a=rtcp-fb:100 goog-remb\r\n' + + 'a=rtpmap:116 red/90000\r\n' + + 'a=rtpmap:117 ulpfec/90000\r\n' + + /* Data SCTP */ + (config.openSctp ? + 'm=application 1 DTLS/SCTP 5000\r\n' + + 'c=IN IP4 0.0.0.0\r\n' + + 'a=sctpmap:5000 webrtc-datachannel\r\n' + + 'a=mid:data\r\n' + : '') + ); + bridgeSDP.media.length = this.mychannel.length; var channel; /* @@ -262,12 +340,17 @@ ColibriFocus.prototype.createdConference = function (result) { // get the mixed ssrc tmp = $(this.mychannel[channel]).find('>source[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]'); // FIXME: check rtp-level-relay-type - if (tmp.length) { + + var isData = bridgeSDP.media[channel].indexOf('application') !== -1; + if (!isData && tmp.length) + { bridgeSDP.media[channel] += 'a=ssrc:' + tmp.attr('ssrc') + ' ' + 'cname:mixed' + '\r\n'; bridgeSDP.media[channel] += 'a=ssrc:' + tmp.attr('ssrc') + ' ' + 'label:mixedlabela0' + '\r\n'; bridgeSDP.media[channel] += 'a=ssrc:' + tmp.attr('ssrc') + ' ' + 'msid:mixedmslabel mixedlabela0' + '\r\n'; bridgeSDP.media[channel] += 'a=ssrc:' + tmp.attr('ssrc') + ' ' + 'mslabel:mixedmslabel' + '\r\n'; - } else { + } + else if (!isData) + { // make chrome happy... '3735928559' == 0xDEADBEEF // FIXME: this currently appears as two streams, should be one bridgeSDP.media[channel] += 'a=ssrc:' + '3735928559' + ' ' + 'cname:mixed' + '\r\n'; @@ -308,21 +391,41 @@ ColibriFocus.prototype.createdConference = function (result) { 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_mline(media.split('\r\n')[0]).media; + var name = SDPUtil.parse_mid(SDPUtil.find_line(media, 'a=mid:')); elem.c('content', {name: name}); - elem.c('channel', { - initiator: 'true', - expire: '15', - id: self.mychannel[channel].attr('id'), - endpoint: 'fix_me_focus_endpoint' - }); - - // FIXME: should reuse code from .toJingle var mline = SDPUtil.parse_mline(media.split('\r\n')[0]); - for (var j = 0; j < mline.fmt.length; j++) { - var rtpmap = SDPUtil.find_line(media, 'a=rtpmap:' + mline.fmt[j]); - elem.c('payload-type', SDPUtil.parse_rtpmap(rtpmap)); - elem.up(); + if (name !== 'data') + { + elem.c('channel', { + initiator: 'true', + expire: self.channelExpire, + id: self.mychannel[channel].attr('id'), + endpoint: 'fix_me_focus_endpoint' + }); + + // 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); + elem.c("sctpconnection", + { + initiator: 'true', + expire: self.channelExpire, + endpoint: 'fix_me_focus_endpoint', + port: sctpPort + } + ); } localSDP.TransportToJingle(channel, elem); @@ -336,7 +439,9 @@ ColibriFocus.prototype.createdConference = function (result) { // ... }, function (error) { - console.warn(error); + console.error( + "ERROR setLocalDescription succeded", + error, elem); } ); @@ -417,7 +522,10 @@ ColibriFocus.prototype.initiate = function (peer, isInitiator) { sdp.media[j] += 'a=ssrc:' + tmp.attr('ssrc') + ' ' + 'label:mixedlabela0' + '\r\n'; sdp.media[j] += 'a=ssrc:' + tmp.attr('ssrc') + ' ' + 'msid:mixedmslabel mixedlabela0' + '\r\n'; sdp.media[j] += 'a=ssrc:' + tmp.attr('ssrc') + ' ' + 'mslabel:mixedmslabel' + '\r\n'; - } else { + } + // No SSRCs for 'data', comes when j == 2 + else if (j < 2) + { // make chrome happy... '3735928559' == 0xDEADBEEF sdp.media[j] += 'a=ssrc:' + '3735928559' + ' ' + 'cname:mixed' + '\r\n'; sdp.media[j] += 'a=ssrc:' + '3735928559' + ' ' + 'label:mixedlabelv0' + '\r\n'; @@ -486,9 +594,17 @@ ColibriFocus.prototype.initiate = function (peer, isInitiator) { // pull in a new participant into the conference ColibriFocus.prototype.addNewParticipant = function (peer) { var self = this; - if (this.confid === 0) { + if (this.confid === 0 || !this.peerconnection.localDescription) + { // bad state - console.log('confid does not exist yet, postponing', peer); + if (this.confid === 0) + { + console.error('confid does not exist yet, postponing', peer); + } + else + { + console.error('local description not ready yet, postponing', peer); + } window.setTimeout(function () { self.addNewParticipant(peer); }, 250); @@ -502,14 +618,26 @@ ColibriFocus.prototype.addNewParticipant = function (peer) { elem.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri', id: this.confid}); var localSDP = new SDP(this.peerconnection.localDescription.sdp); localSDP.media.forEach(function (media, channel) { - var name = SDPUtil.parse_mline(media.split('\r\n')[0]).media; + var name = SDPUtil.parse_mid(SDPUtil.find_line(media, 'a=mid:')); elem.c('content', {name: name}); - elem.c('channel', { + if (name !== 'data') + { + elem.c('channel', { initiator: 'true', - expire:'15', + expire: self.channelExpire, endpoint: peer.substr(1 + peer.lastIndexOf('/')) - }); - elem.up(); // end of channel + }); + } + else + { + elem.c('sctpconnection', { + endpoint: peer.substr(1 + peer.lastIndexOf('/')), + initiator: 'true', + expire: self.channelExpire, + port: 5000 + }); + } + elem.up(); // end of channel/sctpconnection elem.up(); // end of content }); @@ -517,7 +645,15 @@ ColibriFocus.prototype.addNewParticipant = function (peer) { function (result) { var contents = $(result).find('>conference>content').get(); for (var i = 0; i < contents.length; i++) { - tmp = $(contents[i]).find('>channel').get(); + var channelXml = $(contents[i]).find('>channel'); + if (channelXml.length) + { + tmp = channelXml.get(); + } + else + { + tmp = $(contents[i]).find('>sctpconnection').get(); + } self.channels[index][i] = tmp[0]; } self.initiate(peer, true); @@ -531,37 +667,52 @@ ColibriFocus.prototype.addNewParticipant = function (peer) { // update the channel description (payload-types + dtls fp) for a participant ColibriFocus.prototype.updateChannel = function (remoteSDP, participant) { 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++) { - change.c('content', {name: channel === 0 ? 'audio' : 'video'}); - change.c('channel', { - id: $(this.channels[participant][channel]).attr('id'), - endpoint: $(this.channels[participant][channel]).attr('endpoint'), - expire: '15' - }); + for (channel = 0; channel < this.channels[participant].length; channel++) + { + var name = SDPUtil.parse_mid(SDPUtil.find_line(remoteSDP.media[channel], 'a=mid:')); + change.c('content', {name: name}); + if (name !== 'data') + { + change.c('channel', { + id: $(this.channels[participant][channel]).attr('id'), + endpoint: $(this.channels[participant][channel]).attr('endpoint'), + expire: self.channelExpire + }); - 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(); + 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(); - }); + */ + change.up(); + }); + } + else + { + var sctpmap = SDPUtil.find_line(remoteSDP.media[channel], 'a=sctpmap:'); + change.c('sctpconnection', { + endpoint: $(this.channels[participant][channel]).attr('endpoint'), + expire: self.channelExpire, + port: SDPUtil.parse_sctpmap(sctpmap) + }); + } // now add transport remoteSDP.TransportToJingle(channel, change); - change.up(); // end of channel + change.up(); // end of channel/sctpconnection change.up(); // end of content } this.connection.sendIQ(change, @@ -675,8 +826,11 @@ ColibriFocus.prototype.setRemoteDescription = function (session, elem, desctype) this.remotessrc[session.peerjid] = []; for (channel = 0; channel < this.channels[participant].length; channel++) { //if (channel == 0) continue; FIXME: does not work as intended - if (SDPUtil.find_lines(remoteSDP.media[channel], 'a=ssrc:').length) { - this.remotessrc[session.peerjid][channel] = SDPUtil.find_lines(remoteSDP.media[channel], 'a=ssrc:').join('\r\n') + '\r\n'; + if (SDPUtil.find_lines(remoteSDP.media[channel], 'a=ssrc:').length) + { + this.remotessrc[session.peerjid][channel] = + SDPUtil.find_lines(remoteSDP.media[channel], 'a=ssrc:') + .join('\r\n') + '\r\n'; } } @@ -702,14 +856,27 @@ ColibriFocus.prototype.addIceCandidate = function (session, elem) { change.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri', id: this.confid}); $(elem).each(function () { var name = $(this).attr('name'); + var channel = name == 'audio' ? 0 : 1; // FIXME: search mlineindex in localdesc + if (name != 'audio' && name != 'video') + channel = 2; // name == 'data' change.c('content', {name: name}); - change.c('channel', { - id: $(self.channels[participant][channel]).attr('id'), - endpoint: $(self.channels[participant][channel]).attr('endpoint'), - expire: '15' - }); + if (name !== 'data') + { + change.c('channel', { + id: $(self.channels[participant][channel]).attr('id'), + endpoint: $(self.channels[participant][channel]).attr('endpoint'), + expire: self.channelExpire + }); + } + else + { + change.c('sctpconnection', { + endpoint: $(self.channels[participant][channel]).attr('endpoint'), + expire: self.channelExpire + }); + } $(this).find('>transport').each(function () { change.c('transport', { ufrag: $(this).attr('ufrag'), @@ -729,7 +896,7 @@ ColibriFocus.prototype.addIceCandidate = function (session, elem) { }); change.up(); // end of transport }); - change.up(); // end of channel + change.up(); // end of channel/sctpconnection change.up(); // end of content }); // FIXME: need to check if there is at least one candidate when filtering TCP ones @@ -769,21 +936,35 @@ ColibriFocus.prototype.sendIceCandidates = function (candidates) { mycands.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri', id: this.confid}); // FIXME: multi-candidate logic is taken from strophe.jingle, should be refactored there var localSDP = new SDP(this.peerconnection.localDescription.sdp); - for (var mid = 0; mid < localSDP.media.length; mid++) { + for (var mid = 0; mid < localSDP.media.length; mid++) + { var cands = candidates.filter(function (el) { return el.sdpMLineIndex == mid; }); - if (cands.length > 0) { - mycands.c('content', {name: cands[0].sdpMid }); - mycands.c('channel', { - id: $(this.mychannel[cands[0].sdpMLineIndex]).attr('id'), - endpoint: $(this.mychannel[cands[0].sdpMLineIndex]).attr('endpoint'), - expire: '15' - }); + if (cands.length > 0) + { + var name = cands[0].sdpMid; + mycands.c('content', {name: name }); + if (name !== 'data') + { + mycands.c('channel', { + id: $(this.mychannel[cands[0].sdpMLineIndex]).attr('id'), + endpoint: $(this.mychannel[cands[0].sdpMLineIndex]).attr('endpoint'), + expire: self.channelExpire + }); + } + else + { + mycands.c('sctpconnection', { + endpoint: $(this.mychannel[cands[0].sdpMLineIndex]).attr('endpoint'), + port: $(this.mychannel[cands[0].sdpMLineIndex]).attr('port'), + expire: self.channelExpire + }); + } mycands.c('transport', {xmlns: 'urn:xmpp:jingle:transports:ice-udp:1'}); for (var i = 0; i < cands.length; i++) { mycands.c('candidate', SDPUtil.candidateToJingle(cands[i].candidate)).up(); } mycands.up(); // transport - mycands.up(); // channel + mycands.up(); // channel / sctpconnection mycands.up(); // content } } @@ -814,13 +995,26 @@ ColibriFocus.prototype.terminate = function (session, reason) { var change = $iq({to: this.bridgejid, type: 'set'}); change.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri', id: this.confid}); for (var channel = 0; channel < this.channels[participant].length; channel++) { - change.c('content', {name: channel === 0 ? 'audio' : 'video'}); - change.c('channel', { - id: $(this.channels[participant][channel]).attr('id'), - endpoint: $(this.channels[participant][channel]).attr('endpoint'), - expire: '0' - }); - change.up(); // end of channel + var name = channel === 0 ? 'audio' : 'video'; + if (channel == 2) + name = 'data'; + change.c('content', {name: name}); + if (name !== 'data') + { + change.c('channel', { + id: $(this.channels[participant][channel]).attr('id'), + endpoint: $(this.channels[participant][channel]).attr('endpoint'), + expire: '0' + }); + } + else + { + change.c('sctpconnection', { + endpoint: $(this.channels[participant][channel]).attr('endpoint'), + expire: '0' + }); + } + change.up(); // end of channel/sctpconnection change.up(); // end of content } this.connection.sendIQ(change, diff --git a/libs/strophe/strophe.jingle.adapter.js b/libs/strophe/strophe.jingle.adapter.js index 2aa712fc1..8f273ea61 100644 --- a/libs/strophe/strophe.jingle.adapter.js +++ b/libs/strophe/strophe.jingle.adapter.js @@ -32,8 +32,8 @@ function TraceablePeerConnection(ice_config, constraints) { this.switchstreams = false; // override as desired - this.trace = function(what, info) { - //console.warn('WTRACE', what, info); + this.trace = function (what, info) { + console.warn('WTRACE', what, info); self.updateLog.push({ time: new Date(), type: what, @@ -144,8 +144,8 @@ TraceablePeerConnection.prototype.removeStream = function (stream) { TraceablePeerConnection.prototype.createDataChannel = function (label, opts) { this.trace('createDataChannel', label, opts); - this.peerconnection.createDataChannel(label, opts); -} + return this.peerconnection.createDataChannel(label, opts); +}; TraceablePeerConnection.prototype.setLocalDescription = function (description, successCallback, failureCallback) { var self = this; diff --git a/libs/strophe/strophe.jingle.sdp.js b/libs/strophe/strophe.jingle.sdp.js index f7e3c9d56..cdcfd38ef 100644 --- a/libs/strophe/strophe.jingle.sdp.js +++ b/libs/strophe/strophe.jingle.sdp.js @@ -155,7 +155,10 @@ SDP.prototype.toJingle = function (elem, thecreator) { } for (i = 0; i < this.media.length; i++) { mline = SDPUtil.parse_mline(this.media[i].split('\r\n')[0]); - if (!(mline.media == 'audio' || mline.media == 'video')) { + if (!(mline.media === 'audio' || + mline.media === 'video' || + mline.media === 'application')) + { continue; } if (SDPUtil.find_line(this.media[i], 'a=ssrc:')) { @@ -171,12 +174,32 @@ SDP.prototype.toJingle = function (elem, thecreator) { elem.attrs({ name: mid }); // old BUNDLE plan, to be removed - if (bundle.indexOf(mid) != -1) { + if (bundle.indexOf(mid) !== -1) { elem.c('bundle', {xmlns: 'http://estos.de/ns/bundle'}).up(); bundle.splice(bundle.indexOf(mid), 1); } } - if (SDPUtil.find_line(this.media[i], 'a=rtpmap:').length) { + // Sctp + if (SDPUtil.find_line(this.media[i], 'a=sctpmap:').length) + { + for (j = 0; j < mline.fmt.length; j++) + { + var sctpmap = SDPUtil.find_line( + this.media[i], 'a=sctpmap:' + mline.fmt[j]); + if (sctpmap) + { + elem.c('sctp', + { + xmlns: 'http://jitsi.org/ns/sctp', + port : SDPUtil.parse_sctpmap(sctpmap) + }); + elem.up(); + } + } + } + + if (SDPUtil.find_line(this.media[i], 'a=rtpmap:').length) + { elem.c('description', {xmlns: 'urn:xmpp:jingle:apps:rtp:1', media: mline.media }); @@ -438,6 +461,11 @@ SDP.prototype.jingle2media = function (content) { ssrc = desc.attr('ssrc'), self = this, tmp; + var sctp = null; + if (!desc.length) + { + sctp = content.find('sctp'); + } tmp = { media: desc.attr('media') }; tmp.port = '1'; @@ -446,14 +474,28 @@ SDP.prototype.jingle2media = function (content) { tmp.port = '0'; } if (content.find('>transport>fingerprint').length || desc.find('encryption').length) { - tmp.proto = 'RTP/SAVPF'; + if (sctp) + tmp.proto = 'DTLS/SCTP'; + else + tmp.proto = 'RTP/SAVPF'; } else { tmp.proto = 'RTP/AVPF'; } - tmp.fmt = desc.find('payload-type').map(function () { return this.getAttribute('id'); }).get(); - media += SDPUtil.build_mline(tmp) + '\r\n'; + if (!sctp) + { + tmp.fmt = desc.find('payload-type').map( + function () { return this.getAttribute('id'); }).get(); + media += SDPUtil.build_mline(tmp) + '\r\n'; + } + else + { + media += 'm=application 1 DTLS/SCTP ' + sctp.attr('port') + '\r\n'; + media += 'a=sctpmap:' + sctp.attr('port') + ' webrtc-datachannel\r\n'; + } + media += 'c=IN IP4 0.0.0.0\r\n'; - media += 'a=rtcp:1 IN IP4 0.0.0.0\r\n'; + if (!sctp) + media += 'a=rtcp:1 IN IP4 0.0.0.0\r\n'; tmp = content.find('>transport[xmlns="urn:xmpp:jingle:transports:ice-udp:1"]'); if (tmp.length) { if (tmp.attr('ufrag')) { diff --git a/libs/strophe/strophe.jingle.sdp.util.js b/libs/strophe/strophe.jingle.sdp.util.js index 7e681acbb..36750d140 100644 --- a/libs/strophe/strophe.jingle.sdp.util.js +++ b/libs/strophe/strophe.jingle.sdp.util.js @@ -90,6 +90,16 @@ SDPUtil = { data.channels = parts.length ? parts.shift() : '1'; return data; }, + /** + * Parses SDP line "a=sctpmap:..." and extracts SCTP port from it. + * @param line eg. "a=sctpmap:5000 webrtc-datachannel" + * @returns SCTP port number + */ + parse_sctpmap: function (line) + { + var parts = line.substring(10).split(' '); + return parts[0];// SCTP port + }, build_rtpmap: function (el) { var line = 'a=rtpmap:' + el.getAttribute('id') + ' ' + el.getAttribute('name') + '/' + el.getAttribute('clockrate'); if (el.getAttribute('channels') && el.getAttribute('channels') != '1') { From 6d969815200a6fb4cdc1f1919cef60d261f69234 Mon Sep 17 00:00:00 2001 From: paweldomas Date: Tue, 13 May 2014 16:51:08 +0200 Subject: [PATCH 2/6] Replaces focus endpoint name "fix_me_focus_endpoint" with it's actual XMPP resource. --- libs/colibri/colibri.focus.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index 2fac9aaf7..93517c346 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -44,6 +44,12 @@ function ColibriFocus(connection, bridgejid) { this.peers = []; this.confid = null; + /** + * Local XMPP resource used to join the multi user chat. + * @type {*} + */ + this.myMucResource = Strophe.getResourceFromJid(connection.emuc.myroomjid); + /** * Default channel expire value in seconds. * @type {number} @@ -172,7 +178,7 @@ ColibriFocus.prototype._makeConference = function () { elem.c(channel, { initiator: 'true', expire: '15', - endpoint: 'fix_me_focus_endpoint' + endpoint: self.myMucResource }); if (isData) elem.attrs({port: 5000}); @@ -400,7 +406,7 @@ ColibriFocus.prototype.createdConference = function (result) { initiator: 'true', expire: self.channelExpire, id: self.mychannel[channel].attr('id'), - endpoint: 'fix_me_focus_endpoint' + endpoint: self.myMucResource }); // FIXME: should reuse code from .toJingle @@ -422,7 +428,7 @@ ColibriFocus.prototype.createdConference = function (result) { { initiator: 'true', expire: self.channelExpire, - endpoint: 'fix_me_focus_endpoint', + endpoint: self.myMucResource, port: sctpPort } ); From e3f33c7a778aef1a644907eae3f11206f0298ce1 Mon Sep 17 00:00:00 2001 From: paweldomas Date: Tue, 13 May 2014 16:54:35 +0200 Subject: [PATCH 3/6] Adds experimental active speaker detection. --- data_channels.js | 36 +++++++++++++++++++++++++- libs/strophe/strophe.jingle.adapter.js | 2 +- muc.js | 3 +++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/data_channels.js b/data_channels.js index 38d70741e..e63015b89 100644 --- a/data_channels.js +++ b/data_channels.js @@ -1,3 +1,4 @@ +/* global connection, Strophe, updateLargeVideo*/ /** * Callback triggered by PeerConnection when new data channel is opened * on the bridge. @@ -27,6 +28,34 @@ function onDataChannel(event) { var msgData = event.data; console.info("Got Data Channel Message:", msgData, dataChannel); + + // Active speaker event + if (msgData.indexOf('activeSpeaker') === 0) + { + // Endpoint ID from the bridge + var endpointId = msgData.split(":")[1]; + console.info("New active speaker: " + endpointId); + + var container = document.getElementById( + 'participant_' + endpointId); + // Check if local video + if (!container) + { + if (endpointId === + Strophe.getResourceFromJid(connection.emuc.myroomjid)) + { + container = document.getElementById('localVideoContainer'); + } + } + if (container) + { + var video = container.getElementsByTagName("video"); + if (video.length) + { + updateLargeVideo(video[0].src); + } + } + } }; dataChannel.onclose = function () @@ -48,7 +77,7 @@ function bindDataChannelListener(peerConnection) // and peer as single channel can be used for sending and receiving data. // So either channel opened by the bridge or the one opened here is enough // for communication with the bridge. - var dataChannelOptions = + /*var dataChannelOptions = { reliable: true }; @@ -60,4 +89,9 @@ function bindDataChannelListener(peerConnection) { dataChannel.send("My channel !!!"); }; + dataChannel.onmessage = function (event) + { + var msgData = event.data; + console.info("Got My Data Channel Message:", msgData, dataChannel); + };*/ } \ No newline at end of file diff --git a/libs/strophe/strophe.jingle.adapter.js b/libs/strophe/strophe.jingle.adapter.js index 8f273ea61..7de3263ce 100644 --- a/libs/strophe/strophe.jingle.adapter.js +++ b/libs/strophe/strophe.jingle.adapter.js @@ -33,7 +33,7 @@ function TraceablePeerConnection(ice_config, constraints) { // override as desired this.trace = function (what, info) { - console.warn('WTRACE', what, info); + //console.warn('WTRACE', what, info); self.updateLog.push({ time: new Date(), type: what, diff --git a/muc.js b/muc.js index dee2474a9..0a05979d7 100644 --- a/muc.js +++ b/muc.js @@ -21,6 +21,9 @@ Strophe.addConnectionPlugin('emuc', { }, doJoin: function (jid, password) { this.myroomjid = jid; + + console.info("Joined MUC as " + this.myroomjid); + this.initPresenceMap(this.myroomjid); if (!this.roomjid) { From 8ba531ed22c05ab4f0efa14c20e108cf3d71d61c Mon Sep 17 00:00:00 2001 From: paweldomas Date: Mon, 19 May 2014 15:53:45 +0200 Subject: [PATCH 4/6] Focuses clicked video thumbnail and prevents from switching to new large video. --- app.js | 76 ++++++++++++++++++++++++++++++++++++- css/videolayout_default.css | 4 ++ data_channels.js | 4 +- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/app.js b/app.js index 8584d50d5..9e3699236 100644 --- a/app.js +++ b/app.js @@ -15,6 +15,11 @@ var ssrc2jid = {}; */ var ssrc2videoType = {}; var videoSrcToSsrc = {}; +/** + * Currently focused video "src"(displayed in large video). + * @type {String} + */ +var focusedVideoSrc = null; var mutedAudios = {}; var localVideoSrc = null; @@ -353,7 +358,65 @@ $(document).bind('remotestreamadded.jingle', function (event, data, sid) { } }); +/** + * Returns the JID of the user to whom given videoSrc belongs. + * @param videoSrc the video "src" identifier. + * @returns {null | String} the JID of the user to whom given videoSrc + * belongs. + */ +function getJidFromVideoSrc(videoSrc) +{ + if (videoSrc === localVideoSrc) + return connection.emuc.myroomjid; + + var ssrc = videoSrcToSsrc[videoSrc]; + if (!ssrc) + { + return null; + } + return ssrc2jid[ssrc]; +} + +/** + * Gets the selector of video thumbnail container for the user identified by + * given userJid + * @param userJid user's Jid for whom we want to get the video container. + */ +function getParticipantContainer(userJid) +{ + if (!userJid) + return null; + + if (userJid === connection.emuc.myroomjid) + return $("#localVideoContainer"); + else + return $("#participant_" + Strophe.getResourceFromJid(userJid)); +} + function handleVideoThumbClicked(videoSrc) { + // Restore style for previously focused video + var oldContainer = + getParticipantContainer( + getJidFromVideoSrc(focusedVideoSrc)); + if (oldContainer) + oldContainer.removeClass("videoContainerFocused"); + + // Unlock + if (focusedVideoSrc === videoSrc) + { + focusedVideoSrc = null; + return; + } + + // Lock new video + focusedVideoSrc = videoSrc; + + var userJid = getJidFromVideoSrc(videoSrc); + if (userJid) + { + var container = getParticipantContainer(userJid); + container.addClass("videoContainerFocused"); + } $(document).trigger("video.selected", [false]); @@ -499,7 +562,8 @@ $(document).bind('callactive.jingle', function (event, videoelem, sid) { videoelem.show(); resizeThumbnails(); - updateLargeVideo(videoelem.attr('src'), 1); + if (!focusedVideoSrc) + updateLargeVideo(videoelem.attr('src'), 1); showFocusIndicator(); } @@ -618,6 +682,16 @@ $(document).bind('left.muc', function (event, jid) { } }, 10); + // Unlock large video + if (focusedVideoSrc) + { + if (getJidFromVideoSrc(focusedVideoSrc) === jid) + { + console.info("Focused video owner has left the conference"); + focusedVideoSrc = null; + } + } + connection.jingle.terminateByJid(jid); if (focus == null diff --git a/css/videolayout_default.css b/css/videolayout_default.css index 7c7823100..44011d027 100644 --- a/css/videolayout_default.css +++ b/css/videolayout_default.css @@ -52,6 +52,10 @@ z-index: 3; } +#remoteVideos .videocontainer.videoContainerFocused { + border: 3px solid #388396; +} + #localVideoWrapper { display:inline-block; -webkit-mask-box-image: url(../images/videomask.svg); diff --git a/data_channels.js b/data_channels.js index e63015b89..8ab0a4b1b 100644 --- a/data_channels.js +++ b/data_channels.js @@ -1,4 +1,4 @@ -/* global connection, Strophe, updateLargeVideo*/ +/* global connection, Strophe, updateLargeVideo, focusedVideoSrc*/ /** * Callback triggered by PeerConnection when new data channel is opened * on the bridge. @@ -30,7 +30,7 @@ function onDataChannel(event) console.info("Got Data Channel Message:", msgData, dataChannel); // Active speaker event - if (msgData.indexOf('activeSpeaker') === 0) + if (msgData.indexOf('activeSpeaker') === 0 && !focusedVideoSrc) { // Endpoint ID from the bridge var endpointId = msgData.split(":")[1]; From be42629a63de322af23fe9aaec1fbd7368da75a0 Mon Sep 17 00:00:00 2001 From: paweldomas Date: Mon, 26 May 2014 14:54:17 +0200 Subject: [PATCH 5/6] Adopts XEP-0343 for DTLS/SCTP Jingle signaling. --- libs/colibri/colibri.focus.js | 4 +- libs/strophe/strophe.jingle.sdp.js | 62 ++++++++++++++----------- libs/strophe/strophe.jingle.sdp.util.js | 8 +++- 3 files changed, 42 insertions(+), 32 deletions(-) diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index 93517c346..f82801255 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -423,7 +423,7 @@ ColibriFocus.prototype.createdConference = function (result) { else { var sctpmap = SDPUtil.find_line(media, 'a=sctpmap:' + mline.fmt[0]); - var sctpPort = SDPUtil.parse_sctpmap(sctpmap); + var sctpPort = SDPUtil.parse_sctpmap(sctpmap)[0]; elem.c("sctpconnection", { initiator: 'true', @@ -712,7 +712,7 @@ ColibriFocus.prototype.updateChannel = function (remoteSDP, participant) { change.c('sctpconnection', { endpoint: $(this.channels[participant][channel]).attr('endpoint'), expire: self.channelExpire, - port: SDPUtil.parse_sctpmap(sctpmap) + port: SDPUtil.parse_sctpmap(sctpmap)[0] }); } // now add transport diff --git a/libs/strophe/strophe.jingle.sdp.js b/libs/strophe/strophe.jingle.sdp.js index cdcfd38ef..ca128e65e 100644 --- a/libs/strophe/strophe.jingle.sdp.js +++ b/libs/strophe/strophe.jingle.sdp.js @@ -179,24 +179,6 @@ SDP.prototype.toJingle = function (elem, thecreator) { bundle.splice(bundle.indexOf(mid), 1); } } - // Sctp - if (SDPUtil.find_line(this.media[i], 'a=sctpmap:').length) - { - for (j = 0; j < mline.fmt.length; j++) - { - var sctpmap = SDPUtil.find_line( - this.media[i], 'a=sctpmap:' + mline.fmt[j]); - if (sctpmap) - { - elem.c('sctp', - { - xmlns: 'http://jitsi.org/ns/sctp', - port : SDPUtil.parse_sctpmap(sctpmap) - }); - elem.up(); - } - } - } if (SDPUtil.find_line(this.media[i], 'a=rtpmap:').length) { @@ -327,6 +309,26 @@ SDP.prototype.TransportToJingle = function (mediaindex, elem) { var self = this; elem.c('transport'); + // XEP-0343 DTLS/SCTP + if (SDPUtil.find_line(this.media[mediaindex], 'a=sctpmap:').length) + { + var sctpmap = SDPUtil.find_line( + this.media[i], 'a=sctpmap:', self.session); + if (sctpmap) + { + var sctpAttrs = SDPUtil.parse_sctpmap(sctpmap); + elem.c('sctpmap', + { + xmlns: 'urn:xmpp:jingle:transports:dtls-sctp:1', + number: sctpAttrs[0], /* SCTP port */ + protocol: sctpAttrs[1], /* protocol */ + }); + // Optional stream count attribute + if (sctpAttrs.length > 2) + elem.attrs({ streams: sctpAttrs[2]}); + elem.up(); + } + } // XEP-0320 var fingerprints = SDPUtil.find_lines(this.media[mediaindex], 'a=fingerprint:', this.session); fingerprints.forEach(function(line) { @@ -461,11 +463,8 @@ SDP.prototype.jingle2media = function (content) { ssrc = desc.attr('ssrc'), self = this, tmp; - var sctp = null; - if (!desc.length) - { - sctp = content.find('sctp'); - } + var sctp = content.find( + '>transport>sctpmap[xmlns="urn:xmpp:jingle:transports:dtls-sctp:1"]'); tmp = { media: desc.attr('media') }; tmp.port = '1'; @@ -474,14 +473,14 @@ SDP.prototype.jingle2media = function (content) { tmp.port = '0'; } if (content.find('>transport>fingerprint').length || desc.find('encryption').length) { - if (sctp) + if (sctp.length) tmp.proto = 'DTLS/SCTP'; else tmp.proto = 'RTP/SAVPF'; } else { tmp.proto = 'RTP/AVPF'; } - if (!sctp) + if (!sctp.length) { tmp.fmt = desc.find('payload-type').map( function () { return this.getAttribute('id'); }).get(); @@ -489,12 +488,19 @@ SDP.prototype.jingle2media = function (content) { } else { - media += 'm=application 1 DTLS/SCTP ' + sctp.attr('port') + '\r\n'; - media += 'a=sctpmap:' + sctp.attr('port') + ' webrtc-datachannel\r\n'; + media += 'm=application 1 DTLS/SCTP ' + sctp.attr('number') + '\r\n'; + media += 'a=sctpmap:' + sctp.attr('number') + + ' ' + sctp.attr('protocol'); + + var streamCount = sctp.attr('streams'); + if (streamCount) + media += ' ' + streamCount + '\r\n'; + else + media += '\r\n'; } media += 'c=IN IP4 0.0.0.0\r\n'; - if (!sctp) + if (!sctp.length) media += 'a=rtcp:1 IN IP4 0.0.0.0\r\n'; tmp = content.find('>transport[xmlns="urn:xmpp:jingle:transports:ice-udp:1"]'); if (tmp.length) { diff --git a/libs/strophe/strophe.jingle.sdp.util.js b/libs/strophe/strophe.jingle.sdp.util.js index 36750d140..df7fed6e2 100644 --- a/libs/strophe/strophe.jingle.sdp.util.js +++ b/libs/strophe/strophe.jingle.sdp.util.js @@ -93,12 +93,16 @@ SDPUtil = { /** * Parses SDP line "a=sctpmap:..." and extracts SCTP port from it. * @param line eg. "a=sctpmap:5000 webrtc-datachannel" - * @returns SCTP port number + * @returns [SCTP port number, protocol, streams] */ parse_sctpmap: function (line) { var parts = line.substring(10).split(' '); - return parts[0];// SCTP port + var sctpPort = parts[0]; + var protocol = parts[1]; + // Stream count is optional + var streamCount = parts.length > 2 ? parts[2] : null; + return [sctpPort, protocol, streamCount];// SCTP port }, build_rtpmap: function (el) { var line = 'a=rtpmap:' + el.getAttribute('id') + ' ' + el.getAttribute('name') + '/' + el.getAttribute('clockrate'); From 8d6e8d360b6c5b6556cde83623d40817a89dc4f7 Mon Sep 17 00:00:00 2001 From: paweldomas Date: Wed, 4 Jun 2014 11:03:33 +0200 Subject: [PATCH 6/6] Does not switch to local video when we're detected as an active speaker. Comments out sample code. --- data_channels.js | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/data_channels.js b/data_channels.js index 8ab0a4b1b..056345ea6 100644 --- a/data_channels.js +++ b/data_channels.js @@ -12,11 +12,11 @@ function onDataChannel(event) { console.info("Data channel opened by the bridge !!!", dataChannel); + // Code sample for sending string and/or binary data // Sends String message to the bridge - dataChannel.send("Hello bridge!"); - + //dataChannel.send("Hello bridge!"); // Sends 12 bytes binary message to the bridge - dataChannel.send(new ArrayBuffer(12)); + //dataChannel.send(new ArrayBuffer(12)); }; dataChannel.onerror = function (error) @@ -38,15 +38,8 @@ function onDataChannel(event) var container = document.getElementById( 'participant_' + endpointId); - // Check if local video - if (!container) - { - if (endpointId === - Strophe.getResourceFromJid(connection.emuc.myroomjid)) - { - container = document.getElementById('localVideoContainer'); - } - } + // Local video will not have container found, but that's ok + // since we don't want to switch to local video if (container) { var video = container.getElementsByTagName("video");