You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1434 lines
33 KiB
1434 lines
33 KiB
"use strict";
|
|
|
|
// XXX: Recycle _callback IDs
|
|
// XXX: Implement keepalive timer (5s?)
|
|
// XXX: Handle disconnect more gracefully (don't drop everything, cache the request try again at least once)
|
|
var Lang = imports.lang;
|
|
var Mainloop = imports.mainloop;
|
|
var Signals = imports.signals;
|
|
const ByteArray = imports.byteArray;
|
|
|
|
//var Util = imports.misc.util;
|
|
|
|
const {Gio, GLib} = imports.gi;
|
|
|
|
//var Gettext = imports.gettext.domain('gnome-shell-extension-snapcast');
|
|
//var _ = Gettext.gettext;
|
|
var _ = function(s) { return s };
|
|
|
|
var BreakException = {};
|
|
|
|
var EventMethods = [
|
|
"Client.OnConnect"
|
|
, "Client.OnDisconnect"
|
|
, "Client.OnVolumeChanged"
|
|
, "Client.OnLatencyChanged"
|
|
, "Client.OnNameChanged"
|
|
|
|
, "Group.OnMute"
|
|
, "Group.OnStreamChanged"
|
|
|
|
, "Stream.OnUpdate"
|
|
|
|
, "Server.OnUpdate"
|
|
];
|
|
|
|
var RequestMethods = [
|
|
"Client.GetStatus"
|
|
, "Client.SetVolume"
|
|
//, "Client.SetLatency"
|
|
, "Client.SetName"
|
|
|
|
, "Group.GetStatus"
|
|
, "Group.SetMute"
|
|
, "Group.SetStream"
|
|
, "Group.SetClients"
|
|
|
|
, "Server.GetRPCVersion"
|
|
, "Server.GetStatus"
|
|
, "Server.DeleteClient"
|
|
];
|
|
|
|
// Reconnect timer, in seconds
|
|
var ClientReconnect = 2.5;
|
|
|
|
var ClientStates = {
|
|
"failed": -2
|
|
, "init": -1
|
|
|
|
, "disconnected": 1
|
|
, "connecting": 2
|
|
, "connected": 3
|
|
, "ready": 4
|
|
|
|
}
|
|
|
|
var ClientStatesNames = {
|
|
'-2': "failed"
|
|
, '-1': "init"
|
|
|
|
, 1: "disconnected"
|
|
, 2: "connecting"
|
|
, 3: "connected"
|
|
, 4: "ready"
|
|
}
|
|
|
|
var Snapcast = new Lang.Class({
|
|
Name: "Snapcast"
|
|
|
|
, _id: 0
|
|
|
|
, _host: "127.0.0.1:1705"
|
|
|
|
, _handlers: []
|
|
, _callbacks: {}
|
|
|
|
, sClient: null
|
|
, sConn: null
|
|
, sInStr: null
|
|
, sOutReader: null
|
|
, sCancelRead: null
|
|
, sCancelConn: null
|
|
|
|
, _sServer: null
|
|
|
|
, _state: ClientStates.init
|
|
|
|
, _count: 0
|
|
|
|
, _hostTimeoutId:null
|
|
|
|
, _init: function(host) {
|
|
log("SNAPCAST: Init");
|
|
this._host = host;
|
|
this._handlers.push(this.connect("state::" + ClientStatesNames[ClientStates.disconnected], Lang.bind(this, function() {
|
|
if (this._hostTimeoutId !== null) {
|
|
Mainloop.source_remove(this._hostTimeoutId);
|
|
}
|
|
this._hostTimeoutId = Mainloop.timeout_add(ClientReconnect * 1000, Lang.bind(this, function() {
|
|
this._reconnect();
|
|
this._hostTimeoutId = null
|
|
return GLib.SOURCE_REMOVE;
|
|
}));
|
|
})));
|
|
this._handlers.push(this.connect("state::" + ClientStatesNames[ClientStates.connected], Lang.bind(this, function() {
|
|
this._setup_connection();
|
|
//this._sWrite("Server.GetRPCVersion");
|
|
this._sWrite("Server.GetStatus");
|
|
})));
|
|
|
|
return this;
|
|
}
|
|
|
|
, enable: function() {
|
|
log("SNAPCAST: Client Enabled");
|
|
this._connect();
|
|
}
|
|
|
|
, destroy: function() {
|
|
log("SNAPCAST: Client Destroy");
|
|
try {
|
|
if (this._hostTimeoutId !== null) {
|
|
Mainloop.source_remove(this._hostTimeoutId);
|
|
}
|
|
} catch(e) {}
|
|
try {
|
|
this._disconnect();
|
|
} catch(e) {}
|
|
try {
|
|
if (this._handlers.length > 0) {
|
|
for (var i = 0, ii = this._handlers.length; i < ii; i++) {
|
|
this.disconnect(this._handlers.pop());
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
, _set_state: function(state) {
|
|
if ( !(state in ClientStatesNames) ) {
|
|
log("SNAPCAST: Invalid state error");
|
|
return; // XXX: throw
|
|
}
|
|
log("SNAPCAST: State changed to > " + ClientStatesNames[state]);
|
|
this._state = state;
|
|
this.emit("state::" + ClientStatesNames[state]);
|
|
this.emit("state", ClientStatesNames[state]);
|
|
}
|
|
|
|
, _connect: function() {
|
|
this._callbacks = {};
|
|
this._id = 0;
|
|
try {
|
|
this._set_state(ClientStates.connecting);
|
|
this.sClient = new Gio.SocketClient({ timeout: 0, enable_proxy: false, protocol: Gio.SocketProtocol.TCP });//{ timeout: 1 });
|
|
this.sCancelRead = new Gio.Cancellable();
|
|
this.sCancelConn = new Gio.Cancellable();
|
|
this.sClient.connect_to_host_async(this.host, null, this.sCancelConn, Lang.bind(this, this._connect_ready));
|
|
} catch (e) {
|
|
log("SNAPCAST: " + e);
|
|
return;
|
|
}
|
|
}
|
|
|
|
, _connect_ready: function(client, res) {
|
|
try {
|
|
this.sConn = client.connect_to_host_finish(res);
|
|
this._set_state(ClientStates.connected);
|
|
} catch (e) {
|
|
this._set_state(ClientStates.disconnected);
|
|
log("SNAPCAST: " + e.toString());
|
|
}
|
|
|
|
if (!this.sConn) {
|
|
this._set_state(ClientStates.disconnected);
|
|
} else {
|
|
this.sConn.set_graceful_disconnect(true);
|
|
}
|
|
}
|
|
|
|
, _reconnect: function() {
|
|
log("SNAPCAST: Reconnecting");
|
|
this._disconnect();
|
|
this._connect();
|
|
}
|
|
|
|
, _setup_connection: function() {
|
|
if (this._state < ClientStates.connected) {
|
|
return;
|
|
}
|
|
log("SNAPCAST: Setup Connection");
|
|
try {
|
|
this.sInStr = new Gio.DataInputStream({ base_stream: this.sConn.get_input_stream() });
|
|
this.sOutReader = new Gio.DataInputStream({ base_stream: this.sInStr });
|
|
this.sOutReader.read_line_async(0, this.sCancelRead, Lang.bind(this, this._sRead));
|
|
} catch (e) {
|
|
if (e instanceof Gio.IOErrorEnum) {
|
|
this._set_state(ClientStates.disconnected);
|
|
}
|
|
log("SNAPCAST: " + e.toString());
|
|
}
|
|
}
|
|
|
|
, _disconnect: function() {
|
|
try {
|
|
this.sCancelRead.cancel();
|
|
this.sCancelRead = null;
|
|
} catch (e) {
|
|
log("SNAPCAST: Failed to close CancelRead: " + e);
|
|
}
|
|
try {
|
|
this.sOutReader.clear_pending();
|
|
this.sInStr.clear_pending();
|
|
this.sOutReader.close(this.sCancelRead);
|
|
this.sInStr.close(null);
|
|
this.sInStr = null;
|
|
this.sOutReader = null;
|
|
} catch (e) {
|
|
log("SNAPCAST: Failed to close InStr/OutReader: " + e);
|
|
}
|
|
try {
|
|
if (this.sOutStr) {
|
|
this.sOutStr.clear_pending();
|
|
this.sOutStr.close(null);
|
|
this.sOutStr = null;
|
|
}
|
|
} catch (e) {
|
|
log("SNAPCAST: Failed to close OutStr: " + e);
|
|
}
|
|
try {
|
|
this.sCancelConn.cancel();
|
|
this.sCancelConn = null;
|
|
} catch (e) {
|
|
log("SNAPCAST: Failed to close CancelConn: " + e);
|
|
}
|
|
try {
|
|
this.sConn.socket.close();
|
|
this.sConn.close(null);
|
|
this.sConn.run_dispose();
|
|
this.sConn = null;
|
|
} catch (e) {
|
|
log("SNAPCAST: Failed to close sConn: " + e);
|
|
}
|
|
try {
|
|
this.sClient.run_dispose();
|
|
this.sClient = null;
|
|
} catch (e) {
|
|
log("SNAPCAST: Failed to close sClient: " + e);
|
|
}
|
|
try {
|
|
if (this._sServer instanceof SnapcastServer) {
|
|
this._sServer.destroy();
|
|
this._sServer = null;
|
|
delete this["_sServer"];
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
, isConnected: function() {
|
|
return (this.sClient !== null && this.sConn !== null && this.sConn.is_connected());
|
|
}
|
|
|
|
, get host() {
|
|
return this._host;
|
|
}
|
|
|
|
, set host(value) {
|
|
log("SNAPCAST: Set host");
|
|
if (this._host === value) {
|
|
return;
|
|
}
|
|
this._host = value;
|
|
if (this._state >= ClientStates.connected) {
|
|
this._reconnect();
|
|
}
|
|
return this._host;
|
|
}
|
|
|
|
, _sRead: function(gobject, async_res, user_data) {
|
|
if (this._state < ClientStates.connected || !this.sCancelRead) {
|
|
return;
|
|
}
|
|
try {
|
|
// finish async_read call
|
|
var [lineout, charlength, error] = gobject.read_line_finish(async_res);
|
|
|
|
if (lineout === null || error) {
|
|
throw new GLib.Error(Gio.IOErrorEnum, Gio.IOErrorEnum.BROKEN_PIPE, "Failed to read");
|
|
}
|
|
lineout = ByteArray.toString(lineout).trim();
|
|
|
|
var aout = JSON.parse(lineout)
|
|
if (Array.isArray(aout)) {
|
|
aout.forEach(Lang.bind(this, this._parseAout));
|
|
} else {
|
|
this._parseAout(aout);
|
|
}
|
|
|
|
this.sCancelRead.reset();
|
|
this.sOutReader.read_line_async(0, this.sCancelRead, Lang.bind(this, this._sRead));
|
|
} catch (e) {
|
|
if (e instanceof Gio.IOErrorEnum) {
|
|
this.sCancelRead.cancel();
|
|
this._set_state(ClientStates.disconnected);
|
|
}
|
|
log("SNAPCAST: " + e.toString());
|
|
}
|
|
}
|
|
|
|
, _parseAout: function(aout) {
|
|
var rid = ((("id" in aout) && aout.id in this._callbacks) ? aout.id : -1);
|
|
var result = (("params" in aout) ? aout.params : aout.result);
|
|
var method = null;
|
|
if ("method" in aout) {
|
|
method = aout.method;
|
|
} else if (rid in this._callbacks){
|
|
method = this._callbacks[rid].method;
|
|
}
|
|
var id = null;
|
|
if (typeof result === "object" && "id" in result) {
|
|
id = result.id;
|
|
} else if (rid in this._callbacks) {
|
|
id = this._callbacks[rid].id;
|
|
}
|
|
delete this._callbacks[id];
|
|
switch (method) {
|
|
case "Server.OnUpdate":
|
|
case "Server.GetStatus":
|
|
case "Server.DeleteClient":
|
|
case "Group.SetClients":
|
|
this._handlerServerUpdate(result.server);
|
|
break;
|
|
|
|
case "Stream.OnUpdate":
|
|
this._handlerStreamUpdate(result);
|
|
break;
|
|
|
|
case "Group.OnMute":
|
|
this._handlerGroupMute(result);
|
|
break;
|
|
case "Group.OnStreamChanged":
|
|
this._handlerGroupStreamChanged(result);
|
|
break;
|
|
|
|
case "Client.OnNameChanged":
|
|
this._handlerClientNameChanged(result);
|
|
break;
|
|
case "Client.OnLatencyChanged":
|
|
this._handlerClientLatencyChanged(result);
|
|
break;
|
|
case "Client.OnVolumeChanged":
|
|
this._handlerClientVolumeChanged(result);
|
|
break;
|
|
case "Client.OnConnect":
|
|
this._handlerClientConnected(result);
|
|
break;
|
|
case "Client.OnDisconnect":
|
|
this._handlerClientDisconnected(result);
|
|
break;
|
|
|
|
default:
|
|
if (rid === -1) {
|
|
log("SNAPCAST: UNHANDLED REQUEST > " + JSON.stringify(aout));
|
|
} else {
|
|
//log("SNAPCAST: COMMAND RESULT IGNORED > " + JSON.stringify(aout));
|
|
}
|
|
}
|
|
}
|
|
|
|
, _sWrite: function(method, params, callback) {
|
|
if (this._state < ClientStates.connected || !this.sCancelRead) {
|
|
return;
|
|
}
|
|
var [id, s] = this._buildRequest(method, params);
|
|
log("SNAPCAST: #"+id+" ("+s.length+") " + s);
|
|
try {
|
|
this._callbacks[id] = {
|
|
method: method
|
|
, id: id
|
|
};
|
|
this.sOutStr = new Gio.DataOutputStream({ base_stream: this.sConn.get_output_stream() });
|
|
this.sOutStr.set_byte_order(Gio.DataStreamByteOrder.BIG_ENDIAN);
|
|
this.sOutStr.write(s + "\r\n", null);
|
|
} catch (e) {
|
|
if (e instanceof Gio.IOErrorEnum) {
|
|
delete this._callbacks[id];
|
|
this.sCancelRead.cancel();
|
|
this._set_state(ClientStates.disconnected);
|
|
}
|
|
log("SNAPCAST: " + e.toString());
|
|
}
|
|
}
|
|
|
|
, _buildRequest: function(method, params) {
|
|
var id = ++this._id;
|
|
if (params !== undefined && params !== null) {
|
|
return [id, JSON.stringify({
|
|
"id": this._id
|
|
, "jsonrpc": "2.0"
|
|
, "method": method
|
|
, "params": params
|
|
})];
|
|
} else {
|
|
return [id, JSON.stringify({
|
|
"id": this._id
|
|
, "jsonrpc": "2.0"
|
|
, "method": method
|
|
})];
|
|
}
|
|
}
|
|
|
|
, get sServer() {
|
|
if ( this._sServer == null || !(this._sServer instanceof SnapcastServer) ) {
|
|
this._sServer = new SnapcastServer(this);
|
|
}
|
|
return this._sServer;
|
|
}
|
|
|
|
|
|
, _handlerServerUpdate: function(data) {
|
|
log("SNAPCAST: SERVER UPDATE:" + JSON.stringify(data));
|
|
if (this._state === ClientStates.connected) {
|
|
this._set_state(ClientStates.ready);
|
|
}
|
|
this.sServer.update(data);
|
|
this.emit("updated");
|
|
}
|
|
|
|
|
|
, _handlerStreamUpdate: function(data) {
|
|
if ( !(this._sServer instanceof SnapcastServer ) ) {
|
|
return;
|
|
}
|
|
log("SNAPCAST: STREAM UPDATE:" + JSON.stringify(data));
|
|
var stream = this.sServer.stream(data.id);
|
|
if (stream) {
|
|
stream.update(data.stream);
|
|
}
|
|
}
|
|
|
|
|
|
, _handlerGroupMute: function(data) {
|
|
if ( !(this._sServer instanceof SnapcastServer ) ) {
|
|
return;
|
|
}
|
|
log("SNAPCAST: GROUP MUTE UPDATE:" + JSON.stringify(data));
|
|
var group = this.sServer.group(data.id);
|
|
if (group) {
|
|
group.updateMuted(data.mute);
|
|
}
|
|
}
|
|
|
|
, _handlerGroupStreamChanged: function(data) {
|
|
if ( !(this._sServer instanceof SnapcastServer ) ) {
|
|
return;
|
|
}
|
|
log("SNAPCAST: GROUP STREAM UPDATE:" + JSON.stringify(data));
|
|
var group = this.sServer.group(data.id);
|
|
if (group) {
|
|
group.updateStreamID(data.stream_id);
|
|
}
|
|
}
|
|
|
|
|
|
, _handlerClientNameChanged: function(data) {
|
|
if ( !(this._sServer instanceof SnapcastServer ) ) {
|
|
return;
|
|
}
|
|
var client = this.sServer.client(data.id);
|
|
if (client) {
|
|
client.updateName(data.name);
|
|
}
|
|
}
|
|
|
|
,_handlerClientLatencyChanged: function(data) {
|
|
if ( !(this._sServer instanceof SnapcastServer ) ) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
,_handlerClientVolumeChanged: function(data) {
|
|
if ( !(this._sServer instanceof SnapcastServer ) ) {
|
|
return;
|
|
}
|
|
var client = this.sServer.client(data.id);
|
|
if (client) {
|
|
client.updateVolume(data.volume.percent);
|
|
client.updateMuted(data.volume.muted);
|
|
}
|
|
}
|
|
|
|
// XXX: We may need to run a full update sometimes? Update props...
|
|
,_handlerClientConnected: function(data) {
|
|
if ( !(this._sServer instanceof SnapcastServer ) ) {
|
|
return;
|
|
}
|
|
var client = this.sServer.client(data.id);
|
|
if (client) {
|
|
client.updateConnected(true);
|
|
}
|
|
}
|
|
|
|
,_handlerClientDisconnected: function(data) {
|
|
if ( !(this._sServer instanceof SnapcastServer ) ) {
|
|
return;
|
|
}
|
|
var client = this.sServer.client(data.id);
|
|
if (client) {
|
|
client.updateConnected(false);
|
|
}
|
|
}
|
|
|
|
});
|
|
Signals.addSignalMethods(Snapcast.prototype);
|
|
|
|
var SnapcastServer = new Lang.Class({
|
|
Name: "SnapcastServer"
|
|
|
|
, _sclient: null
|
|
, _host: ""
|
|
|
|
, server: {}
|
|
, streams: {}
|
|
, groups: {}
|
|
|
|
, _streams: []
|
|
, _streamsByID: {}
|
|
|
|
, _groups: []
|
|
, _groupsByID: {}
|
|
|
|
, _init: function(sclient) {
|
|
log("SNAPCAST: NEW SERVER");
|
|
this._sclient = sclient;
|
|
// WTF THIS WON'T RE-INIT OTHERWISE
|
|
this.destroy();
|
|
return this;
|
|
}
|
|
|
|
, update: function(data) {
|
|
//this.emit("update");
|
|
if (!equals(this.server, data.server)) {
|
|
log("SERVER NEW PROPS");
|
|
this.server = data.server;
|
|
this.emit("update::props");
|
|
}
|
|
if (!equals(this.streams, data.streams)) {
|
|
log("SERVER NEW STREAMS: " + this._streams.length);
|
|
this._updateStreams(data.streams);
|
|
}
|
|
if (!equals(this.groups, data.groups)) {
|
|
log("SERVER NEW GROUPS: " + this._groups.length);
|
|
this._updateGroups(data.groups);
|
|
}
|
|
}
|
|
|
|
, destroy: function() {
|
|
this._streams.forEach(function(stream) {stream.destroy();});
|
|
this.streams = [];
|
|
this._streams = [];
|
|
this._streamsByID = {};
|
|
this._groups.forEach(function(group) {group.destroy();});
|
|
this.groups = [];
|
|
this._groups = [];
|
|
this._groupsByID = {};
|
|
}
|
|
|
|
, _updateStreams: function(data) {
|
|
var old_ids = [];
|
|
if (Array.isArray(this.streams)) {
|
|
this.streams.forEach(function (stream) {
|
|
old_ids.push(stream.id);
|
|
}, this);
|
|
}
|
|
this.streams = data;
|
|
var new_ids = [];
|
|
var new_by_id = {};
|
|
this.streams.forEach(function (stream) {
|
|
new_ids.push(stream.id);
|
|
new_by_id[stream.id] = stream;
|
|
}, this);
|
|
|
|
// Delete old streams
|
|
old_ids.forEach(function(id) {
|
|
if (new_ids.indexOf(id) < 0) {
|
|
this._streams[this._streamsByID[id]].destroy();
|
|
this._streams.splice(this._streamsByID[id], 1);
|
|
delete this._streamsByID[id];
|
|
this.emit("streams::deleted", id);
|
|
}
|
|
}, this);
|
|
|
|
// Add new streams
|
|
new_ids.forEach(function(id) {
|
|
if ( !(id in this._streamsByID) ) {
|
|
var stream = new SnapcastStream(this._sclient, this);
|
|
this._streamsByID[id] = (this._streams.push(stream) - 1);
|
|
stream.update(new_by_id[id]);
|
|
this.emit("streams::added", stream);
|
|
}
|
|
}, this);
|
|
|
|
// Update existing streams
|
|
new_ids.forEach(function(id) {
|
|
if ( (id in this._streamsByID) ) {
|
|
var stream = this._streams[this._streamsByID[id]];
|
|
stream.update(new_by_id[id]);
|
|
}
|
|
}, this);
|
|
//this.emit("streams::updated");
|
|
}
|
|
|
|
, stream: function(id) {
|
|
return this._streams[this._streamsByID[id]];
|
|
}
|
|
|
|
, _updateGroups: function(data) {
|
|
var old_ids = [];
|
|
if (Array.isArray(this.groups)) {
|
|
this.groups.forEach(function (group) {
|
|
old_ids.push(group.id);
|
|
}, this);
|
|
}
|
|
this.groups = data;
|
|
var new_ids = [];
|
|
var new_by_id = {};
|
|
this.groups.forEach(function (group) {
|
|
new_ids.push(group.id);
|
|
new_by_id[group.id] = group;
|
|
}, this);
|
|
|
|
// Delete old groups
|
|
old_ids.forEach(function(id) {
|
|
if (new_ids.indexOf(id) < 0) {
|
|
log("REMOVE GROUP");
|
|
this.group(id).destroy();
|
|
this._groups.splice(this._groupsByID[id], 1);
|
|
delete this._groupsByID[id];
|
|
this.emit("groups::deleted", id);
|
|
}
|
|
}, this);
|
|
|
|
// Add new groups
|
|
new_ids.forEach(function(id) {
|
|
if ( !(id in this._groupsByID) ) {
|
|
var group = new SnapcastGroup(this._sclient, this);
|
|
this._groupsByID[id] = (this._groups.push(group) - 1);
|
|
group.update(new_by_id[id], true);
|
|
//this.emit("groups::added", group);
|
|
}
|
|
}, this);
|
|
|
|
// Update existing groups
|
|
new_ids.forEach(function(id) {
|
|
if ( (id in this._groupsByID) ) {
|
|
var group = this._groups[this._groupsByID[id]];
|
|
if (group) {
|
|
group.update(new_by_id[id]);
|
|
}
|
|
}
|
|
}, this);
|
|
//this.emit("groups::updated");
|
|
}
|
|
|
|
, group: function(id) {
|
|
return this._groups[this._groupsByID[id]];
|
|
}
|
|
|
|
, client: function(id) {
|
|
var found = null;
|
|
try {
|
|
this._groups.forEach(function (group) {
|
|
if (id in group._clientsByID) {
|
|
found = group._clients[group._clientsByID[id]];
|
|
throw BreakException;
|
|
}
|
|
})
|
|
} catch (e) {
|
|
if (e === BreakException) {
|
|
return found;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
Signals.addSignalMethods(SnapcastServer.prototype);
|
|
|
|
var StreamStates = {
|
|
"unknown": 0
|
|
, "idle": 1
|
|
, "playing": 2
|
|
, "disabled": 3
|
|
};
|
|
|
|
var SnapcastStream = new Lang.Class({
|
|
Name: "SnapcastStream"
|
|
|
|
, _sclient: null
|
|
, _parent: null
|
|
|
|
, _id: null
|
|
|
|
, _status: null
|
|
|
|
, _uri: null
|
|
|
|
, _init: function(sclient, parent) {
|
|
log("SNAPCAST: STREAM ADDED");
|
|
this._parent = parent;
|
|
this._sclient = sclient;
|
|
//this._parent.emit("streams::added", this)
|
|
return this;
|
|
}
|
|
|
|
, destroy: function() {
|
|
this.emit("destroy");
|
|
}
|
|
|
|
, update: function(data) {
|
|
if (!this._id && this._id != data.id) {
|
|
this._id = data.id;
|
|
//this.emit("updated::id");
|
|
}
|
|
if (this._status != data.status) {
|
|
this._status = data.status;
|
|
this.emit("updated::status", this);
|
|
}
|
|
if (!equals(this._uri, data.uri)) {
|
|
this._uri = data.uri;
|
|
this.emit("updated::props", this);
|
|
}
|
|
}
|
|
|
|
, get id() {
|
|
return this._id;
|
|
}
|
|
|
|
, get status() {
|
|
return this._status;
|
|
}
|
|
|
|
, get uri() {
|
|
return this._uri;
|
|
}
|
|
|
|
// TODO: Add/Remove stream
|
|
// TODO: Meta props
|
|
});
|
|
Signals.addSignalMethods(SnapcastStream.prototype);
|
|
|
|
var SnapcastGroup = new Lang.Class({
|
|
Name: "SnapcastGroup"
|
|
|
|
, _parent: null
|
|
, _sclient: null
|
|
|
|
, _id: null
|
|
, _streamID: null
|
|
, _name: ""
|
|
, _muted: false
|
|
|
|
, clients: []
|
|
, _clients: []
|
|
, _clientsByID: {}
|
|
|
|
, _init: function(sclient, parent) {
|
|
log("SNAPCAST: GROUP ADDED");
|
|
this._parent = parent;
|
|
this._sclient = sclient;
|
|
this._clients.forEach(function(client) {client.destroy();});
|
|
this.clients = [];
|
|
this._clients = [];
|
|
this._clientsByID = {};
|
|
//this._parent.emit("groups::added", this);
|
|
return this;
|
|
}
|
|
|
|
, destroy: function() {
|
|
log("SNAPCAST: GROUP DESTROY");
|
|
this._clients.forEach(function(client) {client.destroy();});
|
|
this.clients = [];
|
|
this._clients = [];
|
|
this._clientsByID = {};
|
|
this.emit("destroy");
|
|
// Recurse all clients
|
|
}
|
|
|
|
, update: function(data, is_new) {
|
|
if (!this._id && this._id != data.id) {
|
|
this._id = data.id;
|
|
}
|
|
if (this._streamID != data.stream_id) {
|
|
this._streamID = data.stream_id;
|
|
this.emit("updated::stream_id");
|
|
if (this._name == "") {
|
|
this.emit("updated::name");
|
|
}
|
|
}
|
|
if (this._name != data.name) {
|
|
this._name = data.name;
|
|
this.emit("updated::name");
|
|
}
|
|
if (this._muted != data.muted) {
|
|
this._muted = data.muted;
|
|
this.emit("updated::muted");
|
|
}
|
|
if (is_new) {
|
|
this._parent.emit("groups::added", this);
|
|
}
|
|
if (this._clients != data.clients) {
|
|
this._updateClients(data.clients);
|
|
}
|
|
}
|
|
|
|
, _updateClients: function(data) {
|
|
var has_new = [];
|
|
var has_deleted = [];
|
|
var old_ids = [];
|
|
if (Array.isArray(this.clients)) {
|
|
this.clients.forEach(function (client) {
|
|
old_ids.push(client.id);
|
|
}, this);
|
|
}
|
|
this.clients = data;
|
|
var new_ids = [];
|
|
var new_by_id = {};
|
|
this.clients.forEach(function (client) {
|
|
new_ids.push(client.id);
|
|
new_by_id[client.id] = client;
|
|
}, this);
|
|
|
|
// Delete old clients
|
|
old_ids.forEach(function(id) {
|
|
if (new_ids.indexOf(id) < 0) {
|
|
if ( !(id in this._clientsByID) ) {
|
|
return;
|
|
}
|
|
if (this._clientsByID[id] in this._clients) {
|
|
this._clients[this._clientsByID[id]].destroy();
|
|
}
|
|
this._clients.splice(this._clientsByID[id], 1);
|
|
delete this._clientsByID[id];
|
|
this._parent.emit("clients::deleted", id);
|
|
this.emit("clients::deleted", id);
|
|
has_deleted.push(id);
|
|
}
|
|
}, this);
|
|
|
|
// Add new clients
|
|
new_ids.forEach(function(id) {
|
|
if ( !(id in this._clientsByID) ) {
|
|
var client = new SnapcastClient(this._sclient, this);
|
|
this._clientsByID[id] = (this._clients.push(client) - 1);
|
|
client.update(new_by_id[id]);
|
|
has_new.push(client);
|
|
}
|
|
}, this);
|
|
if (has_new.length > 0) {
|
|
this._parent.emit("clients::added", has_new);
|
|
this.emit("clients::added", has_new);
|
|
}
|
|
|
|
// Update existing clients
|
|
new_ids.forEach(function(id) {
|
|
if ( (id in this._clientsByID) ) {
|
|
var client = this._clients[this._clientsByID[id]];
|
|
if (client) {
|
|
client.update(new_by_id[id]);
|
|
}
|
|
}
|
|
}, this);
|
|
|
|
if (has_deleted.length > 0 || has_new.length > 0) {
|
|
this.emit("updated::clients");
|
|
}
|
|
//this.emit("groups::updated");
|
|
}
|
|
|
|
, client: function(id) {
|
|
return this._clients[this._clientsByID[id]];
|
|
}
|
|
|
|
, get id() {
|
|
return this._id;
|
|
}
|
|
|
|
, get volume() {
|
|
var vol = 0;
|
|
//if (this._clients.length == 0) {
|
|
// return 0;
|
|
//}
|
|
for (var i = 0, ii = this._clients.length; i < ii; i++) {
|
|
vol += this._clients[i].volume;
|
|
}
|
|
return (vol / ii);
|
|
}
|
|
|
|
, set volume(value) {
|
|
var oldvol = this.volume;
|
|
if (oldvol === value) {
|
|
return
|
|
}
|
|
//if (this._clients.length == 0) {
|
|
// return 0;
|
|
//}
|
|
var delta = value - oldvol;
|
|
var ratio = 0.0;
|
|
if (delta < 0) {
|
|
ratio = (oldvol - value) / oldvol;
|
|
} else {
|
|
ratio = (value - oldvol) / (100 - oldvol);
|
|
}
|
|
for (var i = 0, ii = this._clients.length; i < ii; i++) {
|
|
var newvol = this._clients[i].volume;
|
|
if (delta < 0) {
|
|
newvol -= ratio * newvol;
|
|
} else {
|
|
newvol += ratio * (100 - newvol);
|
|
}
|
|
this._clients[i].volume = newvol;
|
|
}
|
|
}
|
|
|
|
, updateVolume: function() {
|
|
this.emit("updated::volume");
|
|
return this.volume;
|
|
}
|
|
|
|
, get muted() {
|
|
//if (this._clients.length === 1) {
|
|
// return this._clients[0].muted;
|
|
//}
|
|
return this._muted
|
|
}
|
|
|
|
, set muted(value) {
|
|
//if (this._clients.length === 1) {
|
|
// return (this._clients[0].muted = value);
|
|
//}
|
|
if (this.updateMuted(value) != null) {
|
|
this._sclient._sWrite("Group.SetMute", {id: this.id, mute: this.muted});
|
|
}
|
|
return this._muted;
|
|
}
|
|
|
|
, updateMuted: function(value) {
|
|
//if (this._clients.length === 1) {
|
|
// return (this._clients[0].updateMuted(value));
|
|
//}
|
|
if (this._muted === value) {
|
|
return
|
|
}
|
|
this._muted = value;
|
|
this.emit("updated::muted");
|
|
return this._muted;
|
|
}
|
|
|
|
, get name() {
|
|
/*if (this._clients.length === 1) {
|
|
return this._clients[0].name;
|
|
}*/
|
|
return (this._name ? this._name : this._streamID);
|
|
}
|
|
|
|
, get streamID() {
|
|
return this._streamID;
|
|
}
|
|
|
|
, set streamID(value) {
|
|
if (this.updateStreamID(value) != null) {
|
|
this._sclient._sWrite("Group.SetStream", {id: this.id, "stream_id": this._streamID});
|
|
}
|
|
return this._streamID;
|
|
}
|
|
|
|
, updateStreamID: function(value) {
|
|
if (this._streamID === value) {
|
|
return;
|
|
}
|
|
this._streamID = value;
|
|
this.emit("updated::stream_id");
|
|
if (this._name === "") {
|
|
this.emit("updated::name");
|
|
}
|
|
return this._streamID;
|
|
}
|
|
|
|
, setClients(cnew) {
|
|
cnew = cnew.sort();
|
|
var cold = [];
|
|
this._clients.forEach(function(client) {
|
|
cold.push(client.id);
|
|
})
|
|
cold = cold.sort();
|
|
if (equals(cold, cnew)) {
|
|
return;
|
|
}
|
|
this._sclient._sWrite("Group.SetClients", {id: this.id, "clients": cnew});
|
|
return cnew;
|
|
}
|
|
});
|
|
Signals.addSignalMethods(SnapcastGroup.prototype);
|
|
|
|
var SnapcastClient = new Lang.Class({
|
|
Name: "SnapcastClient"
|
|
|
|
, _sclient: null
|
|
, _parent: null
|
|
|
|
, _id: null
|
|
, _name: ""
|
|
, _volume: {}
|
|
, _connected: false
|
|
, _props: {}
|
|
|
|
, _init: function(sclient, parent) {
|
|
log("SNAPCAST: CLIENT ADDED")
|
|
this._sclient = sclient;
|
|
this._parent = parent;
|
|
this._blank();
|
|
this._parent.emit("updated::volume");
|
|
return this;
|
|
}
|
|
|
|
, update: function(data) {
|
|
if (!this._id && this._id != data.id) {
|
|
this._id = data.id;
|
|
}
|
|
var props = {
|
|
config: {
|
|
instance: data.config.instance
|
|
, latency: data.config.latency
|
|
}
|
|
, host: data.host
|
|
, snapclient: data.snapclient
|
|
, lastSeen: data.lastSeen
|
|
}
|
|
if (this._name != data.host.name) {
|
|
this._name = data.host.name;
|
|
this.emit("updated::name");
|
|
}
|
|
if (this._connected != data.connected) {
|
|
this._connected = data.connected;
|
|
this.emit("updated::connected");
|
|
}
|
|
if (this._volume != data.config.volume) {
|
|
this._volume = data.config.volume;
|
|
this.emit("updated::volume");
|
|
this.emit("updated::muted");
|
|
}
|
|
if (this._props != props) {
|
|
this._props = props;
|
|
this.emit("updated::props");
|
|
}
|
|
}
|
|
|
|
, destroy: function() {
|
|
log("SNAPCAST: CLIENT DESTROY");
|
|
this.emit("destroy");//, this._id);
|
|
this._blank();
|
|
}
|
|
|
|
, _blank: function() {
|
|
this._id = null;
|
|
this._name = "";
|
|
this._volume = {};
|
|
this._connected = false;
|
|
this._props = {};
|
|
}
|
|
|
|
, get id() {
|
|
return this._id;
|
|
}
|
|
|
|
, get groupID() {
|
|
return this._parent.id;
|
|
}
|
|
|
|
, get connected() {
|
|
return this._connected;
|
|
}
|
|
|
|
, updateConnected: function(value) {
|
|
if (this._connected === value) {
|
|
return;
|
|
}
|
|
this._connected = value;
|
|
if (this._connected) {
|
|
this.emit("updated::connected");
|
|
} else {
|
|
this.emit("updated::disconnected");
|
|
}
|
|
return this._volume.muted;
|
|
}
|
|
|
|
, get volume() {
|
|
return this._volume.percent;
|
|
}
|
|
|
|
, set volume(value) {
|
|
if (this.updateVolume(value) != null) {
|
|
this._sclient._sWrite("Client.SetVolume", {id: this.id, volume: this._volume});
|
|
}
|
|
return this._volume.percent;
|
|
}
|
|
|
|
, updateVolume: function(value) {
|
|
value = Math.round(value);
|
|
if (equals(this._volume.percent, value)) {
|
|
return;
|
|
}
|
|
this._volume.percent = value;
|
|
this._parent.updateVolume();
|
|
this.emit("updated::volume");
|
|
this._parent.emit("updated::volume");
|
|
return this._volume.percent;
|
|
}
|
|
|
|
, get muted() {
|
|
return this._volume.muted;
|
|
}
|
|
|
|
, set muted(value) {
|
|
if (this.updateMuted(value) != null) {
|
|
this._sclient._sWrite("Client.SetVolume", {id: this.id, volume: this._volume});
|
|
}
|
|
}
|
|
|
|
, updateMuted: function(value) {
|
|
if (equals(this._volume.muted, value)) {
|
|
return;
|
|
}
|
|
this._volume.muted = value;
|
|
this._parent.emit("updated::muted");
|
|
this.emit("updated::muted");
|
|
return this._volume.muted;
|
|
}
|
|
/*
|
|
, get name() {
|
|
return (this._name ? this._name : this.id);
|
|
}*/
|
|
, get name() {
|
|
return (this._name === "") ? this._props.host.name : this._name;
|
|
}
|
|
|
|
, set name(value) {
|
|
if (this.updateName(value) != null) {
|
|
this._sclient._sWrite("Client.SetName", {id: this.id, name: this._name});
|
|
}
|
|
return this._name;
|
|
}
|
|
|
|
, updateName: function(value) {
|
|
if (this._name === value) {
|
|
return;
|
|
}
|
|
this._name = value;
|
|
this.emit("updated::name");
|
|
this._parent.emit("updated::name", this);
|
|
return this._name;
|
|
}
|
|
|
|
, delete: function() {
|
|
this._sclient._sWrite("Server.DeleteClient", {id: this.id});
|
|
}
|
|
|
|
, get props() {
|
|
return this._props;
|
|
}
|
|
|
|
, set latency(value) {
|
|
if (value != this._props.config.latency) {
|
|
this._sclient._sWrite("Client.SetLatency", {id: this.id, latency: value});
|
|
this._props.config.latency = value;
|
|
}
|
|
return value;
|
|
}
|
|
});
|
|
Signals.addSignalMethods(SnapcastClient.prototype);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* value_equals.js
|
|
|
|
The MIT License (MIT)
|
|
|
|
Copyright (c) 2013-2017, Reactive Sets
|
|
|
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
of this software and associated documentation files (the "Software"), to deal
|
|
in the Software without restriction, including without limitation the rights
|
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
copies of the Software, and to permit persons to whom the Software is
|
|
furnished to do so, subject to the following conditions:
|
|
|
|
The above copyright notice and this permission notice shall be included in all
|
|
copies or substantial portions of the Software.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
SOFTWARE.
|
|
*/
|
|
/* -----------------------------------------------------------------------------------------
|
|
equals( a, b [, enforce_properties_order, cyclic] )
|
|
|
|
Returns true if a and b are deeply equal, false otherwise.
|
|
|
|
Parameters:
|
|
- a (Any type): value to compare to b
|
|
- b (Any type): value compared to a
|
|
|
|
Optional Parameters:
|
|
- enforce_properties_order (Boolean): true to check if Object properties are provided
|
|
in the same order between a and b
|
|
|
|
- cyclic (Boolean): true to check for cycles in cyclic objects
|
|
|
|
Implementation:
|
|
'a' is considered equal to 'b' if all scalar values in a and b are strictly equal as
|
|
compared with operator '===' except for these two special cases:
|
|
- 0 === -0 but are not equal.
|
|
- NaN is not === to itself but is equal.
|
|
|
|
RegExp objects are considered equal if they have the same lastIndex, i.e. both regular
|
|
expressions have matched the same number of times.
|
|
|
|
Functions must be identical, so that they have the same closure context.
|
|
|
|
"undefined" is a valid value, including in Objects
|
|
|
|
106 automated tests.
|
|
|
|
Provide options for slower, less-common use cases:
|
|
|
|
- Unless enforce_properties_order is true, if 'a' and 'b' are non-Array Objects, the
|
|
order of occurence of their attributes is considered irrelevant:
|
|
{ a: 1, b: 2 } is considered equal to { b: 2, a: 1 }
|
|
|
|
- Unless cyclic is true, Cyclic objects will throw:
|
|
RangeError: Maximum call stack size exceeded
|
|
*/
|
|
function equals( a, b, enforce_properties_order, cyclic ) {
|
|
return a === b // strick equality should be enough unless zero
|
|
&& a !== 0 // because 0 === -0, requires test by _equals()
|
|
|| _equals( a, b ) // handles not strictly equal or zero values
|
|
;
|
|
|
|
function _equals( a, b ) {
|
|
// a and b have already failed test for strict equality or are zero
|
|
|
|
var s, l, p, x, y;
|
|
|
|
// They should have the same toString() signature
|
|
if ( ( s = toString.call( a ) ) !== toString.call( b ) ) return false;
|
|
|
|
switch( s ) {
|
|
default: // Boolean, Date, String
|
|
return a.valueOf() === b.valueOf();
|
|
|
|
case '[object Number]':
|
|
// Converts Number instances into primitive values
|
|
// This is required also for NaN test bellow
|
|
a = +a;
|
|
b = +b;
|
|
|
|
return a ? // a is Non-zero and Non-NaN
|
|
a === b
|
|
: // a is 0, -0 or NaN
|
|
a === a ? // a is 0 or -O
|
|
1/a === 1/b // 1/0 !== 1/-0 because Infinity !== -Infinity
|
|
: b !== b // NaN, the only Number not equal to itself!
|
|
;
|
|
// [object Number]
|
|
|
|
case '[object RegExp]':
|
|
return a.source == b.source
|
|
&& a.global == b.global
|
|
&& a.ignoreCase == b.ignoreCase
|
|
&& a.multiline == b.multiline
|
|
&& a.lastIndex == b.lastIndex
|
|
;
|
|
// [object RegExp]
|
|
|
|
case '[object Function]':
|
|
return false; // functions should be strictly equal because of closure context
|
|
// [object Function]
|
|
|
|
case '[object Array]':
|
|
if ( cyclic && ( x = reference_equals( a, b ) ) !== null ) return x; // intentionally duplicated bellow for [object Object]
|
|
|
|
if ( ( l = a.length ) != b.length ) return false;
|
|
// Both have as many elements
|
|
|
|
while ( l-- ) {
|
|
if ( ( x = a[ l ] ) === ( y = b[ l ] ) && x !== 0 || _equals( x, y ) ) continue;
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
// [object Array]
|
|
|
|
case '[object Object]':
|
|
if ( cyclic && ( x = reference_equals( a, b ) ) !== null ) return x; // intentionally duplicated from above for [object Array]
|
|
|
|
l = 0; // counter of own properties
|
|
|
|
if ( enforce_properties_order ) {
|
|
var properties = [];
|
|
|
|
for ( p in a ) {
|
|
if ( a.hasOwnProperty( p ) ) {
|
|
properties.push( p );
|
|
|
|
if ( ( x = a[ p ] ) === ( y = b[ p ] ) && x !== 0 || _equals( x, y ) ) continue;
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check if 'b' has as the same properties as 'a' in the same order
|
|
for ( p in b )
|
|
if ( b.hasOwnProperty( p ) && properties[ l++ ] != p )
|
|
return false;
|
|
} else {
|
|
for ( p in a ) {
|
|
if ( a.hasOwnProperty( p ) ) {
|
|
++l;
|
|
|
|
if ( ( x = a[ p ] ) === ( y = b[ p ] ) && x !== 0 || _equals( x, y ) ) continue;
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check if 'b' has as not more own properties than 'a'
|
|
for ( p in b )
|
|
if ( b.hasOwnProperty( p ) && --l < 0 )
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
// [object Object]
|
|
} // switch toString.call( a )
|
|
} // _equals()
|
|
|
|
/* -----------------------------------------------------------------------------------------
|
|
reference_equals( a, b )
|
|
|
|
Helper function to compare object references on cyclic objects or arrays.
|
|
|
|
Returns:
|
|
- null if a or b is not part of a cycle, adding them to object_references array
|
|
- true: same cycle found for a and b
|
|
- false: different cycle found for a and b
|
|
|
|
On the first call of a specific invocation of equal(), replaces self with inner function
|
|
holding object_references array object in closure context.
|
|
|
|
This allows to create a context only if and when an invocation of equal() compares
|
|
objects or arrays.
|
|
*/
|
|
function reference_equals( a, b ) {
|
|
var object_references = [];
|
|
|
|
return ( reference_equals = _reference_equals )( a, b );
|
|
|
|
function _reference_equals( a, b ) {
|
|
var l = object_references.length;
|
|
|
|
while ( l-- )
|
|
if ( object_references[ l-- ] === b )
|
|
return object_references[ l ] === a;
|
|
|
|
object_references.push( a, b );
|
|
|
|
return null;
|
|
} // _reference_equals()
|
|
} // reference_equals()
|
|
} // equals()
|