diff --git a/examples/voip/README.md b/examples/voip/README.md new file mode 100644 index 000000000..c3f3d67dd --- /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/voip + $ 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/client.js b/lib/client.js index 7b4a0c53f..98fa17aac 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); @@ -1358,6 +1370,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); + delete client.callList[content.call_id]; + } + } + } + }); +} + function createNewUser(client, userId) { var user = new User(userId); reEmit(client, user, ["User.avatarUrl", "User.displayName", "User.presence"]); @@ -1452,6 +1619,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/http-api.js b/lib/http-api.js index 5c8b7b0d5..42db907f3 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 new file mode 100644 index 000000000..501d7bc89 --- /dev/null +++ b/lib/webrtc/call.js @@ -0,0 +1,891 @@ +"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; +var DEBUG = true; // set true to enable console logging. + +// events: hangup, error, replaced + +/** + * 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] + }]; + utils.forEach(this.turnServers, function(server) { + utils.checkObjectHasKeys(server, ["urls"]); + }); + + 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'; +/** 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} 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(remoteVideoElement, localVideoElement) { + checkForErrorListener(this); + this.localVideoElement = localVideoElement; + this.remoteVideoElement = remoteVideoElement; + _placeCallWithConstraints(this, _getUserMediaVideoContraints('video')); + this.type = 'video'; + _tryPlayRemoteStream(this); +}; + +/** + * Retrieve the local <video> DOM element. + * @return {Element} The dom element + */ +MatrixCall.prototype.getLocalVideoElement = function() { + return this.localVideoElement; +}; + +/** + * Retrieve the remote <video> DOM element. + * @return {Element} The dom element + */ +MatrixCall.prototype.getRemoteVideoElement = function() { + return this.remoteVideoElement; +}; + +/** + * 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; + _tryPlayRemoteStream(this); +}; + +/** + * Configure this call from an invite event. Used by MatrixClient. + * @protected + * @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), + hookCallback(self, self._onSetRemoteDescriptionSuccess), + hookCallback(self, self._onSetRemoteDescriptionError) + ); + } + this.state = 'ringing'; + this.direction = 'inbound'; + + // 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) { + 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("hangup", self); + } + }, this.msg.lifetime - event.getAge()); + } +}; + +/** + * Configure this call from a hangup event. Used by MatrixClient. + * @protected + * @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() { + debuglog("Answering call " + this.callId); + var self = this; + + if (!this.localAVStream && !this.waitForLocalAVStream) { + this.webRtc.getUserMedia( + _getUserMediaVideoContraints(this.type), + hookCallback(self, self._gotUserMediaForAnswer), + hookCallback(self, self._getUserMediaFailed) + ); + this.state = 'wait_local_media'; + } else if (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. Used by + * MatrixClient. + * @protected + * @param {MatrixCall} newCall The new call. + */ +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); + delete(this.localAVStream); + } else if (this.state == 'invite_sent') { + debuglog("Handing local stream to new call"); + newCall._gotUserMediaForAnswer(this.localAVStream); + delete(this.localAVStream); + } + newCall.localVideoElement = this.localVideoElement; + newCall.remoteVideoElement = this.remoteVideoElement; + this.successor = newCall; + this.emit("replaced", 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) { + debuglog("Ending call " + this.callId); + terminate(this, "local", reason, !suppressEvent); + var content = { + version: 0, + call_id: this.callId, + reason: reason + }; + sendEvent(this, 'm.call.hangup', content); +}; + +/** + * Internal + * @private + * @param {Object} stream + */ +MatrixCall.prototype._gotUserMediaForInvite = function(stream) { + if (this.successor) { + this.successor._gotUserMediaForAnswer(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 = _createPeerConnection(this); + this.peerConn.addStream(stream); + this.peerConn.createOffer( + hookCallback(self, self._gotLocalOffer), + hookCallback(self, self._getLocalOfferFailed) + ); + self.state = 'create_offer'; +}; + +/** + * Internal + * @private + * @param {Object} stream + */ +MatrixCall.prototype._gotUserMediaForAnswer = function(stream) { + var self = this; + 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(function(description) { + debuglog("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 + } + }; + sendEvent(self, 'm.call.answer', content); + self.state = 'connecting'; + }, function() { + debuglog("Error setting local description!"); + }, constraints); + }); + self.state = 'create_answer'; +}; + +/** + * Internal + * @private + * @param {Object} event + */ +MatrixCall.prototype._gotLocalIceCandidate = function(event) { + if (event.candidate) { + debuglog( + "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 + }; + sendCandidate(this, c); + } +}; + +/** + * Used by MatrixClient. + * @protected + * @param {Object} cand + */ +MatrixCall.prototype._gotRemoteIceCandidate = function(cand) { + if (this.state == 'ended') { + //debuglog("Ignoring remote ICE candidate because call has ended"); + return; + } + debuglog("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate); + this.peerConn.addIceCandidate( + new this.webRtc.RtcIceCandidate(cand), + function() {}, + function(e) {} + ); +}; + +/** + * Used by MatrixClient. + * @protected + * @param {Object} msg + */ +MatrixCall.prototype._receivedAnswer = function(msg) { + if (this.state == 'ended') { + return; + } + + var self = this; + this.peerConn.setRemoteDescription( + new this.webRtc.RtcSessionDescription(msg.answer), + hookCallback(self, self._onSetRemoteDescriptionSuccess), + hookCallback(self, self._onSetRemoteDescriptionError) + ); + this.state = 'connecting'; +}; + +/** + * Internal + * @private + * @param {Object} description + */ +MatrixCall.prototype._gotLocalOffer = function(description) { + var self = this; + debuglog("Created offer: " + description); + + if (self.state == 'ended') { + debuglog("Ignoring newly created offer on call ID " + self.callId + + " because the call has ended"); + return; + } + + self.peerConn.setLocalDescription(description, 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 + }; + sendEvent(self, 'm.call.invite', content); + + setTimeout(function() { + if (self.state == 'invite_sent') { + self.hangup('invite_timeout'); + } + }, MatrixCall.CALL_TIMEOUT_MS); + self.state = 'invite_sent'; + }, function() { + debuglog("Error setting local description!"); + }); +}; + +/** + * Internal + * @private + * @param {Object} error + */ +MatrixCall.prototype._getLocalOfferFailed = function(error) { + this.emit( + "error", + callError(MatrixCall.ERR_LOCAL_OFFER_FAILED, "Failed to start audio for call!") + ); +}; + +/** + * Internal + * @private + */ +MatrixCall.prototype._getUserMediaFailed = function() { + 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("user_media_failed"); +}; + +/** + * Internal + * @private + */ +MatrixCall.prototype._onIceConnectionStateChanged = function() { + if (this.state == 'ended') { + return; // because ICE can still complete as we're ending the call + } + debuglog( + "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 + * @private + */ +MatrixCall.prototype._onSignallingStateChanged = function() { + debuglog( + "call " + this.callId + ": Signalling state changed to: " + + this.peerConn.signalingState + ); +}; + +/** + * Internal + * @private + */ +MatrixCall.prototype._onSetRemoteDescriptionSuccess = function() { + debuglog("Set remote description"); +}; + +/** + * Internal + * @private + * @param {Object} e + */ +MatrixCall.prototype._onSetRemoteDescriptionError = function(e) { + debuglog("Failed to set remote description" + e); +}; + +/** + * Internal + * @private + * @param {Object} event + */ +MatrixCall.prototype._onAddStream = function(event) { + debuglog("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 = hookCallback(self, self._onRemoteStreamTrackStarted); + }); + + event.stream.onended = hookCallback(self, self._onRemoteStreamEnded); + // not currently implemented in chrome + event.stream.onstarted = hookCallback(self, self._onRemoteStreamStarted); + + _tryPlayRemoteStream(this); +}; + +/** + * Internal + * @private + * @param {Object} event + */ +MatrixCall.prototype._onRemoteStreamStarted = function(event) { + this.state = 'connected'; +}; + +/** + * Internal + * @private + * @param {Object} event + */ +MatrixCall.prototype._onRemoteStreamEnded = function(event) { + debuglog("Remote stream ended"); + this.state = 'ended'; + this.hangupParty = 'remote'; + stopAllMedia(this); + if (this.peerConn.signalingState != 'closed') { + this.peerConn.close(); + } + this.emit("hangup", this); +}; + +/** + * Internal + * @private + * @param {Object} event + */ +MatrixCall.prototype._onRemoteStreamTrackStarted = function(event) { + this.state = 'connected'; +}; + +/** + * Used by MatrixClient. + * @protected + * @param {Object} msg + */ +MatrixCall.prototype._onHangupReceived = function(msg) { + debuglog("Hangup received"); + terminate(this, "remote", msg.reason, true); +}; + +/** + * Used by MatrixClient. + * @protected + * @param {Object} 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} + */ +var sendEvent = function(self, eventType, content) { + return self.client.sendEvent(self.roomId, eventType, content); +}; + +var sendCandidate = function(self, content) { + // Sends candidates with are sent in a special way because we try to amalgamate + // them into one message + self.candidateSendQueue.push(content); + if (self.candidateSendTries === 0) { + setTimeout(function() { + _sendCandidateQueue(self); + }, 100); + } +}; + +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 && self.peerConn.signalingState !== 'closed') { + self.peerConn.close(); + } + if (shouldEmit) { + self.emit("hangup", self); + } +}; + +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 _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( + "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; + } + + var cands = self.candidateSendQueue; + self.candidateSendQueue = []; + ++self.candidateSendTries; + var content = { + version: 0, + call_id: self.callId, + candidates: cands + }; + debuglog("Attempting to send " + cands.length + " candidates"); + sendEvent(self, '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) { + debuglog( + "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; + debuglog("Failed to send candidates. Retrying in " + delayMs + "ms"); + setTimeout(function() { + _sendCandidateQueue(self); + }, delayMs); + }); +}; + +var _placeCallWithConstraints = function(self, constraints) { + self.client.callList[self.callId] = self; + self.webRtc.getUserMedia( + constraints, + hookCallback(self, self._gotUserMediaForInvite), + hookCallback(self, 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 = hookCallback(self, self._onIceConnectionStateChanged); + pc.onsignalingstatechange = hookCallback(self, self._onSignallingStateChanged); + pc.onicecandidate = hookCallback(self, self._gotLocalIceCandidate); + pc.onaddstream = hookCallback(self, 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 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++) { + 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 doc = global.document; + if (!w || !doc) { + return null; + } + var webRtc = {}; + webRtc.isOpenWebRTC = function() { + 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 || + 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); +}; 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": {