From 3bd043a8ebbc18e7ff8129c8df7e372183dfb26c Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 13 Jul 2015 16:45:31 +0100 Subject: [PATCH 01/10] Add MatrixCall class; ported from angular. Untested and probably broken. --- lib/webrtc/call.js | 827 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 827 insertions(+) create mode 100644 lib/webrtc/call.js diff --git a/lib/webrtc/call.js b/lib/webrtc/call.js new file mode 100644 index 000000000..c673eaff9 --- /dev/null +++ b/lib/webrtc/call.js @@ -0,0 +1,827 @@ +"use strict"; +var utils = require("../utils"); +var EventEmitter = require("events").EventEmitter; + +// events: onHangup, callPlaced + +/** + * Construct a new Matrix Call. + * @constructor + * @param {Object} opts Config options. + * @param {string} opts.roomId The room ID for this call. + * @param {MatrixClient} opts.client The Matrix Client instance to send events to. + */ +function MatrixCall(opts) { + this.roomId = opts.roomId; + this.client = opts.client; + this.webRtc = opts.webRtc; + // Array of Objects with urls, username, credential keys + this.turnServers = opts.turnServers || [{ + urls: [MatrixCall.FALLBACK_STUN_SERVER] + }]; + utils.forEach(this.turnServers, function(server) { + utils.checkObjectHasKeys(server, ["urls"]); + }); + this.URL = opts.URL; + + this.callId = "c" + new Date().getTime(); + this.state = 'fledgling'; + this.didConnect = false; + + // A queue for candidates waiting to go out. + // We try to amalgamate candidates into a single candidate message where + // possible + this.candidateSendQueue = []; + this.candidateSendTries = 0; +} +/** The length of time a call can be ringing for. */ +MatrixCall.CALL_TIMEOUT_MS = 60000; +/** The fallback server to use for STUN. */ +MatrixCall.FALLBACK_STUN_SERVER = 'stun:stun.l.google.com:19302'; + +utils.inherits(MatrixCall, EventEmitter); + +/** + * Place a voice call to this room. + */ +MatrixCall.prototype.placeVoiceCall = function() { + _placeCallWithConstraints(this, _getUserMediaVideoContraints('voice')); + this.type = 'voice'; +}; + +/** + * Place a video call to this room. + */ +MatrixCall.prototype.placeVideoCall = function() { + _placeCallWithConstraints(this, _getUserMediaVideoContraints('video')); + this.type = 'video'; +}; + +/** + * Retrieve the local video DOM element. + * @return {Element} The dom element + */ +MatrixCall.prototype.getLocalVideoElement = function() { + return this.localVideoSelector; +}; + +/** + * Retrieve the remote video DOM element. + * @return {Element} The dom element + */ +MatrixCall.prototype.getRemoteVideoElement = function() { + return this.remoteVideoSelector; +}; + +/** + * Configure this call from an invite event. + * @param {MatrixEvent} event The m.call.invite event + */ +MatrixCall.prototype.initWithInvite = function(event) { + this.msg = event.getContent(); + this.peerConn = _createPeerConnection(this); + var self = this; + if (this.peerConn) { + this.peerConn.setRemoteDescription( + new this.webRtc.RtcSessionDescription(this.msg.offer), + function(s) { + self.onSetRemoteDescriptionSuccess(s); + }, + function(e) { + self.onSetRemoteDescriptionError(e); + } + ); + } + this.state = 'ringing'; + this.direction = 'inbound'; + + // firefox and Safari's RTCPeerConnection doesn't add streams until it + // starts getting media on them so we need to figure out whether a video + // channel has been offered by ourselves. + if (this.msg.offer.sdp.indexOf('m=video') > -1) { + this.type = 'video'; + } + else { + this.type = 'voice'; + } + + if (event.getAge()) { + setTimeout(function() { + if (self.state == 'ringing') { + self.state = 'ended'; + self.hangupParty = 'remote'; // effectively + stopAllMedia(self); + if (self.peerConn.signalingState != 'closed') { + self.peerConn.close(); + } + self.emit("onHangup", self); + } + }, this.msg.lifetime - event.getAge()); + } +}; + +/** + * Configure this call from a hangup event. + * @param {MatrixEvent} event The m.call.hangup event + */ +MatrixCall.prototype.initWithHangup = function(event) { + // perverse as it may seem, sometimes we want to instantiate a call with a + // hangup message (because when getting the state of the room on load, events + // come in reverse order and we want to remember that a call has been hung up) + this.msg = event.getContent(); + this.state = 'ended'; +}; + +/** + * Answer a call. + */ +MatrixCall.prototype.answer = function() { + console.log("Answering call " + this.callId); + var self = this; + + if (!this.localAVStream && !this.waitForLocalAVStream) { + this.webRtc.getUserMedia( + _getUserMediaVideoContraints(this.type), + function(stream) { + gotUserMediaForAnswer(self, stream); + }, + this.getUserMediaFailed + ); + this.state = 'wait_local_media'; + } else if (this.localAVStream) { + gotUserMediaForAnswer(this, this.localAVStream); + } else if (this.waitForLocalAVStream) { + this.state = 'wait_local_media'; + } +}; + +/** + * Replace this call with a new call, e.g. for glare resolution. + * @param {MatrixCall} newCall The new call. + */ +MatrixCall.prototype.replacedBy = function(newCall) { + console.log(this.callId + " being replaced by " + newCall.callId); + if (this.state == 'wait_local_media') { + console.log("Telling new call to wait for local media"); + newCall.waitForLocalAVStream = true; + } else if (this.state == 'create_offer') { + console.log("Handing local stream to new call"); + gotUserMediaForAnswer(newCall, this.localAVStream); + delete(this.localAVStream); + } else if (this.state == 'invite_sent') { + console.log("Handing local stream to new call"); + gotUserMediaForAnswer(newCall, this.localAVStream); + delete(this.localAVStream); + } + newCall.localVideoSelector = this.localVideoSelector; + newCall.remoteVideoSelector = this.remoteVideoSelector; + this.successor = newCall; + this.hangup(true); +}; + +/** + * Hangup a call. + * @param {string} reason The reason why the call is being hung up. + * @param {boolean} suppressEvent True to suppress emitting an event. + */ +MatrixCall.prototype.hangup = function(reason, suppressEvent) { + console.log("Ending call " + this.callId); + + // pausing now keeps the last frame (ish) of the video call in the video element + // rather than it just turning black straight away + if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) { + this.getRemoteVideoElement().pause(); + } + if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) { + this.getLocalVideoElement().pause(); + } + + this.stopAllMedia(); + if (this.peerConn) { + this.peerConn.close(); + } + + this.hangupParty = 'local'; + this.hangupReason = reason; + + var content = { + version: 0, + call_id: this.callId, + reason: reason + }; + this.sendEvent('m.call.hangup', content); + this.state = 'ended'; + if (!suppressEvent) { + this.emit("onHangup", this); + } +}; + +/** + * Internal + * @param {Object} stream + */ +MatrixCall.prototype.gotUserMediaForInvite = function(stream) { + if (this.successor) { + gotUserMediaForAnswer(this.successor, stream); + return; + } + if (this.state == 'ended') { + return; + } + var self = this; + var videoEl = this.getLocalVideoElement(); + + if (videoEl && this.type == 'video') { + videoEl.autoplay = true; + videoEl.src = this.URL.createObjectURL(stream); + videoEl.muted = true; + setTimeout(function() { + var vel = self.getLocalVideoElement(); + if (vel.play) { + vel.play(); + } + }, 0); + } + + this.localAVStream = stream; + var audioTracks = stream.getAudioTracks(); + for (var i = 0; i < audioTracks.length; i++) { + audioTracks[i].enabled = true; + } + this.peerConn = this._createPeerConnection(); + this.peerConn.addStream(stream); + this.peerConn.createOffer(function(d) { + self.gotLocalOffer(d); + }, function(e) { + self.getLocalOfferFailed(e); + }); + self.state = 'create_offer'; +}; + +var gotUserMediaForAnswer = function(self, stream) { + if (self.state == 'ended') { + return; + } + var localVidEl = self.getLocalVideoElement(); + + if (localVidEl && self.type == 'video') { + localVidEl.autoplay = true; + localVidEl.src = self.URL.createObjectURL(stream); + localVidEl.muted = self; + setTimeout(function() { + var vel = self.getLocalVideoElement(); + if (vel.play) { + vel.play(); + } + }, 0); + } + + self.localAVStream = stream; + var audioTracks = stream.getAudioTracks(); + for (var i = 0; i < audioTracks.length; i++) { + audioTracks[i].enabled = true; + } + self.peerConn.addStream(stream); + + var constraints = { + 'mandatory': { + 'OfferToReceiveAudio': true, + 'OfferToReceiveVideo': self.type == 'video' + }, + }; + self.peerConn.createAnswer(constraints, function(description) { + console.log("Created answer: " + description); + self.peerConn.setLocalDescription(description, function() { + var content = { + version: 0, + call_id: self.callId, + answer: { + sdp: self.peerConn.localDescription.sdp, + type: self.peerConn.localDescription.type + } + }; + self.sendEvent('m.call.answer', content); + self.state = 'connecting'; + }, function() { + console.log("Error setting local description!"); + }); + }); + self.state = 'create_answer'; +}; + +/** + * Internal + * @param {Object} event + */ +MatrixCall.prototype.gotLocalIceCandidate = function(event) { + if (event.candidate) { + console.log( + "Got local ICE " + event.candidate.sdpMid + " candidate: " + + event.candidate.candidate + ); + // As with the offer, note we need to make a copy of this object, not + // pass the original: that broke in Chrome ~m43. + var c = { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex + }; + this.sendCandidate(c); + } +}; + +/** + * Internal + * @param {Object} cand + */ +MatrixCall.prototype.gotRemoteIceCandidate = function(cand) { + if (this.state == 'ended') { + //console.log("Ignoring remote ICE candidate because call has ended"); + return; + } + console.log("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate); + this.peerConn.addIceCandidate( + new this.webRtc.RtcIceCandidate(cand), + function() {}, + function(e) {} + ); +}; + +/** + * Internal + * @param {Object} msg + */ +MatrixCall.prototype.receivedAnswer = function(msg) { + if (this.state == 'ended') { + return; + } + + var self = this; + this.peerConn.ngsetRemoteDescription( + new this.webRtc.RtcSessionDescription(msg.answer) + ).then(function(s) { + self.onSetRemoteDescriptionSuccess(s); + }, + function(e) { + self.onSetRemoteDescriptionError(e); + }); + this.state = 'connecting'; +}; + +/** + * Internal + * @param {Object} description + */ +MatrixCall.prototype.gotLocalOffer = function(description) { + var self = this; + console.log("Created offer: " + description); + + if (self.state == 'ended') { + console.log("Ignoring newly created offer on call ID " + self.callId + + " because the call has ended"); + return; + } + + self.peerConn.ngsetLocalDescription(description).then(function() { + var content = { + version: 0, + call_id: self.callId, + // OpenWebRTC appears to add extra stuff (like the DTLS fingerprint) + // to the description when setting it on the peerconnection. + // According to the spec it should only add ICE + // candidates. Any ICE candidates that have already been generated + // at this point will probably be sent both in the offer and separately. + // Also, note that we have to make a new object here, copying the + // type and sdp properties. + // Passing the RTCSessionDescription object as-is doesn't work in + // Chrome (as of about m43). + offer: { + sdp: self.peerConn.localDescription.sdp, + type: self.peerConn.localDescription.type + }, + lifetime: MatrixCall.CALL_TIMEOUT_MS + }; + self.sendEvent('m.call.invite', content); + + setTimeout(function() { + if (self.state == 'invite_sent') { + self.hangup('invite_timeout'); + } + }, MatrixCall.CALL_TIMEOUT_MS); + self.state = 'invite_sent'; + }, function() { + console.log("Error setting local description!"); + } + ); +}; + +/** + * Internal + * @param {Object} error + */ +MatrixCall.prototype.getLocalOfferFailed = function(error) { + this.onError("Failed to start audio for call!"); +}; + +/** + * Internal + */ +MatrixCall.prototype.getUserMediaFailed = function() { + this.onError( + "Couldn't start capturing media! Is your microphone set up and does " + + "this app have permission?" + ); + this.hangup(); +}; + +/** + * Internal + */ +MatrixCall.prototype.onIceConnectionStateChanged = function() { + if (this.state == 'ended') { + return; // because ICE can still complete as we're ending the call + } + console.log( + "Ice connection state changed to: " + this.peerConn.iceConnectionState + ); + // ideally we'd consider the call to be connected when we get media but + // chrome doesn't implement any of the 'onstarted' events yet + if (this.peerConn.iceConnectionState == 'completed' || + this.peerConn.iceConnectionState == 'connected') { + this.state = 'connected'; + this.didConnect = true; + } else if (this.peerConn.iceConnectionState == 'failed') { + this.hangup('ice_failed'); + } +}; + +/** + * Internal + */ +MatrixCall.prototype.onSignallingStateChanged = function() { + console.log( + "call " + this.callId + ": Signalling state changed to: " + + this.peerConn.signalingState + ); +}; + +/** + * Internal + */ +MatrixCall.prototype.onSetRemoteDescriptionSuccess = function() { + console.log("Set remote description"); +}; + +/** + * Internal + * @param {Object} e + */ +MatrixCall.prototype.onSetRemoteDescriptionError = function(e) { + console.log("Failed to set remote description" + e); +}; + +/** + * Internal + * @param {Object} event + */ +MatrixCall.prototype.onAddStream = function(event) { + console.log("Stream added" + event); + + var s = event.stream; + + this.remoteAVStream = s; + + if (this.direction == 'inbound') { + if (s.getVideoTracks().length > 0) { + this.type = 'video'; + } else { + this.type = 'voice'; + } + } + + var self = this; + forAllTracksOnStream(s, function(t) { + // not currently implemented in chrome + t.onstarted = self.onRemoteStreamTrackStarted; + }); + + event.stream.onended = function(e) { self.onRemoteStreamEnded(e); }; + // not currently implemented in chrome + event.stream.onstarted = function(e) { self.onRemoteStreamStarted(e); }; + + this.tryPlayRemoteStream(); +}; + +/** + * Internal + * @param {Object} event + */ +MatrixCall.prototype.tryPlayRemoteStream = function(event) { + if (this.getRemoteVideoElement() && this.remoteAVStream) { + var player = this.getRemoteVideoElement(); + player.autoplay = true; + player.src = this.URL.createObjectURL(this.remoteAVStream); + var self = this; + setTimeout(function() { + var vel = self.getRemoteVideoElement(); + if (vel.play) { + vel.play(); + } + // OpenWebRTC does not support oniceconnectionstatechange yet + if (self.webRtc.isOpenWebRTC()) { + self.state = 'connected'; + } + }, 0); + } +}; + +/** + * Internal + * @param {Object} event + */ +MatrixCall.prototype.onRemoteStreamStarted = function(event) { + this.state = 'connected'; +}; + +/** + * Internal + * @param {Object} event + */ +MatrixCall.prototype.onRemoteStreamEnded = function(event) { + console.log("Remote stream ended"); + this.state = 'ended'; + this.hangupParty = 'remote'; + stopAllMedia(this); + if (this.peerConn.signalingState != 'closed') { + this.peerConn.close(); + } + this.emit("onHangup", this); +}; + +/** + * Internal + * @param {Object} event + */ +MatrixCall.prototype.onRemoteStreamTrackStarted = function(event) { + this.state = 'connected'; +}; + +/** + * Internal + * @param {Object} msg + */ +MatrixCall.prototype.onHangupReceived = function(msg) { + console.log("Hangup received"); + if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) { + this.getRemoteVideoElement().pause(); + } + if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) { + this.getLocalVideoElement().pause(); + } + this.state = 'ended'; + this.hangupParty = 'remote'; + this.hangupReason = msg.reason; + stopAllMedia(this); + if (this.peerConn && this.peerConn.signalingState != 'closed') { + this.peerConn.close(); + } + this.emit("onHangup", this); +}; + +/** + * Internal + * @param {Object} msg + */ +MatrixCall.prototype.onAnsweredElsewhere = function(msg) { + console.log("Answered elsewhere"); + if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) { + this.getRemoteVideoElement().pause(); + } + if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) { + this.getLocalVideoElement().pause(); + } + this.state = 'ended'; + this.hangupParty = 'remote'; + this.hangupReason = "answered_elsewhere"; + stopAllMedia(this); + if (this.peerConn && this.peerConn.signalingState != 'closed') { + this.peerConn.close(); + } + this.emit("onHangup", this); +}; + +/** + * Internal + * @param {string} eventType + * @param {Object} content + * @return {Promise} + */ +MatrixCall.prototype.sendEvent = function(eventType, content) { + return this.client.sendEvent(this.roomId, eventType, content); +}; + +/** + * Internal + * @param {Object} content + */ +MatrixCall.prototype.sendCandidate = function(content) { + // Sends candidates with are sent in a special way because we try to amalgamate + // them into one message + this.candidateSendQueue.push(content); + var self = this; + if (this.candidateSendTries === 0) { + setTimeout(function() { + _sendCandidateQueue(self); + }, 100); + } +}; + +var stopAllMedia = function(self) { + if (self.localAVStream) { + forAllTracksOnStream(self.localAVStream, function(t) { + if (t.stop) { + t.stop(); + } + }); + // also call stop on the main stream so firefox will stop sharing + // the mic + if (self.localAVStream.stop) { + self.localAVStream.stop(); + } + } + if (self.remoteAVStream) { + forAllTracksOnStream(self.remoteAVStream, function(t) { + if (t.stop) { + t.stop(); + } + }); + } +}; + +var _sendCandidateQueue = function(self) { + if (self.candidateSendQueue.length === 0) { + return; + } + + var cands = self.candidateSendQueue; + self.candidateSendQueue = []; + ++self.candidateSendTries; + var content = { + version: 0, + call_id: self.callId, + candidates: cands + }; + console.log("Attempting to send " + cands.length + " candidates"); + self.sendEvent('m.call.candidates', content).then(function() { + self.candidateSendTries = 0; + _sendCandidateQueue(self); + }, function(error) { + for (var i = 0; i < cands.length; i++) { + self.candidateSendQueue.push(cands[i]); + } + + if (self.candidateSendTries > 5) { + console.log( + "Failed to send candidates on attempt %s. Giving up for now.", + self.candidateSendTries + ); + self.candidateSendTries = 0; + return; + } + + var delayMs = 500 * Math.pow(2, self.candidateSendTries); + ++self.candidateSendTries; + console.log("Failed to send candidates. Retrying in " + delayMs + "ms"); + setTimeout(function() { + _sendCandidateQueue(self); + }, delayMs); + }); +}; + +var _placeCallWithConstraints = function(self, constraints) { + self.emit("callPlaced", self); + self.webRtc.getUserMedia( + constraints, self.gotUserMediaForInvite, self.getUserMediaFailed + ); + self.state = 'wait_local_media'; + self.direction = 'outbound'; + self.config = constraints; +}; + +var _createPeerConnection = function(self) { + var servers = self.turnServers; + if (self.webRtc.vendor === "mozilla") { + // modify turnServers struct to match what mozilla expects. + servers = []; + for (var i = 0; i < self.turnServers.length; i++) { + for (var j = 0; j < self.turnServers[i].urls.length; j++) { + servers.push({ + url: self.turnServers[i].urls[j], + username: self.turnServers[i].username, + credential: self.turnServers[i].credential + }); + } + } + } + + var pc = new self.webRtc.RtcPeerConnection({ + iceServers: servers + }); + pc.oniceconnectionstatechange = self.onIceConnectionStateChanged; + pc.onsignalingstatechange = self.onSignallingStateChanged; + pc.onicecandidate = self.gotLocalIceCandidate; + pc.onaddstream = self.onAddStream; + return pc; +}; + +var _getUserMediaVideoContraints = function(callType) { + switch (callType) { + case 'voice': + return ({audio: true, video: false}); + case 'video': + return ({audio: true, video: { + mandatory: { + minWidth: 640, + maxWidth: 640, + minHeight: 360, + maxHeight: 360, + } + }}); + } +}; + +var forAllVideoTracksOnStream = function(s, f) { + var tracks = s.getVideoTracks(); + for (var i = 0; i < tracks.length; i++) { + f(tracks[i]); + } +}; + +var forAllAudioTracksOnStream = function(s, f) { + var tracks = s.getAudioTracks(); + for (var i = 0; i < tracks.length; i++) { + f(tracks[i]); + } +}; + +var forAllTracksOnStream = function(s, f) { + forAllVideoTracksOnStream(s, f); + forAllAudioTracksOnStream(s, f); +}; + +/** The MatrixCall class. */ +module.exports.MatrixCall = MatrixCall; + +/** + * Create a new Matrix call for the browser. + * @param {MatrixClient} client The client instance to use. + * @param {string} roomId The room the call is in. + * @return {MatrixCall} the call or null if the browser doesn't support calling. + */ +module.exports.createNewMatrixCall = function(client, roomId) { + var w = global.window; + var webRtc = {}; + webRtc.isOpenWebRTC = function() { + // TODO + }; + var getUserMedia = ( + w.navigator.getUserMedia || w.navigator.webkitGetUserMedia || + w.navigator.mozGetUserMedia + ); + if (getUserMedia) { + webRtc.getUserMedia = function() { + return getUserMedia.apply(w.navigator, arguments); + }; + } + webRtc.RtcPeerConnection = ( + w.RTCPeerConnection || w.webkitRTCPeerConnection || w.mozRTCPeerConnection + ); + webRtc.RtcSessionDescription = ( + w.RTCSessionDescription || w.webkitRTCSessionDescription || + w.mozRTCSessionDescription + ); + webRtc.RtcIceCandidate = ( + w.RTCIceCandidate || w.webkitRTCIceCandidate || w.mozRTCIceCandidate + ); + webRtc.vendor = null; + if (w.mozRTCPeerConnection) { + webRtc.vendor = "mozilla"; + } + else if (w.webkitRTCPeerConnection) { + webRtc.vendor = "webkit"; + } + else if (w.RTCPeerConnection) { + webRtc.vendor = "generic"; + } + if (!webRtc.RtcIceCandidate || !webRtc.RtcSessionDescription || + !webRtc.RtcPeerConnection || !webRtc.getUserMedia) { + return null; // Web RTC is not supported. + } + var opts = { + webRtc: webRtc, + client: client, + URL: w.URL, + roomId: roomId + }; + return new MatrixCall(opts); +}; From 0ef20faff7d872b12c2a2725f3c8726ca61b1b00 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 13 Jul 2015 17:11:37 +0100 Subject: [PATCH 02/10] Add JSDoc; Add createNewMatrixCall to globals. --- lib/http-api.js | 2 +- lib/matrix.js | 9 ++++ lib/webrtc/call.js | 111 ++++++++++++++++++++------------------------- 3 files changed, 59 insertions(+), 63 deletions(-) diff --git a/lib/http-api.js b/lib/http-api.js index 9b37a0def..edddab27e 100644 --- a/lib/http-api.js +++ b/lib/http-api.js @@ -140,7 +140,7 @@ module.exports.MatrixHttpApi.prototype = { /** * Upload content to the Home Server - * @param {File object} file A File object (in a browser) or in Node, + * @param {File} file A File object (in a browser) or in Node, an object with properties: name: The file's name stream: A read stream diff --git a/lib/matrix.js b/lib/matrix.js index ba03067b9..8384afab8 100644 --- a/lib/matrix.js +++ b/lib/matrix.js @@ -24,6 +24,15 @@ module.exports.RoomState = require("./models/room-state"); module.exports.User = require("./models/user"); /** The {@link module:scheduler~MatrixScheduler|MatrixScheduler} class. */ module.exports.MatrixScheduler = require("./scheduler"); +/** + * Create a new Matrix Call. + * @function + * @param {module:client.MatrixClient} client The MatrixClient instance to use. + * @param {string} roomId The room the call is in. + * @return {module:webrtc/call~MatrixCall} The Matrix call or null if the browser + * does not support WebRTC. + */ +module.exports.createNewMatrixCall = require("./webrtc/call").createNewMatrixCall; // expose the underlying request object so different environments can use // different request libs (e.g. request or browser-request) diff --git a/lib/webrtc/call.js b/lib/webrtc/call.js index c673eaff9..32e09384d 100644 --- a/lib/webrtc/call.js +++ b/lib/webrtc/call.js @@ -1,4 +1,8 @@ "use strict"; +/** + * This is an internal module. See {@link createNewMatrixCall} for the public API. + * @module webrtc/call + */ var utils = require("../utils"); var EventEmitter = require("events").EventEmitter; @@ -186,34 +190,13 @@ MatrixCall.prototype.replacedBy = function(newCall) { */ MatrixCall.prototype.hangup = function(reason, suppressEvent) { console.log("Ending call " + this.callId); - - // pausing now keeps the last frame (ish) of the video call in the video element - // rather than it just turning black straight away - if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) { - this.getRemoteVideoElement().pause(); - } - if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) { - this.getLocalVideoElement().pause(); - } - - this.stopAllMedia(); - if (this.peerConn) { - this.peerConn.close(); - } - - this.hangupParty = 'local'; - this.hangupReason = reason; - + terminate(this, "local", reason, !suppressEvent); var content = { version: 0, call_id: this.callId, reason: reason }; this.sendEvent('m.call.hangup', content); - this.state = 'ended'; - if (!suppressEvent) { - this.emit("onHangup", this); - } }; /** @@ -357,14 +340,15 @@ MatrixCall.prototype.receivedAnswer = function(msg) { } var self = this; - this.peerConn.ngsetRemoteDescription( - new this.webRtc.RtcSessionDescription(msg.answer) - ).then(function(s) { - self.onSetRemoteDescriptionSuccess(s); - }, - function(e) { - self.onSetRemoteDescriptionError(e); - }); + this.peerConn.setRemoteDescription( + new this.webRtc.RtcSessionDescription(msg.answer), + function(s) { + self.onSetRemoteDescriptionSuccess(s); + }, + function(e) { + self.onSetRemoteDescriptionError(e); + } + ); this.state = 'connecting'; }; @@ -382,7 +366,7 @@ MatrixCall.prototype.gotLocalOffer = function(description) { return; } - self.peerConn.ngsetLocalDescription(description).then(function() { + self.peerConn.setLocalDescription(description, function() { var content = { version: 0, call_id: self.callId, @@ -411,8 +395,7 @@ MatrixCall.prototype.gotLocalOffer = function(description) { self.state = 'invite_sent'; }, function() { console.log("Error setting local description!"); - } - ); + }); }; /** @@ -572,20 +555,7 @@ MatrixCall.prototype.onRemoteStreamTrackStarted = function(event) { */ MatrixCall.prototype.onHangupReceived = function(msg) { console.log("Hangup received"); - if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) { - this.getRemoteVideoElement().pause(); - } - if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) { - this.getLocalVideoElement().pause(); - } - this.state = 'ended'; - this.hangupParty = 'remote'; - this.hangupReason = msg.reason; - stopAllMedia(this); - if (this.peerConn && this.peerConn.signalingState != 'closed') { - this.peerConn.close(); - } - this.emit("onHangup", this); + terminate(this, "remote", msg.reason, true); }; /** @@ -594,20 +564,7 @@ MatrixCall.prototype.onHangupReceived = function(msg) { */ MatrixCall.prototype.onAnsweredElsewhere = function(msg) { console.log("Answered elsewhere"); - if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) { - this.getRemoteVideoElement().pause(); - } - if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) { - this.getLocalVideoElement().pause(); - } - this.state = 'ended'; - this.hangupParty = 'remote'; - this.hangupReason = "answered_elsewhere"; - stopAllMedia(this); - if (this.peerConn && this.peerConn.signalingState != 'closed') { - this.peerConn.close(); - } - this.emit("onHangup", this); + terminate(this, "remote", "answered_elsewhere", true); }; /** @@ -636,6 +593,26 @@ MatrixCall.prototype.sendCandidate = function(content) { } }; +var terminate = function(self, hangupParty, hangupReason, shouldEmit) { + if (self.getRemoteVideoElement() && self.getRemoteVideoElement().pause) { + self.getRemoteVideoElement().pause(); + } + if (self.getLocalVideoElement() && self.getLocalVideoElement().pause) { + self.getLocalVideoElement().pause(); + } + self.state = 'ended'; + self.hangupParty = hangupParty; + self.hangupReason = hangupReason; + stopAllMedia(self); + if (self.peerConn && + (hangupParty === "local" || self.peerConn.signalingState != 'closed')) { + self.peerConn.close(); + } + if (shouldEmit) { + self.emit("onHangup", self); + } +}; + var stopAllMedia = function(self) { if (self.localAVStream) { forAllTracksOnStream(self.localAVStream, function(t) { @@ -780,9 +757,19 @@ module.exports.MatrixCall = MatrixCall; */ module.exports.createNewMatrixCall = function(client, roomId) { var w = global.window; + var doc = global.document; var webRtc = {}; webRtc.isOpenWebRTC = function() { - // TODO + var scripts = doc.getElementById("script"); + if (!scripts || !scripts.length) { + return false; + } + for (var i = 0; i < scripts.length; i++) { + if (scripts[i].src.indexOf("owr.js") > -1) { + return true; + } + } + return false; }; var getUserMedia = ( w.navigator.getUserMedia || w.navigator.webkitGetUserMedia || From 7cce41df2e1f7f11f5884fe774d367824497869a Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 14 Jul 2015 10:48:19 +0100 Subject: [PATCH 03/10] Add structured errors and callbacks. Now sends candidates. --- lib/webrtc/call.js | 195 +++++++++++++++++++++++++++++---------------- 1 file changed, 127 insertions(+), 68 deletions(-) diff --git a/lib/webrtc/call.js b/lib/webrtc/call.js index 32e09384d..06a62c39d 100644 --- a/lib/webrtc/call.js +++ b/lib/webrtc/call.js @@ -5,20 +5,25 @@ */ var utils = require("../utils"); var EventEmitter = require("events").EventEmitter; +var DEBUG = true; // set true to enable console logging. -// events: onHangup, callPlaced +// events: onHangup, callPlaced, error /** * Construct a new Matrix Call. * @constructor * @param {Object} opts Config options. * @param {string} opts.roomId The room ID for this call. + * @param {Object} opts.webRtc The WebRTC globals from the browser. + * @param {Object} opts.URL The URL global. + * @param {Array} opts.turnServers Optional. A list of TURN servers. * @param {MatrixClient} opts.client The Matrix Client instance to send events to. */ function MatrixCall(opts) { this.roomId = opts.roomId; this.client = opts.client; this.webRtc = opts.webRtc; + this.URL = opts.URL; // Array of Objects with urls, username, credential keys this.turnServers = opts.turnServers || [{ urls: [MatrixCall.FALLBACK_STUN_SERVER] @@ -26,7 +31,6 @@ function MatrixCall(opts) { utils.forEach(this.turnServers, function(server) { utils.checkObjectHasKeys(server, ["urls"]); }); - this.URL = opts.URL; this.callId = "c" + new Date().getTime(); this.state = 'fledgling'; @@ -42,23 +46,39 @@ function MatrixCall(opts) { MatrixCall.CALL_TIMEOUT_MS = 60000; /** The fallback server to use for STUN. */ MatrixCall.FALLBACK_STUN_SERVER = 'stun:stun.l.google.com:19302'; +/** An error code when the local client failed to create an offer. */ +MatrixCall.ERR_LOCAL_OFFER_FAILED = "local_offer_failed"; +/** + * An error code when there is no local mic/camera to use. This may be because + * the hardware isn't plugged in, or the user has explicitly denied access. + */ +MatrixCall.ERR_NO_USER_MEDIA = "no_user_media"; utils.inherits(MatrixCall, EventEmitter); /** * Place a voice call to this room. + * @throws If you have not specified a listener for 'error' events. */ MatrixCall.prototype.placeVoiceCall = function() { + checkForErrorListener(this); _placeCallWithConstraints(this, _getUserMediaVideoContraints('voice')); this.type = 'voice'; }; /** * Place a video call to this room. + * @param {Element} localVideoElement a DOM element with the local camera preview. + * @param {Element} remoteVideoElement a DOM element to render video to. + * @throws If you have not specified a listener for 'error' events. */ -MatrixCall.prototype.placeVideoCall = function() { +MatrixCall.prototype.placeVideoCall = function(localVideoElement, remoteVideoElement) { + checkForErrorListener(this); + this.localVideoElement = localVideoElement; + this.remoteVideoElement = remoteVideoElement; _placeCallWithConstraints(this, _getUserMediaVideoContraints('video')); this.type = 'video'; + this.tryPlayRemoteStream(); }; /** @@ -66,7 +86,7 @@ MatrixCall.prototype.placeVideoCall = function() { * @return {Element} The dom element */ MatrixCall.prototype.getLocalVideoElement = function() { - return this.localVideoSelector; + return this.localVideoElement; }; /** @@ -74,7 +94,17 @@ MatrixCall.prototype.getLocalVideoElement = function() { * @return {Element} The dom element */ MatrixCall.prototype.getRemoteVideoElement = function() { - return this.remoteVideoSelector; + return this.remoteVideoElement; +}; + +/** + * Set the remote video DOM element. If this call is active, video will be + * rendered to it. + * @param {Element} element The DOM element. + */ +MatrixCall.prototype.setRemoteVideoElement = function(element) { + this.remoteVideoElement = element; + this.tryPlayRemoteStream(); }; /** @@ -88,12 +118,8 @@ MatrixCall.prototype.initWithInvite = function(event) { if (this.peerConn) { this.peerConn.setRemoteDescription( new this.webRtc.RtcSessionDescription(this.msg.offer), - function(s) { - self.onSetRemoteDescriptionSuccess(s); - }, - function(e) { - self.onSetRemoteDescriptionError(e); - } + hookCallback(self, self.onSetRemoteDescriptionSuccess), + hookCallback(self, self.onSetRemoteDescriptionError) ); } this.state = 'ringing'; @@ -140,20 +166,18 @@ MatrixCall.prototype.initWithHangup = function(event) { * Answer a call. */ MatrixCall.prototype.answer = function() { - console.log("Answering call " + this.callId); + debuglog("Answering call " + this.callId); var self = this; if (!this.localAVStream && !this.waitForLocalAVStream) { this.webRtc.getUserMedia( _getUserMediaVideoContraints(this.type), - function(stream) { - gotUserMediaForAnswer(self, stream); - }, - this.getUserMediaFailed + hookCallback(self, self.gotUserMediaForAnswer), + hookCallback(self, self.getUserMediaFailed) ); this.state = 'wait_local_media'; } else if (this.localAVStream) { - gotUserMediaForAnswer(this, this.localAVStream); + this.gotUserMediaForAnswer(this.localAVStream); } else if (this.waitForLocalAVStream) { this.state = 'wait_local_media'; } @@ -164,21 +188,21 @@ MatrixCall.prototype.answer = function() { * @param {MatrixCall} newCall The new call. */ MatrixCall.prototype.replacedBy = function(newCall) { - console.log(this.callId + " being replaced by " + newCall.callId); + debuglog(this.callId + " being replaced by " + newCall.callId); if (this.state == 'wait_local_media') { - console.log("Telling new call to wait for local media"); + debuglog("Telling new call to wait for local media"); newCall.waitForLocalAVStream = true; } else if (this.state == 'create_offer') { - console.log("Handing local stream to new call"); - gotUserMediaForAnswer(newCall, this.localAVStream); + debuglog("Handing local stream to new call"); + newCall.gotUserMediaForAnswer(this.localAVStream); delete(this.localAVStream); } else if (this.state == 'invite_sent') { - console.log("Handing local stream to new call"); - gotUserMediaForAnswer(newCall, this.localAVStream); + debuglog("Handing local stream to new call"); + newCall.gotUserMediaForAnswer(this.localAVStream); delete(this.localAVStream); } - newCall.localVideoSelector = this.localVideoSelector; - newCall.remoteVideoSelector = this.remoteVideoSelector; + newCall.localVideoElement = this.localVideoElement; + newCall.remoteVideoElement = this.remoteVideoElement; this.successor = newCall; this.hangup(true); }; @@ -189,7 +213,7 @@ MatrixCall.prototype.replacedBy = function(newCall) { * @param {boolean} suppressEvent True to suppress emitting an event. */ MatrixCall.prototype.hangup = function(reason, suppressEvent) { - console.log("Ending call " + this.callId); + debuglog("Ending call " + this.callId); terminate(this, "local", reason, !suppressEvent); var content = { version: 0, @@ -205,7 +229,7 @@ MatrixCall.prototype.hangup = function(reason, suppressEvent) { */ MatrixCall.prototype.gotUserMediaForInvite = function(stream) { if (this.successor) { - gotUserMediaForAnswer(this.successor, stream); + this.successor.gotUserMediaForAnswer(stream); return; } if (this.state == 'ended') { @@ -231,17 +255,21 @@ MatrixCall.prototype.gotUserMediaForInvite = function(stream) { for (var i = 0; i < audioTracks.length; i++) { audioTracks[i].enabled = true; } - this.peerConn = this._createPeerConnection(); + this.peerConn = _createPeerConnection(this); this.peerConn.addStream(stream); - this.peerConn.createOffer(function(d) { - self.gotLocalOffer(d); - }, function(e) { - self.getLocalOfferFailed(e); - }); + this.peerConn.createOffer( + hookCallback(self, self.gotLocalOffer), + hookCallback(self, self.getLocalOfferFailed) + ); self.state = 'create_offer'; }; -var gotUserMediaForAnswer = function(self, stream) { +/** + * Internal + * @param {Object} stream + */ +MatrixCall.prototype.gotUserMediaForAnswer = function(stream) { + var self = this; if (self.state == 'ended') { return; } @@ -273,7 +301,7 @@ var gotUserMediaForAnswer = function(self, stream) { }, }; self.peerConn.createAnswer(constraints, function(description) { - console.log("Created answer: " + description); + debuglog("Created answer: " + description); self.peerConn.setLocalDescription(description, function() { var content = { version: 0, @@ -286,7 +314,7 @@ var gotUserMediaForAnswer = function(self, stream) { self.sendEvent('m.call.answer', content); self.state = 'connecting'; }, function() { - console.log("Error setting local description!"); + debuglog("Error setting local description!"); }); }); self.state = 'create_answer'; @@ -298,7 +326,7 @@ var gotUserMediaForAnswer = function(self, stream) { */ MatrixCall.prototype.gotLocalIceCandidate = function(event) { if (event.candidate) { - console.log( + debuglog( "Got local ICE " + event.candidate.sdpMid + " candidate: " + event.candidate.candidate ); @@ -319,10 +347,10 @@ MatrixCall.prototype.gotLocalIceCandidate = function(event) { */ MatrixCall.prototype.gotRemoteIceCandidate = function(cand) { if (this.state == 'ended') { - //console.log("Ignoring remote ICE candidate because call has ended"); + //debuglog("Ignoring remote ICE candidate because call has ended"); return; } - console.log("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate); + debuglog("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate); this.peerConn.addIceCandidate( new this.webRtc.RtcIceCandidate(cand), function() {}, @@ -342,12 +370,8 @@ MatrixCall.prototype.receivedAnswer = function(msg) { var self = this; this.peerConn.setRemoteDescription( new this.webRtc.RtcSessionDescription(msg.answer), - function(s) { - self.onSetRemoteDescriptionSuccess(s); - }, - function(e) { - self.onSetRemoteDescriptionError(e); - } + hookCallback(self, self.onSetRemoteDescriptionSuccess), + hookCallback(self, self.onSetRemoteDescriptionError) ); this.state = 'connecting'; }; @@ -358,10 +382,10 @@ MatrixCall.prototype.receivedAnswer = function(msg) { */ MatrixCall.prototype.gotLocalOffer = function(description) { var self = this; - console.log("Created offer: " + description); + debuglog("Created offer: " + description); if (self.state == 'ended') { - console.log("Ignoring newly created offer on call ID " + self.callId + + debuglog("Ignoring newly created offer on call ID " + self.callId + " because the call has ended"); return; } @@ -394,7 +418,7 @@ MatrixCall.prototype.gotLocalOffer = function(description) { }, MatrixCall.CALL_TIMEOUT_MS); self.state = 'invite_sent'; }, function() { - console.log("Error setting local description!"); + debuglog("Error setting local description!"); }); }; @@ -403,16 +427,23 @@ MatrixCall.prototype.gotLocalOffer = function(description) { * @param {Object} error */ MatrixCall.prototype.getLocalOfferFailed = function(error) { - this.onError("Failed to start audio for call!"); + this.emit( + "error", + callError(MatrixCall.ERR_LOCAL_OFFER_FAILED, "Failed to start audio for call!") + ); }; /** * Internal */ MatrixCall.prototype.getUserMediaFailed = function() { - this.onError( - "Couldn't start capturing media! Is your microphone set up and does " + - "this app have permission?" + this.emit( + "error", + callError( + MatrixCall.ERR_NO_USER_MEDIA, + "Couldn't start capturing media! Is your microphone set up and " + + "does this app have permission?" + ) ); this.hangup(); }; @@ -424,7 +455,7 @@ MatrixCall.prototype.onIceConnectionStateChanged = function() { if (this.state == 'ended') { return; // because ICE can still complete as we're ending the call } - console.log( + debuglog( "Ice connection state changed to: " + this.peerConn.iceConnectionState ); // ideally we'd consider the call to be connected when we get media but @@ -442,7 +473,7 @@ MatrixCall.prototype.onIceConnectionStateChanged = function() { * Internal */ MatrixCall.prototype.onSignallingStateChanged = function() { - console.log( + debuglog( "call " + this.callId + ": Signalling state changed to: " + this.peerConn.signalingState ); @@ -452,7 +483,7 @@ MatrixCall.prototype.onSignallingStateChanged = function() { * Internal */ MatrixCall.prototype.onSetRemoteDescriptionSuccess = function() { - console.log("Set remote description"); + debuglog("Set remote description"); }; /** @@ -460,7 +491,7 @@ MatrixCall.prototype.onSetRemoteDescriptionSuccess = function() { * @param {Object} e */ MatrixCall.prototype.onSetRemoteDescriptionError = function(e) { - console.log("Failed to set remote description" + e); + debuglog("Failed to set remote description" + e); }; /** @@ -468,7 +499,7 @@ MatrixCall.prototype.onSetRemoteDescriptionError = function(e) { * @param {Object} event */ MatrixCall.prototype.onAddStream = function(event) { - console.log("Stream added" + event); + debuglog("Stream added" + event); var s = event.stream; @@ -531,7 +562,7 @@ MatrixCall.prototype.onRemoteStreamStarted = function(event) { * @param {Object} event */ MatrixCall.prototype.onRemoteStreamEnded = function(event) { - console.log("Remote stream ended"); + debuglog("Remote stream ended"); this.state = 'ended'; this.hangupParty = 'remote'; stopAllMedia(this); @@ -554,7 +585,7 @@ MatrixCall.prototype.onRemoteStreamTrackStarted = function(event) { * @param {Object} msg */ MatrixCall.prototype.onHangupReceived = function(msg) { - console.log("Hangup received"); + debuglog("Hangup received"); terminate(this, "remote", msg.reason, true); }; @@ -563,7 +594,7 @@ MatrixCall.prototype.onHangupReceived = function(msg) { * @param {Object} msg */ MatrixCall.prototype.onAnsweredElsewhere = function(msg) { - console.log("Answered elsewhere"); + debuglog("Answered elsewhere"); terminate(this, "remote", "answered_elsewhere", true); }; @@ -635,6 +666,26 @@ var stopAllMedia = function(self) { } }; +var checkForErrorListener = function(self) { + if (self.listeners("error").length === 0) { + throw new Error( + "You MUST attach an error listener using call.on('error', function() {})" + ); + } +}; + +var callError = function(code, msg) { + var e = new Error(msg); + e.code = code; + return e; +}; + +var debuglog = function() { + if (DEBUG) { + console.log.apply(console, arguments); + } +}; + var _sendCandidateQueue = function(self) { if (self.candidateSendQueue.length === 0) { return; @@ -648,7 +699,7 @@ var _sendCandidateQueue = function(self) { call_id: self.callId, candidates: cands }; - console.log("Attempting to send " + cands.length + " candidates"); + debuglog("Attempting to send " + cands.length + " candidates"); self.sendEvent('m.call.candidates', content).then(function() { self.candidateSendTries = 0; _sendCandidateQueue(self); @@ -658,7 +709,7 @@ var _sendCandidateQueue = function(self) { } if (self.candidateSendTries > 5) { - console.log( + debuglog( "Failed to send candidates on attempt %s. Giving up for now.", self.candidateSendTries ); @@ -668,7 +719,7 @@ var _sendCandidateQueue = function(self) { var delayMs = 500 * Math.pow(2, self.candidateSendTries); ++self.candidateSendTries; - console.log("Failed to send candidates. Retrying in " + delayMs + "ms"); + debuglog("Failed to send candidates. Retrying in " + delayMs + "ms"); setTimeout(function() { _sendCandidateQueue(self); }, delayMs); @@ -678,7 +729,9 @@ var _sendCandidateQueue = function(self) { var _placeCallWithConstraints = function(self, constraints) { self.emit("callPlaced", self); self.webRtc.getUserMedia( - constraints, self.gotUserMediaForInvite, self.getUserMediaFailed + constraints, + hookCallback(self, self.gotUserMediaForInvite), + hookCallback(self, self.getUserMediaFailed) ); self.state = 'wait_local_media'; self.direction = 'outbound'; @@ -704,10 +757,10 @@ var _createPeerConnection = function(self) { var pc = new self.webRtc.RtcPeerConnection({ iceServers: servers }); - pc.oniceconnectionstatechange = self.onIceConnectionStateChanged; - pc.onsignalingstatechange = self.onSignallingStateChanged; - pc.onicecandidate = self.gotLocalIceCandidate; - pc.onaddstream = self.onAddStream; + pc.oniceconnectionstatechange = hookCallback(self, self.onIceConnectionStateChanged); + pc.onsignalingstatechange = hookCallback(self, self.onSignallingStateChanged); + pc.onicecandidate = hookCallback(self, self.gotLocalIceCandidate); + pc.onaddstream = hookCallback(self, self.onAddStream); return pc; }; @@ -727,6 +780,12 @@ var _getUserMediaVideoContraints = function(callType) { } }; +var hookCallback = function(call, fn) { + return function() { + return fn.apply(call, arguments); + }; +}; + var forAllVideoTracksOnStream = function(s, f) { var tracks = s.getVideoTracks(); for (var i = 0; i < tracks.length; i++) { From 3e60842c3bcbc05b3cac4cdab35b14d2f4f926fe Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 14 Jul 2015 11:11:41 +0100 Subject: [PATCH 04/10] Sort out access levels for functions; add JSDoc. --- lib/webrtc/call.js | 191 ++++++++++++++++++++++++--------------------- package.json | 2 +- 2 files changed, 103 insertions(+), 90 deletions(-) diff --git a/lib/webrtc/call.js b/lib/webrtc/call.js index 06a62c39d..93478b32d 100644 --- a/lib/webrtc/call.js +++ b/lib/webrtc/call.js @@ -78,7 +78,7 @@ MatrixCall.prototype.placeVideoCall = function(localVideoElement, remoteVideoEle this.remoteVideoElement = remoteVideoElement; _placeCallWithConstraints(this, _getUserMediaVideoContraints('video')); this.type = 'video'; - this.tryPlayRemoteStream(); + _tryPlayRemoteStream(this); }; /** @@ -104,22 +104,23 @@ MatrixCall.prototype.getRemoteVideoElement = function() { */ MatrixCall.prototype.setRemoteVideoElement = function(element) { this.remoteVideoElement = element; - this.tryPlayRemoteStream(); + _tryPlayRemoteStream(this); }; /** - * Configure this call from an invite event. + * Configure this call from an invite event. Used by MatrixClient. + * @protected * @param {MatrixEvent} event The m.call.invite event */ -MatrixCall.prototype.initWithInvite = function(event) { +MatrixCall.prototype._initWithInvite = function(event) { this.msg = event.getContent(); this.peerConn = _createPeerConnection(this); var self = this; if (this.peerConn) { this.peerConn.setRemoteDescription( new this.webRtc.RtcSessionDescription(this.msg.offer), - hookCallback(self, self.onSetRemoteDescriptionSuccess), - hookCallback(self, self.onSetRemoteDescriptionError) + hookCallback(self, self._onSetRemoteDescriptionSuccess), + hookCallback(self, self._onSetRemoteDescriptionError) ); } this.state = 'ringing'; @@ -151,10 +152,11 @@ MatrixCall.prototype.initWithInvite = function(event) { }; /** - * Configure this call from a hangup event. + * Configure this call from a hangup event. Used by MatrixClient. + * @protected * @param {MatrixEvent} event The m.call.hangup event */ -MatrixCall.prototype.initWithHangup = function(event) { +MatrixCall.prototype._initWithHangup = function(event) { // perverse as it may seem, sometimes we want to instantiate a call with a // hangup message (because when getting the state of the room on load, events // come in reverse order and we want to remember that a call has been hung up) @@ -172,33 +174,35 @@ MatrixCall.prototype.answer = function() { if (!this.localAVStream && !this.waitForLocalAVStream) { this.webRtc.getUserMedia( _getUserMediaVideoContraints(this.type), - hookCallback(self, self.gotUserMediaForAnswer), + hookCallback(self, self._gotUserMediaForAnswer), hookCallback(self, self.getUserMediaFailed) ); this.state = 'wait_local_media'; } else if (this.localAVStream) { - this.gotUserMediaForAnswer(this.localAVStream); + this._gotUserMediaForAnswer(this.localAVStream); } else if (this.waitForLocalAVStream) { this.state = 'wait_local_media'; } }; /** - * Replace this call with a new call, e.g. for glare resolution. + * Replace this call with a new call, e.g. for glare resolution. Used by + * MatrixClient. + * @protected * @param {MatrixCall} newCall The new call. */ -MatrixCall.prototype.replacedBy = function(newCall) { +MatrixCall.prototype._replacedBy = function(newCall) { debuglog(this.callId + " being replaced by " + newCall.callId); if (this.state == 'wait_local_media') { debuglog("Telling new call to wait for local media"); newCall.waitForLocalAVStream = true; } else if (this.state == 'create_offer') { debuglog("Handing local stream to new call"); - newCall.gotUserMediaForAnswer(this.localAVStream); + newCall._gotUserMediaForAnswer(this.localAVStream); delete(this.localAVStream); } else if (this.state == 'invite_sent') { debuglog("Handing local stream to new call"); - newCall.gotUserMediaForAnswer(this.localAVStream); + newCall._gotUserMediaForAnswer(this.localAVStream); delete(this.localAVStream); } newCall.localVideoElement = this.localVideoElement; @@ -220,16 +224,17 @@ MatrixCall.prototype.hangup = function(reason, suppressEvent) { call_id: this.callId, reason: reason }; - this.sendEvent('m.call.hangup', content); + sendEvent(this, 'm.call.hangup', content); }; /** * Internal + * @private * @param {Object} stream */ -MatrixCall.prototype.gotUserMediaForInvite = function(stream) { +MatrixCall.prototype._gotUserMediaForInvite = function(stream) { if (this.successor) { - this.successor.gotUserMediaForAnswer(stream); + this.successor._gotUserMediaForAnswer(stream); return; } if (this.state == 'ended') { @@ -258,17 +263,18 @@ MatrixCall.prototype.gotUserMediaForInvite = function(stream) { this.peerConn = _createPeerConnection(this); this.peerConn.addStream(stream); this.peerConn.createOffer( - hookCallback(self, self.gotLocalOffer), - hookCallback(self, self.getLocalOfferFailed) + hookCallback(self, self._gotLocalOffer), + hookCallback(self, self._getLocalOfferFailed) ); self.state = 'create_offer'; }; /** * Internal + * @private * @param {Object} stream */ -MatrixCall.prototype.gotUserMediaForAnswer = function(stream) { +MatrixCall.prototype._gotUserMediaForAnswer = function(stream) { var self = this; if (self.state == 'ended') { return; @@ -311,7 +317,7 @@ MatrixCall.prototype.gotUserMediaForAnswer = function(stream) { type: self.peerConn.localDescription.type } }; - self.sendEvent('m.call.answer', content); + sendEvent(self, 'm.call.answer', content); self.state = 'connecting'; }, function() { debuglog("Error setting local description!"); @@ -322,9 +328,10 @@ MatrixCall.prototype.gotUserMediaForAnswer = function(stream) { /** * Internal + * @private * @param {Object} event */ -MatrixCall.prototype.gotLocalIceCandidate = function(event) { +MatrixCall.prototype._gotLocalIceCandidate = function(event) { if (event.candidate) { debuglog( "Got local ICE " + event.candidate.sdpMid + " candidate: " + @@ -337,15 +344,16 @@ MatrixCall.prototype.gotLocalIceCandidate = function(event) { sdpMid: event.candidate.sdpMid, sdpMLineIndex: event.candidate.sdpMLineIndex }; - this.sendCandidate(c); + sendCandidate(this, c); } }; /** - * Internal + * Used by MatrixClient. + * @protected * @param {Object} cand */ -MatrixCall.prototype.gotRemoteIceCandidate = function(cand) { +MatrixCall.prototype._gotRemoteIceCandidate = function(cand) { if (this.state == 'ended') { //debuglog("Ignoring remote ICE candidate because call has ended"); return; @@ -359,10 +367,11 @@ MatrixCall.prototype.gotRemoteIceCandidate = function(cand) { }; /** - * Internal + * Used by MatrixClient. + * @protected * @param {Object} msg */ -MatrixCall.prototype.receivedAnswer = function(msg) { +MatrixCall.prototype._receivedAnswer = function(msg) { if (this.state == 'ended') { return; } @@ -370,17 +379,18 @@ MatrixCall.prototype.receivedAnswer = function(msg) { var self = this; this.peerConn.setRemoteDescription( new this.webRtc.RtcSessionDescription(msg.answer), - hookCallback(self, self.onSetRemoteDescriptionSuccess), - hookCallback(self, self.onSetRemoteDescriptionError) + hookCallback(self, self._onSetRemoteDescriptionSuccess), + hookCallback(self, self._onSetRemoteDescriptionError) ); this.state = 'connecting'; }; /** * Internal + * @private * @param {Object} description */ -MatrixCall.prototype.gotLocalOffer = function(description) { +MatrixCall.prototype._gotLocalOffer = function(description) { var self = this; debuglog("Created offer: " + description); @@ -409,7 +419,7 @@ MatrixCall.prototype.gotLocalOffer = function(description) { }, lifetime: MatrixCall.CALL_TIMEOUT_MS }; - self.sendEvent('m.call.invite', content); + sendEvent(self, 'm.call.invite', content); setTimeout(function() { if (self.state == 'invite_sent') { @@ -424,9 +434,10 @@ MatrixCall.prototype.gotLocalOffer = function(description) { /** * Internal + * @private * @param {Object} error */ -MatrixCall.prototype.getLocalOfferFailed = function(error) { +MatrixCall.prototype._getLocalOfferFailed = function(error) { this.emit( "error", callError(MatrixCall.ERR_LOCAL_OFFER_FAILED, "Failed to start audio for call!") @@ -435,8 +446,9 @@ MatrixCall.prototype.getLocalOfferFailed = function(error) { /** * Internal + * @private */ -MatrixCall.prototype.getUserMediaFailed = function() { +MatrixCall.prototype._getUserMediaFailed = function() { this.emit( "error", callError( @@ -450,8 +462,9 @@ MatrixCall.prototype.getUserMediaFailed = function() { /** * Internal + * @private */ -MatrixCall.prototype.onIceConnectionStateChanged = function() { +MatrixCall.prototype._onIceConnectionStateChanged = function() { if (this.state == 'ended') { return; // because ICE can still complete as we're ending the call } @@ -471,8 +484,9 @@ MatrixCall.prototype.onIceConnectionStateChanged = function() { /** * Internal + * @private */ -MatrixCall.prototype.onSignallingStateChanged = function() { +MatrixCall.prototype._onSignallingStateChanged = function() { debuglog( "call " + this.callId + ": Signalling state changed to: " + this.peerConn.signalingState @@ -481,24 +495,27 @@ MatrixCall.prototype.onSignallingStateChanged = function() { /** * Internal + * @private */ -MatrixCall.prototype.onSetRemoteDescriptionSuccess = function() { +MatrixCall.prototype._onSetRemoteDescriptionSuccess = function() { debuglog("Set remote description"); }; /** * Internal + * @private * @param {Object} e */ -MatrixCall.prototype.onSetRemoteDescriptionError = function(e) { +MatrixCall.prototype._onSetRemoteDescriptionError = function(e) { debuglog("Failed to set remote description" + e); }; /** * Internal + * @private * @param {Object} event */ -MatrixCall.prototype.onAddStream = function(event) { +MatrixCall.prototype._onAddStream = function(event) { debuglog("Stream added" + event); var s = event.stream; @@ -516,52 +533,31 @@ MatrixCall.prototype.onAddStream = function(event) { var self = this; forAllTracksOnStream(s, function(t) { // not currently implemented in chrome - t.onstarted = self.onRemoteStreamTrackStarted; + t.onstarted = hookCallback(self, self._onRemoteStreamTrackStarted); }); - event.stream.onended = function(e) { self.onRemoteStreamEnded(e); }; + event.stream.onended = hookCallback(self, self._onRemoteStreamEnded); // not currently implemented in chrome - event.stream.onstarted = function(e) { self.onRemoteStreamStarted(e); }; + event.stream.onstarted = hookCallback(self, self._onRemoteStreamStarted); - this.tryPlayRemoteStream(); + _tryPlayRemoteStream(this); }; /** * Internal + * @private * @param {Object} event */ -MatrixCall.prototype.tryPlayRemoteStream = function(event) { - if (this.getRemoteVideoElement() && this.remoteAVStream) { - var player = this.getRemoteVideoElement(); - player.autoplay = true; - player.src = this.URL.createObjectURL(this.remoteAVStream); - var self = this; - setTimeout(function() { - var vel = self.getRemoteVideoElement(); - if (vel.play) { - vel.play(); - } - // OpenWebRTC does not support oniceconnectionstatechange yet - if (self.webRtc.isOpenWebRTC()) { - self.state = 'connected'; - } - }, 0); - } -}; - -/** - * Internal - * @param {Object} event - */ -MatrixCall.prototype.onRemoteStreamStarted = function(event) { +MatrixCall.prototype._onRemoteStreamStarted = function(event) { this.state = 'connected'; }; /** * Internal + * @private * @param {Object} event */ -MatrixCall.prototype.onRemoteStreamEnded = function(event) { +MatrixCall.prototype._onRemoteStreamEnded = function(event) { debuglog("Remote stream ended"); this.state = 'ended'; this.hangupParty = 'remote'; @@ -574,50 +570,49 @@ MatrixCall.prototype.onRemoteStreamEnded = function(event) { /** * Internal + * @private * @param {Object} event */ -MatrixCall.prototype.onRemoteStreamTrackStarted = function(event) { +MatrixCall.prototype._onRemoteStreamTrackStarted = function(event) { this.state = 'connected'; }; /** - * Internal + * Used by MatrixClient. + * @protected * @param {Object} msg */ -MatrixCall.prototype.onHangupReceived = function(msg) { +MatrixCall.prototype._onHangupReceived = function(msg) { debuglog("Hangup received"); terminate(this, "remote", msg.reason, true); }; /** - * Internal + * Used by MatrixClient. + * @protected * @param {Object} msg */ -MatrixCall.prototype.onAnsweredElsewhere = function(msg) { +MatrixCall.prototype._onAnsweredElsewhere = function(msg) { debuglog("Answered elsewhere"); terminate(this, "remote", "answered_elsewhere", true); }; /** * Internal + * @param {MatrixCall} self * @param {string} eventType * @param {Object} content * @return {Promise} */ -MatrixCall.prototype.sendEvent = function(eventType, content) { - return this.client.sendEvent(this.roomId, eventType, content); +var sendEvent = function(self, eventType, content) { + return self.client.sendEvent(self.roomId, eventType, content); }; -/** - * Internal - * @param {Object} content - */ -MatrixCall.prototype.sendCandidate = function(content) { +var sendCandidate = function(self, content) { // Sends candidates with are sent in a special way because we try to amalgamate // them into one message - this.candidateSendQueue.push(content); - var self = this; - if (this.candidateSendTries === 0) { + self.candidateSendQueue.push(content); + if (self.candidateSendTries === 0) { setTimeout(function() { _sendCandidateQueue(self); }, 100); @@ -666,6 +661,24 @@ var stopAllMedia = function(self) { } }; +var _tryPlayRemoteStream = function(self) { + if (self.getRemoteVideoElement() && self.remoteAVStream) { + var player = self.getRemoteVideoElement(); + player.autoplay = true; + player.src = self.URL.createObjectURL(self.remoteAVStream); + setTimeout(function() { + var vel = self.getRemoteVideoElement(); + if (vel.play) { + vel.play(); + } + // OpenWebRTC does not support oniceconnectionstatechange yet + if (self.webRtc.isOpenWebRTC()) { + self.state = 'connected'; + } + }, 0); + } +}; + var checkForErrorListener = function(self) { if (self.listeners("error").length === 0) { throw new Error( @@ -700,7 +713,7 @@ var _sendCandidateQueue = function(self) { candidates: cands }; debuglog("Attempting to send " + cands.length + " candidates"); - self.sendEvent('m.call.candidates', content).then(function() { + sendEvent(self, 'm.call.candidates', content).then(function() { self.candidateSendTries = 0; _sendCandidateQueue(self); }, function(error) { @@ -730,8 +743,8 @@ var _placeCallWithConstraints = function(self, constraints) { self.emit("callPlaced", self); self.webRtc.getUserMedia( constraints, - hookCallback(self, self.gotUserMediaForInvite), - hookCallback(self, self.getUserMediaFailed) + hookCallback(self, self._gotUserMediaForInvite), + hookCallback(self, self._getUserMediaFailed) ); self.state = 'wait_local_media'; self.direction = 'outbound'; @@ -757,10 +770,10 @@ var _createPeerConnection = function(self) { var pc = new self.webRtc.RtcPeerConnection({ iceServers: servers }); - pc.oniceconnectionstatechange = hookCallback(self, self.onIceConnectionStateChanged); - pc.onsignalingstatechange = hookCallback(self, self.onSignallingStateChanged); - pc.onicecandidate = hookCallback(self, self.gotLocalIceCandidate); - pc.onaddstream = hookCallback(self, self.onAddStream); + pc.oniceconnectionstatechange = hookCallback(self, self._onIceConnectionStateChanged); + pc.onsignalingstatechange = hookCallback(self, self._onSignallingStateChanged); + pc.onicecandidate = hookCallback(self, self._gotLocalIceCandidate); + pc.onaddstream = hookCallback(self, self._onAddStream); return pc; }; diff --git a/package.json b/package.json index f3ef9769d..0933a1e2b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "istanbul cover --report cobertura --config .istanbul.yml -i \"lib/**/*.js\" jasmine-node -- spec --verbose --junitreport --forceexit --captureExceptions", "build": "jshint -c .jshint lib/ && browserify browser-index.js -o dist/browser-matrix-dev.js", "watch": "watchify browser-index.js -o dist/browser-matrix-dev.js -v", - "lint": "jshint -c .jshint lib spec && gjslint --unix_mode --disable 0131,0211,0200 --max_line_length 90 -r spec/ -r lib/", + "lint": "jshint -c .jshint lib spec && gjslint --unix_mode --disable 0131,0211,0200,0222 --max_line_length 90 -r spec/ -r lib/", "release": "npm run build && mkdir dist/$npm_package_version && uglifyjs -c -m -o dist/$npm_package_version/browser-matrix-$npm_package_version.min.js dist/browser-matrix-dev.js && cp dist/browser-matrix-dev.js dist/$npm_package_version/browser-matrix-$npm_package_version.js" }, "repository": { From 8a41504cbbfc702c3f7c1e3a84221c4f5c02cd9b Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 14 Jul 2015 16:06:22 +0100 Subject: [PATCH 05/10] Glue in call handling into MatrixClient. Outbound calls work. --- lib/client.js | 177 +++++++++++++++++++++++++++++++++++++++++++++ lib/webrtc/call.js | 24 +++--- 2 files changed, 192 insertions(+), 9 deletions(-) diff --git a/lib/client.js b/lib/client.js index 856ecb3ad..5462467e3 100644 --- a/lib/client.js +++ b/lib/client.js @@ -15,6 +15,7 @@ var EventStatus = require("./models/event").EventStatus; var StubStore = require("./store/stub"); var Room = require("./models/room"); var User = require("./models/user"); +var webRtcCall = require("./webrtc/call"); var utils = require("./utils"); // TODO: @@ -73,6 +74,17 @@ function MatrixClient(opts) { this._syncingRooms = { // room_id: Promise }; + this.callList = { + // callId: MatrixCall + }; + + // try constructing a MatrixCall to see if we are running in an environment + // which has WebRTC. If we are, listen for and handle m.call.* events. + var call = webRtcCall.createNewMatrixCall(this); + if (call) { + setupCallEventHandler(this); + } + } utils.inherits(MatrixClient, EventEmitter); @@ -1335,6 +1347,161 @@ function reEmit(reEmitEntity, emittableEntity, eventNames) { }); } +function setupCallEventHandler(client) { + var candidatesByCall = { + // callId: [Candidate] + }; + client.on("event", function(event) { + if (event.getType().indexOf("m.call.") !== 0) { + return; // not a call event + } + var content = event.getContent(); + var call = content.call_id ? client.callList[content.call_id] : undefined; + var i; + + if (event.getType() === "m.call.invite") { + if (event.getSender() === client.credentials.userId) { + return; // ignore invites you send + } + + if (event.getAge() > content.lifetime) { + return; // expired call + } + + if (call && call.state === "ended") { + return; // stale/old invite event + } + if (call) { + console.log( + "WARN: Already have a MatrixCall with id %s but got an " + + "invite. Clobbering.", + content.call_id + ); + } + + call = webRtcCall.createNewMatrixCall(client, event.getRoomId()); + if (!call) { + console.log( + "Incoming call ID " + content.call_id + " but this client " + + "doesn't support WebRTC" + ); + // don't hang up the call: there could be other clients + // connected that do support WebRTC and declining the + // the call on their behalf would be really annoying. + return; + } + + call.callId = content.call_id; + call._initWithInvite(event); + client.callList[call.callId] = call; + + // if we stashed candidate events for that call ID, play them back now + if (candidatesByCall[call.callId]) { + for (i = 0; i < candidatesByCall[call.callId].length; i++) { + call._gotRemoteIceCandidate( + candidatesByCall[call.callId][i] + ); + } + } + + // Were we trying to call that user (room)? + var existingCall; + var existingCalls = utils.values(client.callList); + for (i = 0; i < existingCalls.length; ++i) { + var thisCall = existingCalls[i]; + if (call.room_id === thisCall.room_id && + thisCall.direction === 'outbound' && + (["wait_local_media", "create_offer", "invite_sent"].indexOf( + thisCall.state) !== -1)) { + existingCall = thisCall; + break; + } + } + + if (existingCall) { + // If we've only got to wait_local_media or create_offer and + // we've got an invite, pick the incoming call because we know + // we haven't sent our invite yet otherwise, pick whichever + // call has the lowest call ID (by string comparison) + if (existingCall.state === 'wait_local_media' || + existingCall.state === 'create_offer' || + existingCall.callId > call.callId) { + console.log( + "Glare detected: answering incoming call " + call.callId + + " and canceling outgoing call " + existingCall.callId + ); + existingCall._replacedBy(call); + call.answer(); + } + else { + console.log( + "Glare detected: rejecting incoming call " + call.callId + + " and keeping outgoing call " + existingCall.callId + ); + call.hangup(); + } + } + else { + client.emit("Call.incoming", call); + } + } + else if (event.getType() === 'm.call.answer') { + if (!call) { + console.log("Got answer for unknown call ID " + content.call_id); + return; + } + if (event.getSender() === client.credentials.userId) { + if (call.state === 'ringing') { + call._onAnsweredElsewhere(content); + } + } + else { + call._receivedAnswer(content); + } + } + else if (event.getType() === 'm.call.candidates') { + if (event.getSender() === client.credentials.userId) { + return; + } + if (!call) { + // store the candidates; we may get a call eventually. + if (!candidatesByCall[content.call_id]) { + candidatesByCall[content.call_id] = []; + } + candidatesByCall[content.call_id] = candidatesByCall[ + content.call_id + ].concat(content.candidates); + } + else { + for (i = 0; i < content.candidates.length; i++) { + call._gotRemoteIceCandidate(content.candidates[i]); + } + } + } + else if (event.getType() === 'm.call.hangup') { + // Note that we also observe our own hangups here so we can see + // if we've already rejected a call that would otherwise be valid + if (!call) { + // if not live, store the fact that the call has ended because + // we're probably getting events backwards so + // the hangup will come before the invite + call = webRtcCall.createNewMatrixCall(client, event.getRoomId()); + if (call) { + call.callId = content.call_id; + call._initWithHangup(event); + client.callList[content.call_id] = call; + } + } + else { + if (call.state !== 'ended') { + call._onHangupReceived(content); + client.callList[content.call_id] = undefined; // delete the call + } + } + } + }); +} + function createNewUser(client, userId) { var user = new User(userId); reEmit(client, user, ["User.avatarUrl", "User.displayName", "User.presence"]); @@ -1429,6 +1596,16 @@ module.exports.MatrixClient = MatrixClient; * }); */ +/** + * Fires whenever an incoming call arrives. + * @event module:client~MatrixClient#"Call.incoming" + * @param {MatrixCall} call The incoming call. + * @example + * matrixClient.on("Call.incoming", function(call){ + * call.answer(); // auto-answer + * }); + */ + // EventEmitter JSDocs /** diff --git a/lib/webrtc/call.js b/lib/webrtc/call.js index 93478b32d..e23f1657b 100644 --- a/lib/webrtc/call.js +++ b/lib/webrtc/call.js @@ -7,7 +7,7 @@ var utils = require("../utils"); var EventEmitter = require("events").EventEmitter; var DEBUG = true; // set true to enable console logging. -// events: onHangup, callPlaced, error +// events: onHangup, error, replaced /** * Construct a new Matrix Call. @@ -68,8 +68,10 @@ MatrixCall.prototype.placeVoiceCall = function() { /** * Place a video call to this room. - * @param {Element} localVideoElement a DOM element with the local camera preview. - * @param {Element} remoteVideoElement a DOM element to render video to. + * @param {Element} localVideoElement a <video> DOM element + * to render the local camera preview. + * @param {Element} remoteVideoElement a <video> DOM element + * to render video to. * @throws If you have not specified a listener for 'error' events. */ MatrixCall.prototype.placeVideoCall = function(localVideoElement, remoteVideoElement) { @@ -82,7 +84,7 @@ MatrixCall.prototype.placeVideoCall = function(localVideoElement, remoteVideoEle }; /** - * Retrieve the local video DOM element. + * Retrieve the local <video> DOM element. * @return {Element} The dom element */ MatrixCall.prototype.getLocalVideoElement = function() { @@ -90,7 +92,7 @@ MatrixCall.prototype.getLocalVideoElement = function() { }; /** - * Retrieve the remote video DOM element. + * Retrieve the remote <video> DOM element. * @return {Element} The dom element */ MatrixCall.prototype.getRemoteVideoElement = function() { @@ -98,9 +100,9 @@ MatrixCall.prototype.getRemoteVideoElement = function() { }; /** - * Set the remote video DOM element. If this call is active, video will be - * rendered to it. - * @param {Element} element The DOM element. + * Set the remote <video> DOM element. If this call is active, + * video will be rendered to it immediately. + * @param {Element} element The <video> DOM element. */ MatrixCall.prototype.setRemoteVideoElement = function(element) { this.remoteVideoElement = element; @@ -208,6 +210,7 @@ MatrixCall.prototype._replacedBy = function(newCall) { newCall.localVideoElement = this.localVideoElement; newCall.remoteVideoElement = this.remoteVideoElement; this.successor = newCall; + this.emit("replaced", newCall); this.hangup(true); }; @@ -740,7 +743,7 @@ var _sendCandidateQueue = function(self) { }; var _placeCallWithConstraints = function(self, constraints) { - self.emit("callPlaced", self); + self.client.callList[self.callId] = self; self.webRtc.getUserMedia( constraints, hookCallback(self, self._gotUserMediaForInvite), @@ -830,6 +833,9 @@ module.exports.MatrixCall = MatrixCall; module.exports.createNewMatrixCall = function(client, roomId) { var w = global.window; var doc = global.document; + if (!w || !doc) { + return null; + } var webRtc = {}; webRtc.isOpenWebRTC = function() { var scripts = doc.getElementById("script"); From 053a5b1beaad970554772ae6ac7340fe3d40461b Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 14 Jul 2015 16:23:31 +0100 Subject: [PATCH 06/10] Make inbound calls work. --- lib/client.js | 2 +- lib/webrtc/call.js | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/client.js b/lib/client.js index 5462467e3..613d85b88 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1495,7 +1495,7 @@ function setupCallEventHandler(client) { else { if (call.state !== 'ended') { call._onHangupReceived(content); - client.callList[content.call_id] = undefined; // delete the call + delete client.callList[content.call_id]; } } } diff --git a/lib/webrtc/call.js b/lib/webrtc/call.js index e23f1657b..753a2dff7 100644 --- a/lib/webrtc/call.js +++ b/lib/webrtc/call.js @@ -68,13 +68,13 @@ MatrixCall.prototype.placeVoiceCall = function() { /** * Place a video call to this room. - * @param {Element} localVideoElement a <video> DOM element - * to render the local camera preview. * @param {Element} remoteVideoElement a <video> DOM element * to render video to. + * @param {Element} localVideoElement a <video> DOM element + * to render the local camera preview. * @throws If you have not specified a listener for 'error' events. */ -MatrixCall.prototype.placeVideoCall = function(localVideoElement, remoteVideoElement) { +MatrixCall.prototype.placeVideoCall = function(remoteVideoElement, localVideoElement) { checkForErrorListener(this); this.localVideoElement = localVideoElement; this.remoteVideoElement = remoteVideoElement; @@ -309,7 +309,7 @@ MatrixCall.prototype._gotUserMediaForAnswer = function(stream) { 'OfferToReceiveVideo': self.type == 'video' }, }; - self.peerConn.createAnswer(constraints, function(description) { + self.peerConn.createAnswer(function(description) { debuglog("Created answer: " + description); self.peerConn.setLocalDescription(description, function() { var content = { @@ -324,7 +324,7 @@ MatrixCall.prototype._gotUserMediaForAnswer = function(stream) { self.state = 'connecting'; }, function() { debuglog("Error setting local description!"); - }); + }, constraints); }); self.state = 'create_answer'; }; From e3fdcaaff53a1997f69d99749c146704f585f598 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 14 Jul 2015 17:11:30 +0100 Subject: [PATCH 07/10] Add noddy voip example app. --- examples/voip/README.md | 9 ++++ examples/voip/browserTest.js | 89 ++++++++++++++++++++++++++++++++++++ examples/voip/index.html | 26 +++++++++++ examples/voip/lib/matrix.js | 1 + lib/webrtc/call.js | 8 ++-- 5 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 examples/voip/README.md create mode 100644 examples/voip/browserTest.js create mode 100644 examples/voip/index.html create mode 120000 examples/voip/lib/matrix.js diff --git a/examples/voip/README.md b/examples/voip/README.md new file mode 100644 index 000000000..1253d8000 --- /dev/null +++ b/examples/voip/README.md @@ -0,0 +1,9 @@ +To try it out, **you must build the SDK first** and then host this folder: + +``` + $ npm run build + $ cd examples/browser + $ python -m SimpleHTTPServer 8003 +``` + +Then visit ``http://localhost:8003``. diff --git a/examples/voip/browserTest.js b/examples/voip/browserTest.js new file mode 100644 index 000000000..53b19920c --- /dev/null +++ b/examples/voip/browserTest.js @@ -0,0 +1,89 @@ +"use strict"; +console.log("Loading browser sdk"); +var BASE_URL = "https://matrix.org"; +var TOKEN = "accesstokengoeshere"; +var USER_ID = "@username:localhost"; +var ROOM_ID = "!room:id"; + + +var client = matrixcs.createClient({ + baseUrl: BASE_URL, + accessToken: TOKEN, + userId: USER_ID +}); +var call; + +function disableButtons(place, answer, hangup) { + document.getElementById("hangup").disabled = hangup; + document.getElementById("answer").disabled = answer; + document.getElementById("call").disabled = place; +} + +function addListeners(call) { + var lastError = ""; + call.on("hangup", function() { + disableButtons(false, true, true); + document.getElementById("result").innerHTML = ( + "

