summaryrefslogtreecommitdiffstats
path: root/js
diff options
context:
space:
mode:
authorJoas Schilling <coding@schilljs.com>2017-11-02 11:46:48 +0100
committerGitHub <noreply@github.com>2017-11-02 11:46:48 +0100
commit08b470a1a53632edba47fd1a2fc46cea0c3b163a (patch)
treec3815c7b90dafcc126e0f528fdb643c8964c0526 /js
parentd7ef5ef868060f497e6e69617f088c69dfbb6cc3 (diff)
parent85bdea646f99cd300f14dd36ab35f666fe3493cf (diff)
Merge pull request #366 from nextcloud/standalone-signaling
Standalone signaling support
Diffstat (limited to 'js')
-rw-r--r--js/admin/signaling-server.js153
-rw-r--r--js/calls.js4
-rw-r--r--js/signaling.js410
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);