601 lines
17 KiB
JavaScript
Executable File
601 lines
17 KiB
JavaScript
Executable File
|
|
import ReactGA from 'react-ga';
|
|
import { sha256 } from 'js-sha256';
|
|
|
|
const helpers = require('../../helpers');
|
|
const coreActions = require('../core/actions');
|
|
const uiActions = require('../ui/actions');
|
|
const pusherActions = require('../pusher/actions');
|
|
const snapcastActions = require('./actions');
|
|
|
|
const SnapcastMiddleware = (function () {
|
|
let socket = null;
|
|
|
|
// requests pending
|
|
const deferredRequests = [];
|
|
|
|
// handle all manner of socket messages
|
|
const handleMessage = (ws, store, message) => {
|
|
if (store.getState().ui.log_snapcast) {
|
|
console.log('Snapcast log (incoming)', message);
|
|
}
|
|
|
|
// Pull our ID. JSON-RPC nests the ID under the error object,
|
|
// so make sure we handle that.
|
|
// TODO: Use this as our measure of a successful response vs error
|
|
let id = null;
|
|
if (message.id) {
|
|
id = message.id;
|
|
} else if (message.error && message.error.id) {
|
|
id = message.error.id;
|
|
}
|
|
|
|
// Response with request_id
|
|
if (id) {
|
|
// Response matches a pending request
|
|
if (deferredRequests[id] !== undefined) {
|
|
store.dispatch(uiActions.stopLoading(id));
|
|
|
|
// Response is an error
|
|
if (message.error !== undefined) {
|
|
deferredRequests[id].reject(message.error);
|
|
|
|
// Successful response
|
|
} else {
|
|
deferredRequests[id].resolve(message.result);
|
|
}
|
|
|
|
// Hmm, the response doesn't appear to be for us?
|
|
} else {
|
|
store.dispatch(coreActions.handleException(
|
|
'Snapcast: Response received with no matching request',
|
|
message,
|
|
));
|
|
}
|
|
|
|
// General broadcast received
|
|
} else {
|
|
// Broadcast of an error
|
|
if (message.error !== undefined) {
|
|
store.dispatch(coreActions.handleException(
|
|
`Snapcast: ${message.error.message}`,
|
|
message,
|
|
(message.error.data !== undefined && message.error.data.description !== undefined ? message.error.data.description : null),
|
|
));
|
|
} else {
|
|
switch (message.method) {
|
|
case 'Client.OnConnect':
|
|
store.dispatch(snapcastActions.clientLoaded(message.params.client));
|
|
break;
|
|
|
|
case 'Client.OnDisconnect':
|
|
store.dispatch(snapcastActions.clientLoaded(message.params.client));
|
|
break;
|
|
|
|
case 'Client.OnVolumeChanged':
|
|
store.dispatch(snapcastActions.clientLoaded(message.params));
|
|
break;
|
|
|
|
case 'Client.OnLatencyChanged':
|
|
store.dispatch(snapcastActions.clientLoaded(message.params));
|
|
break;
|
|
|
|
case 'Client.OnNameChanged':
|
|
store.dispatch(snapcastActions.clientLoaded(message.params));
|
|
break;
|
|
|
|
case 'Group.OnMute':
|
|
store.dispatch(snapcastActions.groupLoaded(message.params));
|
|
break;
|
|
|
|
case 'Server.OnUpdate':
|
|
store.dispatch(snapcastActions.serverLoaded(message.param));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const request = (store, method, params = null) => new Promise((resolve, reject) => {
|
|
const id = helpers.generateGuid(8);
|
|
const message = {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
method,
|
|
};
|
|
if (params) {
|
|
message.params = params;
|
|
}
|
|
|
|
if (store.getState().ui.log_snapcast) {
|
|
console.log('Snapcast log (outgoing)', message);
|
|
}
|
|
|
|
socket.send(JSON.stringify(message));
|
|
|
|
store.dispatch(uiActions.startLoading(id, `snapcast_${method}`));
|
|
|
|
// Start our 30 second timeout
|
|
const timeout = setTimeout(
|
|
() => {
|
|
store.dispatch(uiActions.stopLoading(id));
|
|
reject({
|
|
id,
|
|
code: 32300,
|
|
message: 'Request timed out',
|
|
});
|
|
},
|
|
30000,
|
|
);
|
|
|
|
// add query to our deferred responses
|
|
deferredRequests[id] = {
|
|
resolve,
|
|
reject,
|
|
};
|
|
});
|
|
|
|
return (store) => (next) => (action) => {
|
|
const { snapcast } = store.getState();
|
|
|
|
switch (action.type) {
|
|
|
|
case 'SNAPCAST_CONNECT':
|
|
if (socket) {
|
|
socket.close();
|
|
}
|
|
|
|
store.dispatch({ type: 'SNAPCAST_CONNECTING' });
|
|
|
|
socket = new WebSocket(
|
|
`ws${window.location.protocol === 'https:' ? 's' : ''}://${store.getState().snapcast.host}:${store.getState().snapcast.port}/jsonrpc`,
|
|
);
|
|
|
|
socket.onopen = () => {
|
|
store.dispatch({
|
|
type: 'SNAPCAST_CONNECTED',
|
|
});
|
|
};
|
|
|
|
socket.onclose = (e) => {
|
|
store.dispatch({
|
|
type: 'SNAPCAST_DISCONNECTED',
|
|
});
|
|
|
|
// attempt to reconnect every 5 seconds
|
|
if (store.getState().snapcast.enabled) {
|
|
setTimeout(() => {
|
|
store.dispatch(snapcastActions.connect());
|
|
}, 5000);
|
|
}
|
|
};
|
|
|
|
socket.onerror = (e) => {
|
|
if (socket.readyState == 1) {
|
|
store.dispatch(coreActions.handleException(
|
|
'Snapcast websocket error',
|
|
e,
|
|
e.type,
|
|
));
|
|
}
|
|
};
|
|
|
|
socket.onmessage = (message) => {
|
|
var message = JSON.parse(message.data);
|
|
handleMessage(socket, store, message);
|
|
};
|
|
break;
|
|
|
|
case 'SNAPCAST_CONNECTED':
|
|
if (store.getState().ui.allow_reporting) {
|
|
const hashed_hostname = sha256(window.location.hostname);
|
|
ReactGA.event({ category: 'Snapcast', action: 'Connected', label: hashed_hostname });
|
|
}
|
|
store.dispatch(uiActions.createNotification({ content: 'Snapcast connected' }));
|
|
store.dispatch(snapcastActions.getServer());
|
|
next(action);
|
|
break;
|
|
|
|
case 'SNAPCAST_DISCONNECT':
|
|
if (socket != null) socket.close();
|
|
socket = null;
|
|
break;
|
|
|
|
case 'SNAPCAST_DISCONNECTED':
|
|
if (store.getState().snapcast.enabled) {
|
|
store.dispatch(uiActions.createNotification({ type: 'bad', content: 'Snapcast disconnected' }));
|
|
}
|
|
break;
|
|
|
|
case 'SNAPCAST_DEBUG':
|
|
request(store, action.message.method, action.message.data)
|
|
.then(
|
|
(response) => {
|
|
store.dispatch({ type: 'DEBUG', response });
|
|
},
|
|
(error) => {
|
|
store.dispatch(coreActions.handleException(
|
|
'Could not debug',
|
|
error,
|
|
error.message,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_REQUEST':
|
|
request(store, action.method, action.params)
|
|
.then(
|
|
(response) => {
|
|
if (action.response_callback) {
|
|
action.response_callback.call(this, response);
|
|
}
|
|
},
|
|
(error) => {
|
|
if (action.error_callback) {
|
|
action.error_callback.call(this, error);
|
|
} else {
|
|
store.dispatch(coreActions.handleException(
|
|
'Snapcast request failed',
|
|
error,
|
|
action.method,
|
|
action,
|
|
));
|
|
}
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_SET_ENABLED':
|
|
store.dispatch(snapcastActions.set({ enabled: action.enabled }));
|
|
store.dispatch(action.enabled ? snapcastActions.connect() : snapcastActions.disconnect());
|
|
break;
|
|
|
|
case 'SNAPCAST_GET_SERVER':
|
|
request(store, 'Server.GetStatus')
|
|
.then(
|
|
(response) => {
|
|
store.dispatch(snapcastActions.serverLoaded(response.server.server, true));
|
|
store.dispatch(snapcastActions.groupsLoaded(response.server.groups, true));
|
|
store.dispatch(snapcastActions.streamsLoaded(response.server.streams, true));
|
|
},
|
|
(error) => {
|
|
store.dispatch(coreActions.handleException(
|
|
'Could not get Snapcast server',
|
|
error,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_GROUPS_LOADED':
|
|
var groups_index = { ...snapcast.groups };
|
|
var groups_loaded = [];
|
|
var clients_loaded = [];
|
|
|
|
for (const raw_group of action.groups) {
|
|
var group = helpers.formatGroup(raw_group);
|
|
|
|
if (groups_index[group.id]) {
|
|
group = { ...groups_index[group.id], ...group };
|
|
}
|
|
|
|
if (raw_group.clients) {
|
|
group.clients_ids = helpers.arrayOf('id', raw_group.clients);
|
|
clients_loaded = [...clients_loaded, ...raw_group.clients];
|
|
}
|
|
|
|
groups_loaded.push(group);
|
|
}
|
|
|
|
action.groups = groups_loaded;
|
|
|
|
if (clients_loaded.length > 0) {
|
|
store.dispatch(snapcastActions.clientsLoaded(clients_loaded, action.flush));
|
|
}
|
|
|
|
next(action);
|
|
break;
|
|
|
|
case 'SNAPCAST_CLIENTS_LOADED':
|
|
var clients_index = { ...snapcast.clients };
|
|
var clients_loaded = [];
|
|
|
|
for (const raw_client of action.clients) {
|
|
var client = helpers.formatClient(raw_client);
|
|
|
|
if (clients_index[client.id]) {
|
|
client = { ...clients_index[client.id], ...client };
|
|
}
|
|
|
|
clients_loaded.push(client);
|
|
}
|
|
|
|
action.clients = clients_loaded;
|
|
|
|
next(action);
|
|
break;
|
|
|
|
case 'SNAPCAST_SET_CLIENT_NAME':
|
|
var client = snapcast.clients[action.id];
|
|
var params = {
|
|
id: action.id,
|
|
name: action.name,
|
|
};
|
|
|
|
request(store, 'Client.SetName', params)
|
|
.then(
|
|
response => {
|
|
store.dispatch(snapcastActions.clientLoaded(
|
|
{
|
|
id: action.id,
|
|
name: response.name,
|
|
},
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_SET_CLIENT_MUTE':
|
|
var client = store.getState().snapcast.clients[action.id];
|
|
var params = {
|
|
id: action.id,
|
|
volume: {
|
|
muted: action.mute,
|
|
percent: client.volume,
|
|
},
|
|
};
|
|
|
|
request(store, 'Client.SetVolume', params)
|
|
.then(
|
|
response => {
|
|
store.dispatch(snapcastActions.clientLoaded(
|
|
{
|
|
id: action.id,
|
|
volume: response.volume.percent,
|
|
mute: response.volume.muted,
|
|
},
|
|
));
|
|
},
|
|
error => {
|
|
store.dispatch(coreActions.handleException(
|
|
'Error',
|
|
error,
|
|
error.message,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_SET_CLIENT_VOLUME':
|
|
var client = snapcast.clients[action.id];
|
|
var params = {
|
|
id: action.id,
|
|
volume: {
|
|
muted: client.mute,
|
|
percent: action.volume,
|
|
},
|
|
};
|
|
|
|
request(store, 'Client.SetVolume', params)
|
|
.then(
|
|
response => {
|
|
store.dispatch(snapcastActions.clientLoaded(
|
|
{
|
|
id: action.id,
|
|
volume: response.volume.percent,
|
|
},
|
|
));
|
|
},
|
|
(error) => {
|
|
store.dispatch(coreActions.handleException(
|
|
'Error',
|
|
error,
|
|
error.message,
|
|
));
|
|
}
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_SET_CLIENT_LATENCY':
|
|
var client = store.getState().snapcast.clients[action.id];
|
|
var params = {
|
|
id: action.id,
|
|
latency: action.latency,
|
|
};
|
|
|
|
request(store, 'Client.SetLatency', params)
|
|
.then(
|
|
response => {
|
|
store.dispatch(snapcastActions.clientLoaded(
|
|
{
|
|
id: action.id,
|
|
latency: response.latency,
|
|
},
|
|
));
|
|
},
|
|
error => {
|
|
store.dispatch(coreActions.handleException(
|
|
'Error',
|
|
error,
|
|
error.message,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_SET_CLIENT_GROUP':
|
|
|
|
var group = snapcast.groups[action.group_id];
|
|
var { clients_ids } = group;
|
|
var clients_ids_index = clients_ids.indexOf(action.id);
|
|
|
|
// Not in group (yet), so add it
|
|
if (clients_ids_index <= -1) {
|
|
clients_ids.push(action.id);
|
|
|
|
// Already there, so remove it
|
|
} else {
|
|
clients_ids.splice(clients_ids_index, 1);
|
|
}
|
|
|
|
var params = {
|
|
id: action.group_id,
|
|
clients: clients_ids,
|
|
};
|
|
|
|
request(store, 'Group.SetClients', params)
|
|
.then(
|
|
response => {
|
|
store.dispatch(snapcastActions.serverLoaded(response.server));
|
|
},
|
|
error => {
|
|
store.dispatch(coreActions.handleException(
|
|
'Error',
|
|
error,
|
|
error.message,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_DELETE_CLIENT':
|
|
var params = {
|
|
id: action.id,
|
|
};
|
|
|
|
request(store, 'Server.DeleteClient', params)
|
|
.then(
|
|
response => {
|
|
store.dispatch({
|
|
type: 'SNAPCAST_CLIENT_REMOVED',
|
|
key: action.data.params.id,
|
|
});
|
|
},
|
|
error => {
|
|
store.dispatch(coreActions.handleException(
|
|
'Error',
|
|
error,
|
|
error.message,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_SET_GROUP_NAME':
|
|
var group = snapcast.groups[action.id];
|
|
var params = {
|
|
id: action.id,
|
|
name: action.name,
|
|
};
|
|
|
|
request(store, 'Group.SetName', params)
|
|
.then(
|
|
response => {
|
|
store.dispatch(snapcastActions.groupLoaded(
|
|
{
|
|
id: action.id,
|
|
name: response.name,
|
|
},
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_SET_GROUP_STREAM':
|
|
var group = store.getState().snapcast.groups[action.id];
|
|
var params = {
|
|
id: action.id,
|
|
stream_id: action.stream_id,
|
|
};
|
|
|
|
request(store, 'Group.SetStream', params)
|
|
.then(
|
|
response => {
|
|
store.dispatch(snapcastActions.groupLoaded(
|
|
{
|
|
id: action.id,
|
|
stream_id: action.stream_id,
|
|
},
|
|
));
|
|
},
|
|
error => {
|
|
store.dispatch(coreActions.handleException(
|
|
'Could not change stream',
|
|
error,
|
|
error.message,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_SET_GROUP_MUTE':
|
|
var group = store.getState().snapcast.groups[action.id];
|
|
var params = {
|
|
id: action.id,
|
|
mute: action.mute,
|
|
};
|
|
|
|
request(store, 'Group.SetMute', params)
|
|
.then(
|
|
response => {
|
|
store.dispatch(snapcastActions.groupLoaded(
|
|
{
|
|
id: action.id,
|
|
muted: response.mute,
|
|
},
|
|
));
|
|
},
|
|
error => {
|
|
store.dispatch(coreActions.handleException(
|
|
'Could not toggle mute',
|
|
error,
|
|
error.message,
|
|
));
|
|
},
|
|
);
|
|
break;
|
|
|
|
case 'SNAPCAST_SET_GROUP_VOLUME':
|
|
var clients_to_update = [];
|
|
var group = snapcast.groups[action.id];
|
|
var change = action.percent - action.old_percent;
|
|
|
|
for (const client_id of group.clients_ids) {
|
|
// Apply the change proportionately to each client
|
|
var client = snapcast.clients[client_id];
|
|
const current_volume = client.volume;
|
|
const new_volume = current_volume + change;
|
|
|
|
// Only change if the client is within min/max limits
|
|
if ((change > 0 && current_volume < 100) || (change < 0 && current_volume > 0)) {
|
|
clients_to_update.push({
|
|
id: client.id,
|
|
volume: new_volume,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Loop our required changes, and post each to Snapcast
|
|
for (const client_to_update of clients_to_update) {
|
|
let volume = client_to_update.volume + ((group.clients_ids.length - clients_to_update.length) * change);
|
|
|
|
// Make sure we're not creating an impossible percent
|
|
if (volume < 0) {
|
|
volume = 0;
|
|
} else if (volume > 100) {
|
|
volume = 100;
|
|
}
|
|
|
|
store.dispatch(snapcastActions.setClientVolume(client_to_update.id, volume));
|
|
}
|
|
break;
|
|
|
|
default:
|
|
return next(action);
|
|
}
|
|
};
|
|
}());
|
|
|
|
export default SnapcastMiddleware;
|