diff options
author | Joas Schilling <coding@schilljs.com> | 2017-11-02 11:46:48 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-11-02 11:46:48 +0100 |
commit | 08b470a1a53632edba47fd1a2fc46cea0c3b163a (patch) | |
tree | c3815c7b90dafcc126e0f528fdb643c8964c0526 /js | |
parent | d7ef5ef868060f497e6e69617f088c69dfbb6cc3 (diff) | |
parent | 85bdea646f99cd300f14dd36ab35f666fe3493cf (diff) |
Merge pull request #366 from nextcloud/standalone-signaling
Standalone signaling support
Diffstat (limited to 'js')
-rw-r--r-- | js/admin/signaling-server.js | 153 | ||||
-rw-r--r-- | js/calls.js | 4 | ||||
-rw-r--r-- | js/signaling.js | 410 |
3 files changed, 552 insertions, 15 deletions
diff --git a/js/admin/signaling-server.js b/js/admin/signaling-server.js new file mode 100644 index 000000000..284d5d9f7 --- /dev/null +++ b/js/admin/signaling-server.js @@ -0,0 +1,153 @@ +/* global OC, OCP, OCA, $, _, Handlebars */ + +(function(OC, OCP, OCA, $, _, Handlebars) { + 'use strict'; + + OCA.VideoCalls = OCA.VideoCalls || {}; + OCA.VideoCalls.Admin = OCA.VideoCalls.Admin || {}; + OCA.VideoCalls.Admin.SignalingServer = { + + TEMPLATE: '<div class="signaling-server">' + + ' <input type="text" class="server" placeholder="wss://signaling.example.org" value="{{server}}">' + + ' <input type="checkbox" id="verify{{seed}}" name="verify{{seed}}" class="checkbox verify" value="1" {{#if verify}} checked="checked"{{/if}}>' + + ' <label for="verify{{seed}}">' + t('spreed', 'Validate SSL certificate') + '</label>' + + ' <a class="icon icon-delete" title="' + t('spreed', 'Delete server') + '"></a>' + + ' <a class="icon icon-add" title="' + t('spreed', 'Add new server') + '"></a>' + + ' <span class="icon icon-checkmark-color hidden" title="' + t('spreed', 'Saved') + '"></span>' + + '</div>', + $list: undefined, + $secret: undefined, + template: undefined, + seed: 0, + + init: function() { + this.template = Handlebars.compile(this.TEMPLATE); + this.$list = $('div.signaling-servers'); + this.$secret = $('#signaling_secret'); + this.renderList(); + + this.$secret.on('change', this.saveServers.bind(this)); + }, + + renderList: function() { + var data = this.$list.data('servers'); + + var hasServers = false; + if (!_.isUndefined(data.secret)) { + _.each(data.servers, function (server) { + this.$list.append( + this.renderServer(server) + ); + }.bind(this)); + + hasServers = data.servers.length !== 0; + + this.$secret.val(data.secret); + } + + if (!hasServers) { + this.addNewTemplate(); + } + + this.$secret.parents('p').first().removeClass('hidden'); + }, + + addNewTemplate: function() { + var $server = this.renderServer({ + validate: true + }); + this.$list.append($server); + return $server; + }, + + deleteServer: function(e) { + e.stopPropagation(); + + var $server = $(e.currentTarget).parents('div.signaling-server').first(); + $server.remove(); + + this.saveServers(); + + if (this.$list.find('div.signaling-server').length === 0) { + var $newServer = this.addNewTemplate(); + this.temporaryShowSuccess($newServer); + } + }, + + saveServers: function() { + var servers = [], + $error = [], + $success = [], + self = this, + $secret = this.$secret, + secret = this.$secret.val().trim(); + + this.$list.find('input').removeClass('error'); + this.$secret.removeClass('error'); + this.$list.find('.icon-checkmark-color').addClass('hidden'); + + this.$list.find('div.signaling-server').each(function() { + var $row = $(this), + $server = $row.find('input.server'), + $verify = $row.find('input.verify'), + data = { + server: $server.val().trim(), + verify: !!$verify.prop('checked') + }; + + if (data.server === '') { + $error.push($server); + return; + } + + if (secret === '') { + $error.push($secret); + return; + } + + $success.push($(this)); + servers.push(data); + }); + + OCP.AppConfig.setValue('spreed', 'signaling_servers', JSON.stringify({ + servers: servers, + secret: secret + }), { + success: function() { + _.each($error, function($input) { + $input.addClass('error'); + }); + _.each($success, function($server) { + self.temporaryShowSuccess($server); + }); + } + }); + }, + + temporaryShowSuccess: function($server) { + var $icon = $server.find('.icon-checkmark-color'); + $icon.removeClass('hidden'); + setTimeout(function() { + $icon.addClass('hidden'); + }, 2000); + }, + + renderServer: function(server) { + server.seed = this.seed++; + var $template = $(this.template(server)); + + $template.find('a.icon-add').on('click', this.addNewTemplate.bind(this)); + $template.find('a.icon-delete').on('click', this.deleteServer.bind(this)); + $template.find('input').on('change', this.saveServers.bind(this)); + + return $template; + } + + }; + + +})(OC, OCP, OCA, $, _, Handlebars); + +$(document).ready(function(){ + OCA.VideoCalls.Admin.SignalingServer.init(); +}); diff --git a/js/calls.js b/js/calls.js index 4a0f7dc9a..adad25d55 100644 --- a/js/calls.js +++ b/js/calls.js @@ -17,6 +17,10 @@ selectParticipants.removeClass('error'); }); + signaling.on('roomChanged', function() { + OCA.SpreedMe.Calls.leaveCurrentCall(false); + }); + OCA.SpreedMe.Calls.leaveAllCalls(); } diff --git a/js/signaling.js b/js/signaling.js index 1bac8b641..43782cc11 100644 --- a/js/signaling.js +++ b/js/signaling.js @@ -3,10 +3,12 @@ OCA.SpreedMe = OCA.SpreedMe || {}; - function SignalingBase() { + function SignalingBase(settings) { + this.settings = settings; this.sessionId = ''; this.currentCallToken = null; this.handlers = {}; + this.features = {}; } SignalingBase.prototype.on = function(ev, handler) { @@ -15,10 +17,20 @@ } else { this.handlers[ev].push(handler); } - }; - SignalingBase.prototype.emit = function(/*ev, data*/) { - // Override in subclasses. + switch (ev) { + case 'stunservers': + case 'turnservers': + var servers = this.settings[ev] || []; + if (servers.length) { + // The caller expects the handler to be called when the data + // is available, so defer to simulate a delayed response. + _.defer(function() { + handler(servers); + }); + } + break; + } }; SignalingBase.prototype._trigger = function(ev, args) { @@ -43,6 +55,10 @@ this.currentCallToken = null; }; + SignalingBase.prototype.hasFeature = function(feature) { + return this.features && this.features[feature]; + }; + SignalingBase.prototype.emit = function(ev, data) { switch (ev) { case 'join': @@ -110,7 +126,7 @@ }; // Connection to the internal signaling server provided by the app. - function InternalSignaling() { + function InternalSignaling(/*settings*/) { SignalingBase.prototype.constructor.apply(this, arguments); this.spreedArrayConnection = []; @@ -156,13 +172,6 @@ // through it. this._sendMessageWithCallback(ev); break; - - case 'stunservers': - case 'turnservers': - // Values are not pushed by the server but have to be explicitly - // requested. - this._sendMessageWithCallback(ev); - break; } }; @@ -431,10 +440,381 @@ }.bind(this)); }; + function StandaloneSignaling(settings, urls) { + SignalingBase.prototype.constructor.apply(this, arguments); + if (typeof(urls) === "string") { + urls = [urls]; + } + // We can connect to any of the servers. + var idx = Math.floor(Math.random() * urls.length); + // TODO(jojo): Try other server if connection fails. + var url = urls[idx]; + // Make sure we are using websocket urls. + if (url.indexOf("https://") === 0) { + url = "wss://" + url.substr(8); + } else if (url.indexOf("http://") === 0) { + url = "ws://" + url.substr(7); + } + if (url[url.length - 1] === "/") { + url = url.substr(0, url.length - 1); + } + this.url = url + "/spreed"; + this.initialReconnectIntervalMs = 1000; + this.maxReconnectIntervalMs = 16000; + this.reconnectIntervalMs = this.initialReconnectIntervalMs; + this.joinedUsers = {}; + this.connect(); + } + + StandaloneSignaling.prototype = new SignalingBase(); + StandaloneSignaling.prototype.constructor = StandaloneSignaling; + + StandaloneSignaling.prototype.reconnect = function() { + if (this.reconnectTimer) { + return; + } + + // Wiggle interval a little bit to prevent all clients from connecting + // simultaneously in case the server connection is interrupted. + var interval = this.reconnectIntervalMs - (this.reconnectIntervalMs / 2) + (this.reconnectIntervalMs * Math.random()); + console.log("Reconnect in", interval); + this.reconnected = true; + this.reconnectTimer = window.setTimeout(function() { + this.reconnectTimer = null; + this.connect(); + }.bind(this), interval); + this.reconnectIntervalMs = this.reconnectIntervalMs * 2; + if (this.reconnectIntervalMs > this.maxReconnectIntervalMs) { + this.reconnectIntervalMs = this.maxReconnectIntervalMs; + } + if (this.socket) { + this.socket.close(); + this.socket = null; + } + }; + + StandaloneSignaling.prototype.connect = function() { + console.log("Connecting to", this.url); + this.callbacks = {}; + this.id = 1; + this.pendingMessages = []; + this.connected = false; + this.socket = new WebSocket(this.url); + window.signalingSocket = this.socket; + this.socket.onopen = function(event) { + console.log("Connected", event); + this.reconnectIntervalMs = this.initialReconnectIntervalMs; + this.sendHello(); + }.bind(this); + this.socket.onerror = function(event) { + console.log("Error", event); + this.reconnect(); + }.bind(this); + this.socket.onclose = function(event) { + console.log("Close", event); + this.reconnect(); + }.bind(this); + this.socket.onmessage = function(event) { + var data = event.data; + if (typeof(data) === "string") { + data = JSON.parse(data); + } + console.log("Received", data); + var id = data.id; + if (id && this.callbacks.hasOwnProperty(id)) { + var cb = this.callbacks[id]; + delete this.callbacks[id]; + cb(data); + } + switch (data.type) { + case "hello": + if (!id) { + // Only process if not received as result of our "hello". + this.helloResponseReceived(data); + } + break; + case "room": + if (this.currentCallToken && data.room.roomid !== this.currentCallToken) { + this._trigger('roomChanged', [this.currentCallToken, data.room.roomid]); + this.joinedUsers = {}; + this.currentCallToken = null; + } + break; + case "event": + this.processEvent(data); + break; + case "message": + data.message.data.from = data.message.sender.sessionid; + this._trigger("message", [data.message.data]); + break; + default: + if (!id) { + console.log("Ignore unknown event", data); + } + break; + } + }.bind(this); + }; + + StandaloneSignaling.prototype.disconnect = function() { + if (this.socket) { + this.doSend({ + "type": "bye", + "bye": {} + }); + this.socket.close(); + this.socket = null; + } + SignalingBase.prototype.disconnect.apply(this, arguments); + }; + + StandaloneSignaling.prototype.sendCallMessage = function(data) { + this.doSend({ + "type": "message", + "message": { + "recipient": { + "type": "session", + "sessionid": data.to + }, + "data": data + } + }); + }; + + StandaloneSignaling.prototype.doSend = function(msg, callback) { + if (!this.connected && msg.type !== "hello") { + // Defer sending any messages until the hello rsponse has been + // received. + this.pendingMessages.push([msg, callback]); + return; + } + + if (callback) { + var id = this.id++; + this.callbacks[id] = callback; + msg["id"] = ""+id; + } + console.log("Sending", msg); + this.socket.send(JSON.stringify(msg)); + }; + + StandaloneSignaling.prototype.sendHello = function() { + var msg; + if (this.resumeId) { + console.log("Trying to resume session", this.sessionId); + msg = { + "type": "hello", + "hello": { + "version": "1.0", + "resumeid": this.resumeId + } + }; + } else { + var user = OC.getCurrentUser(); + var url = OC.generateUrl("/ocs/v2.php/apps/spreed/api/v1/signaling/backend"); + msg = { + "type": "hello", + "hello": { + "version": "1.0", + "auth": { + "url": OC.getProtocol() + "://" + OC.getHost() + url, + "params": { + "userid": user.uid, + "ticket": this.settings.ticket + } + } + } + }; + } + this.doSend(msg, this.helloResponseReceived.bind(this)); + }; + + StandaloneSignaling.prototype.helloResponseReceived = function(data) { + console.log("Hello response received", data); + if (data.type !== "hello") { + if (this.resumeId) { + // Resuming the session failed, reconnect as new session. + this.resumeId = ''; + this.sendHello(); + return; + } + + // TODO(fancycode): How should this be handled better? + console.error("Could not connect to server", data); + this.reconnect(); + return; + } + + var resumedSession = !!this.resumeId; + this.connected = true; + this.sessionId = data.hello.sessionid; + this.resumeId = data.hello.resumeid; + this.features = {}; + var i; + if (data.hello.server && data.hello.server.features) { + var features = data.hello.server.features; + for (i = 0; i < features.length; i++) { + this.features[features[i]] = true; + } + } + + var messages = this.pendingMessages; + this.pendingMessages = []; + for (i = 0; i < messages.length; i++) { + var msg = messages[i][0]; + var callback = messages[i][1]; + this.doSend(msg, callback); + } + + this._trigger("connect"); + if (this.reconnected) { + // The list of rooms might have changed while we were not connected, + // so perform resync once. + this.internalSyncRooms(); + } + if (!resumedSession && this.currentCallToken) { + this.joinCall(this.currentCallToken); + } + }; + + StandaloneSignaling.prototype.joinCall = function(token, callback) { + console.log("Join call", token); + this.doSend({ + "type": "room", + "room": { + "roomid": token + } + }, function(data) { + this.joinResponseReceived(data, token, callback); + }.bind(this)); + }; + + StandaloneSignaling.prototype.joinResponseReceived = function(data, token, callback) { + console.log("Joined", data, token); + this.currentCallToken = token; + if (this.roomCollection) { + // The list of rooms is not fetched from the server. Update ping + // of joined room so it gets sorted to the top. + this.roomCollection.forEach(function(room) { + if (room.get('token') === token) { + room.set('lastPing', (new Date()).getTime() / 1000); + } + }); + this.roomCollection.sort(); + } + if (callback) { + var roomDescription = { + "clients": {} + }; + callback('', roomDescription); + } + }; + + StandaloneSignaling.prototype.leaveCall = function(token) { + console.log("Leave call", token); + this.doSend({ + "type": "room", + "room": { + "roomid": "" + } + }, function(data) { + console.log("Left", data); + this.joinedUsers = {}; + this.currentCallToken = null; + }.bind(this)); + }; + + StandaloneSignaling.prototype.processEvent = function(data) { + switch (data.event.target) { + case "room": + this.processRoomEvent(data); + break; + case "roomlist": + this.processRoomListEvent(data); + break; + default: + console.log("Unsupported event target", data); + break; + } + }; + + StandaloneSignaling.prototype.processRoomEvent = function(data) { + var i; + switch (data.event.type) { + case "join": + var joinedUsers = data.event.join || []; + if (joinedUsers.length) { + console.log("Users joined", joinedUsers); + var leftUsers = {}; + if (this.reconnected) { + this.reconnected = false; + // The browser reconnected, some of the previous sessions + // may now no longer exist. + leftUsers = _.extend({}, this.joinedUsers); + } + for (i = 0; i < joinedUsers.length; i++) { + this.joinedUsers[joinedUsers[i].sessionid] = true; + delete leftUsers[joinedUsers[i].sessionid]; + } + leftUsers = _.keys(leftUsers); + if (leftUsers.length) { + this._trigger("usersLeft", [leftUsers]); + } + this._trigger("usersJoined", [joinedUsers]); + } + break; + case "leave": + var leftSessionIds = data.event.leave || []; + if (leftSessionIds.length) { + console.log("Users left", leftSessionIds); + for (i = 0; i < leftSessionIds.length; i++) { + delete this.joinedUsers[leftSessionIds[i]]; + } + this._trigger("usersLeft", [leftSessionIds]); + } + break; + default: + console.log("Unknown room event", data); + break; + } + }; + + StandaloneSignaling.prototype.setRoomCollection = function(/* rooms */) { + SignalingBase.prototype.setRoomCollection.apply(this, arguments); + // Retrieve initial list of rooms for this user. + return this.internalSyncRooms(); + }; + + StandaloneSignaling.prototype.syncRooms = function() { + // Never manually sync rooms, will be done based on notifications + // from the signaling server. + var defer = $.Deferred(); + defer.resolve([]); + return defer; + }; + + StandaloneSignaling.prototype.internalSyncRooms = function() { + return SignalingBase.prototype.syncRooms.apply(this, arguments); + }; + + StandaloneSignaling.prototype.processRoomListEvent = function(data) { + console.log("Room list event", data); + this.internalSyncRooms(); + }; + OCA.SpreedMe.createSignalingConnection = function() { - // TODO(fancycode): Create different type of signaling connection - // depending on configuration. - return new InternalSignaling(); + var settings = $("#app #signaling-settings").text(); + if (settings) { + settings = JSON.parse(settings); + } else { + settings = {}; + } + var urls = settings['server']; + if (urls && urls.length) { + return new StandaloneSignaling(settings, urls); + } else { + return new InternalSignaling(settings); + } }; })(OCA, OC); |