Call ended. Last error: "+lastError+"

" + ); + }); + call.on("error", function(err) { + lastError = err.message; + call.hangup(); + disableButtons(false, true, true); + }); +} + +window.onload = function() { + document.getElementById("result").innerHTML = "

Please wait. Syncing...

"; + document.getElementById("config").innerHTML = "

" + + "Homeserver: "+BASE_URL+"
"+ + "Room: "+ROOM_ID+"
"+ + "User: "+USER_ID+"
"+ + "

"; + disableButtons(true, true, true); +}; + +client.on("syncComplete", function () { + document.getElementById("result").innerHTML = "

Ready for calls.

"; + disableButtons(false, true, true); + + document.getElementById("call").onclick = function() { + console.log("Placing call..."); + call = matrixcs.createNewMatrixCall( + client, ROOM_ID + ); + console.log("Call => %s", call); + addListeners(call); + call.placeVideoCall( + document.getElementById("remote"), + document.getElementById("local") + ); + document.getElementById("result").innerHTML = "

Placed call.

"; + disableButtons(true, true, false); + }; + + document.getElementById("hangup").onclick = function() { + console.log("Hanging up call..."); + console.log("Call => %s", call); + call.hangup(); + document.getElementById("result").innerHTML = "

Hungup call.

"; + }; + + document.getElementById("answer").onclick = function() { + console.log("Answering call..."); + console.log("Call => %s", call); + call.answer(); + disableButtons(true, true, false); + document.getElementById("result").innerHTML = "

