Files
Iris/src/js/services/snapcast/middleware.js
2020-08-03 21:02:18 +12:00

639 lines
18 KiB
JavaScript
Executable File

import ReactGA from 'react-ga';
import { sha256 } from 'js-sha256';
import {
generateGuid,
} from '../../util/helpers';
import {
arrayOf,
} from '../../util/arrays';
import {
formatGroup,
formatClient,
} from '../../util/format';
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;
let reconnectTimer = 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);
}
// Some messages are arrays of messages
if (Array.isArray(message)) {
message.map(messageItem => handleMessage(ws, store, messageItem));
return;
}
// 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 {
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;
default:
break;
}
}
};
const request = (store, method, params = null) => new Promise((resolve, reject) => {
const id = 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();
clearTimeout(reconnectTimer);
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) {
clearTimeout(reconnectTimer);
reconnectTimer = 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) => {
handleMessage(socket, store, JSON.parse(message.data));
};
break;
case 'SNAPCAST_CONNECTED':
if (store.getState().ui.allow_reporting) {
ReactGA.event({
category: 'Snapcast',
action: 'Connected',
label: sha256(window.location.hostname),
});
}
store.dispatch(snapcastActions.getServer());
next(action);
break;
case 'SNAPCAST_DISCONNECT':
if (socket != null) socket.close();
socket = null;
clearTimeout(reconnectTimer);
break;
case 'SNAPCAST_SET_CONNECTION':
store.dispatch(snapcastActions.serverLoaded({}));
store.dispatch(snapcastActions.clientsLoaded([]));
store.dispatch(snapcastActions.groupsLoaded([]));
store.dispatch(snapcastActions.streamsLoaded([]));
store.dispatch(snapcastActions.set(action.data));
// Wait 250 ms and then retry connection
if (store.getState().snapcast.enabled) {
setTimeout(
() => {
store.dispatch(snapcastActions.connect());
},
250,
);
}
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 clients_loaded = [];
const groups_loaded = action.groups.map(raw_group => {
let group = formatGroup(raw_group);
if (groups_index[group.id]) {
group = { ...groups_index[group.id], ...group };
}
if (raw_group.clients) {
group.clients_ids = arrayOf('id', raw_group.clients);
clients_loaded = [...clients_loaded, ...raw_group.clients];
}
// Create a name (display only) based on it's ID
if (group.name === undefined || group.name === '') {
group.name = `Group ${group.id.substring(0, 3)}`;
}
return 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 = 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,
},
));
/*
// A group was referenced, so we should update the group's averaged volume
if (action.group_id) {
const group = snapcast.groups[action.group_id];
const clients = [];
snapcast.groups.filter
store.dispatch(snapcastActions.calculateGroupVolume(group.id, clients));
}
*/
},
(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.groupsLoaded(response.server.groups, true));
},
(error) => {
store.dispatch(coreActions.handleException(
'Error',
error,
error.message,
));
},
);
break;
case 'SNAPCAST_DELETE_CLIENT':
request(store, 'Server.DeleteClient', { id: action.id })
.then(
() => {
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,
mute: 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));
}
store.dispatch(snapcastActions.groupLoaded({
id: action.id,
volume: action.percent,
}))
break;
default:
return next(action);
}
};
}());
export default SnapcastMiddleware;