Answered call.

"; + }; + + client.on("Call.incoming", function(c) { + console.log("Call ringing"); + disableButtons(true, false, false); + document.getElementById("result").innerHTML = "

Incoming call...

"; + call = c; + addListeners(call); + }); +}); +client.startClient(); diff --git a/examples/voip/index.html b/examples/voip/index.html new file mode 100644 index 000000000..a3259cfa1 --- /dev/null +++ b/examples/voip/index.html @@ -0,0 +1,26 @@ + + +VoIP Test + + + + + You can place and receive calls with this example. Make sure to edit the + constants in browserTest.js first. +
+
+ + + +
+
+ +
+
+
+
+ +
+
+ + diff --git a/examples/voip/lib/matrix.js b/examples/voip/lib/matrix.js new file mode 120000 index 000000000..e4b1b6ecb --- /dev/null +++ b/examples/voip/lib/matrix.js @@ -0,0 +1 @@ +../../../dist/browser-matrix-dev.js \ No newline at end of file diff --git a/lib/webrtc/call.js b/lib/webrtc/call.js index 753a2dff7..3ae8eeebe 100644 --- a/lib/webrtc/call.js +++ b/lib/webrtc/call.js @@ -7,7 +7,7 @@ var utils = require("../utils"); var EventEmitter = require("events").EventEmitter; var DEBUG = true; // set true to enable console logging. -// events: onHangup, error, replaced +// events: hangup, error, replaced /** * Construct a new Matrix Call. @@ -147,7 +147,7 @@ MatrixCall.prototype._initWithInvite = function(event) { if (self.peerConn.signalingState != 'closed') { self.peerConn.close(); } - self.emit("onHangup", self); + self.emit("hangup", self); } }, this.msg.lifetime - event.getAge()); } @@ -568,7 +568,7 @@ MatrixCall.prototype._onRemoteStreamEnded = function(event) { if (this.peerConn.signalingState != 'closed') { this.peerConn.close(); } - this.emit("onHangup", this); + this.emit("hangup", this); }; /** @@ -638,7 +638,7 @@ var terminate = function(self, hangupParty, hangupReason, shouldEmit) { self.peerConn.close(); } if (shouldEmit) { - self.emit("onHangup", self); + self.emit("hangup", self); } }; From 4cf0e10c02cff6493d77a5ad9de7bafe433b84f3 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 14 Jul 2015 17:55:55 +0100 Subject: [PATCH 08/10] Bug fixes. Everything should be working now. --- lib/webrtc/call.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/webrtc/call.js b/lib/webrtc/call.js index 3ae8eeebe..92318e296 100644 --- a/lib/webrtc/call.js +++ b/lib/webrtc/call.js @@ -177,7 +177,7 @@ MatrixCall.prototype.answer = function() { this.webRtc.getUserMedia( _getUserMediaVideoContraints(this.type), hookCallback(self, self._gotUserMediaForAnswer), - hookCallback(self, self.getUserMediaFailed) + hookCallback(self, self._getUserMediaFailed) ); this.state = 'wait_local_media'; } else if (this.localAVStream) { @@ -460,7 +460,7 @@ MatrixCall.prototype._getUserMediaFailed = function() { "does this app have permission?" ) ); - this.hangup(); + this.hangup("user_media_failed"); }; /** @@ -633,8 +633,7 @@ var terminate = function(self, hangupParty, hangupReason, shouldEmit) { self.hangupParty = hangupParty; self.hangupReason = hangupReason; stopAllMedia(self); - if (self.peerConn && - (hangupParty === "local" || self.peerConn.signalingState != 'closed')) { + if (self.peerConn && self.peerConn.signalingState !== 'closed') { self.peerConn.close(); } if (shouldEmit) { From be1264d5c3372ce191a7b9739eda220f2647bcf7 Mon Sep 17 00:00:00 2001 From: Kegsay Date: Wed, 15 Jul 2015 09:47:25 +0100 Subject: [PATCH 09/10] Update README.md --- examples/voip/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/voip/README.md b/examples/voip/README.md index 1253d8000..c3f3d67dd 100644 --- a/examples/voip/README.md +++ b/examples/voip/README.md @@ -2,7 +2,7 @@ To try it out, **you must build the SDK first** and then host this folder: ``` $ npm run build - $ cd examples/browser + $ cd examples/voip $ python -m SimpleHTTPServer 8003 ``` From 9f3f33e2cc01bbd50d37bebb8e0a1e8c20ca321c Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 15 Jul 2015 10:16:43 +0100 Subject: [PATCH 10/10] s/Safari/OpenWebRTC/ --- lib/webrtc/call.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/webrtc/call.js b/lib/webrtc/call.js index 92318e296..501d7bc89 100644 --- a/lib/webrtc/call.js +++ b/lib/webrtc/call.js @@ -128,7 +128,7 @@ MatrixCall.prototype._initWithInvite = function(event) { this.state = 'ringing'; this.direction = 'inbound'; - // firefox and Safari's RTCPeerConnection doesn't add streams until it + // firefox and OpenWebRTC's RTCPeerConnection doesn't add streams until it // starts getting media on them so we need to figure out whether a video // channel has been offered by ourselves. if (this.msg.offer.sdp.indexOf('m=video') > -1) {