WIP: Snapcast

This commit is contained in:
James Barnsley
2019-10-25 15:47:25 +13:00
parent b75c2a67f8
commit 88a5efaa05
10 changed files with 10442 additions and 5607 deletions

View File

@ -20,7 +20,7 @@ import * as spotifyActions from '../services/spotify/actions';
import * as lastfmActions from '../services/lastfm/actions';
import * as geniusActions from '../services/genius/actions';
class Services extends React.Component {
class Services extends React.Component {
constructor(props) {
super(props);
this.state = {
@ -40,7 +40,7 @@ class Services extends React.Component {
if (this.props.genius.authorization && (!this.props.genius.me || this.props.core.users[`genius:user:${this.props.genius.me.id}`] === undefined)) {
this.props.geniusActions.getMe();
}
}
}
componentWillReceiveProps(newProps) {
let changed = false;
@ -96,10 +96,9 @@ class Services extends React.Component {
if (!this.props.mopidy.uri_schemes || !this.props.mopidy.uri_schemes.includes('spotify:')) {
not_installed = (
<div>
<p className="message warning">
<em>Mopidy-Spotify</em>
{' '}
extension is not running - you will not be able to play any Spotify tracks
<p className="message warning">
<em>Mopidy-Spotify</em>
{' extension is not running - you will not be able to play any Spotify tracks'}
</p>
<br />
</div>
@ -117,17 +116,14 @@ extension is not running - you will not be able to play any Spotify tracks
onChange={(e) => this.setState({ country: e.target.value })}
onFocus={(e) => this.setState({ input_in_focus: 'country' })}
onBlur={(e) => this.props.spotifyActions.set({ country: e.target.value })}
value={this.state.country}
value={this.state.country}
/>
<div className="description">
An
{' '}
<a href="http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2" target="_blank">ISO 3166-1 alpha-2</a>
{' '}
country code (eg
{' '}
<em>NZ</em>
)
{'An '}
<a href="http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2" target="_blank">ISO 3166-1 alpha-2</a>
{' country code (eg '}
<em>NZ</em>
{')'}
</div>
</div>
</label>
@ -139,20 +135,16 @@ country code (eg
onChange={(e) => this.setState({ locale: e.target.value })}
onFocus={(e) => this.setState({ input_in_focus: 'locale' })}
onBlur={(e) => this.props.spotifyActions.set({ locale: e.target.value })}
value={this.state.locale}
value={this.state.locale}
/>
<div className="description">
Lowercase
{' '}
<a href="http://en.wikipedia.org/wiki/ISO_639" target="_blank">ISO 639 language code</a>
{' '}
and an uppercase
{' '}
<a href="http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2" target="_blank">ISO 3166-1 alpha-2 country code</a>
, joined by an underscore (eg
{' '}
<em>en_NZ</em>
)
{'Lowercase '}
<a href="http://en.wikipedia.org/wiki/ISO_639" target="_blank">ISO 639 language code</a>
{' and an uppercase '}
<a href="http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2" target="_blank">ISO 3166-1 alpha-2 country code</a>
{', joined by an underscore (eg'}
<em>en_NZ</em>
{')'}
</div>
</div>
</label>
@ -170,7 +162,16 @@ and an uppercase
<div className="name">Authorization</div>
<div className="input">
<SpotifyAuthenticationFrame />
{this.props.spotify.refreshing_token ? <a className="button button--working">Refreshing...</a> : <a className="button button--default" onClick={(e) => this.props.spotifyActions.refreshingToken()}>Force token refresh</a>}
{this.props.spotify.refreshing_token ? (
<a className="button button--working">Refreshing...</a>
) : (
<a
className="button button--default"
onClick={(e) => this.props.spotifyActions.refreshingToken()}
>
Force token refresh
</a>
)}
</div>
</div>
</div>
@ -201,7 +202,7 @@ and an uppercase
return (
<div>
{this.props.lastfm.authorization ? (
{this.props.lastfm.authorization ? (
<div className="field current-user">
<div className="name">Current user</div>
<div className="input">
@ -209,7 +210,7 @@ and an uppercase
{user}
</div>
</div>
</div>
</div>
) : null}
<div className="field">
@ -246,7 +247,7 @@ and an uppercase
return (
<div>
{this.props.genius.authorization ? (
{this.props.genius.authorization ? (
<div className="field current-user">
<div className="name">Current user</div>
<div className="input">
@ -254,7 +255,7 @@ and an uppercase
{user}
</div>
</div>
</div>
</div>
) : null}
<div className="field">
@ -278,7 +279,7 @@ and an uppercase
type="checkbox"
name="ssl"
checked={this.props.core.http_streaming_enabled}
onChange={(e) => this.props.coreActions.set({ http_streaming_enabled: !this.props.core.http_streaming_enabled })}
onChange={(e) => this.props.coreActions.set({ http_streaming_enabled: !this.props.core.http_streaming_enabled })}
/>
<span className="label">
Stream audio to this browser
@ -292,7 +293,7 @@ and an uppercase
<input
type="text"
onChange={(e) => this.props.coreActions.set({ http_streaming_url: e.target.value })}
value={this.props.core.http_streaming_url}
value={this.props.core.http_streaming_url}
/>
<div className="description">
The full URL to your stream endpoint
@ -303,7 +304,7 @@ and an uppercase
);
}
renderMenu() {
renderMenu() {
if (this.props.spotify.me && this.props.core.users[this.props.spotify.me.uri]) {
var spotify_icon = <Thumbnail className="menu-item__thumbnail" circle size="small" images={this.props.core.users[this.props.spotify.me.uri].images} />;
} else {
@ -402,7 +403,7 @@ and an uppercase
</div>
);
}
return null;
return null;
}
render() {

View File

@ -2,22 +2,19 @@
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import Link from './Link';
import VolumeControl from './Fields/VolumeControl';
import MuteControl from './Fields/MuteControl';
import LatencyControl from './Fields/LatencyControl';
import TextField from './Fields/TextField';
import DropdownField from './Fields/DropdownField';
import Icon from './Icon';
import * as helpers from '../helpers';
import * as coreActions from '../services/core/actions';
import * as uiActions from '../services/ui/actions';
import * as pusherActions from '../services/pusher/actions';
import * as snapcastActions from '../services/snapcast/actions';
class Snapcast extends React.Component {
class Snapcast extends React.Component {
constructor(props) {
super(props);
@ -27,21 +24,16 @@ class Snapcast extends React.Component {
}
componentDidMount() {
if (this.props.pusher_connected && this.props.snapcast_enabled) {
this.props.snapcastActions.getServer();
const { connected, actions: snapcastActions } = this.props;
if (this.props.connected) {
actions.getServer();
}
}
componentWillReceiveProps(newProps) {
// Just connected
if (newProps.snapcast_enabled && !this.props.pusher_connected && newProps.pusher_connected) {
this.props.snapcastActions.getServer();
// Just enabled
// This is the more probable scenario as we don't know if we're enabled until pusher connects
// and then gets the config from the server
} else if (!this.props.snapcast_enabled && newProps.snapcast_enabled && newProps.pusher_connected) {
this.props.snapcastActions.getServer();
componentWillReceiveProps(newProps) {
const { connected, actions: snapcastActions } = this.props;
if (newProps.connected && !connected) {
actions.getServer();
}
}
@ -58,7 +50,11 @@ class Snapcast extends React.Component {
this.setState({ clients_expanded });
}
renderClientsList(group, groups) {
renderClientsList(group, groups) {
const {
snapcastActions: actions,
} = this.props;
if (!this.props.show_disconnected_clients && group.clients) {
var clients = helpers.applyFilter('connected', true, group.clients);
} else {
@ -101,8 +97,8 @@ class Snapcast extends React.Component {
</div>
<div className="input">
<TextField
onChange={(value) => this.props.snapcastActions.setClientName(client.id, value)}
value={client.name}
onChange={(value) => actions.setClientName(client.id, value)}
value={client.name}
/>
</div>
</label>
@ -111,7 +107,7 @@ class Snapcast extends React.Component {
Group
</div>
<div className="input">
<select onChange={(e) => this.props.snapcastActions.setClientGroup(client.id, e.target.value)} value={group.id}>
<select onChange={(e) => actions.setClientGroup(client.id, e.target.value)} value={group.id}>
{
groups.map((group) => (
<option value={group.id} key={group.id}>
@ -133,12 +129,12 @@ class Snapcast extends React.Component {
<MuteControl
className="snapcast__client__mute-control"
mute={client.mute}
onMuteChange={(mute) => this.props.snapcastActions.setClientMute(client.id, mute)}
onMuteChange={(mute) => actions.setClientMute(client.id, mute)}
/>
<VolumeControl
className="snapcast__client__volume-control"
volume={client.volume}
onVolumeChange={(percent) => this.props.snapcastActions.setClientVolume(client.id, percent)}
onVolumeChange={(percent) => actions.setClientVolume(client.id, percent)}
/>
</div>
</div>
@ -150,13 +146,13 @@ class Snapcast extends React.Component {
<LatencyControl
max="150"
value={client.latency}
onChange={(value) => this.props.snapcastActions.setClientLatency(client.id, parseInt(value))}
onChange={(value) => actions.setClientLatency(client.id, parseInt(value))}
/>
<TextField
className="tiny"
type="number"
onChange={(value) => this.props.snapcastActions.setClientLatency(client.id, parseInt(value))}
value={String(client.latency)}
onChange={(value) => actions.setClientLatency(client.id, parseInt(value))}
value={String(client.latency)}
/>
</div>
</div>
@ -174,7 +170,7 @@ class Snapcast extends React.Component {
</div>
</div>
</div>
);
);
})
}
</div>
@ -182,16 +178,11 @@ class Snapcast extends React.Component {
}
render() {
if (!this.props.snapcast_enabled) {
return (
<p className="message warning">
To enable Snapcast, edit your
<code>mopidy.conf</code>
{' '}
file
</p>
);
}
const {
enabled,
snapcastActions: actions,
uiActions,
} = this.props;
const streams = [];
for (var id in this.props.streams) {
@ -211,22 +202,24 @@ file
<div className="snapcast">
<div className="field checkbox">
<div className="name">
Display
</div>
<div className="input">
<button
className="button button--default button--small"
onClick={(e) => this.props.snapcastActions.getServer()}
>
Refresh
</button>
<label>
<input
type="checkbox"
name="enabled"
checked={enabled}
onChange={() => actions.setEnabled(!enabled)}
/>
<span className="label">
Enabled
</span>
</label>
<label>
<input
type="checkbox"
name="show_disconnected_clients"
checked={this.props.show_disconnected_clients}
onChange={(e) => this.props.uiActions.set({ snapcast_show_disconnected_clients: !this.props.show_disconnected_clients })}
onChange={() => uiActions.set({ snapcast_show_disconnected_clients: !this.props.show_disconnected_clients })}
/>
<span className="label">
Show disconnected clients
@ -237,7 +230,7 @@ file
<div className="snapcast__groups">
{
groups.map((group) => {
groups.map((group) => {
group = helpers.collate(group, { clients: this.props.clients });
// Average our clients' volume for an overall group volume
@ -256,13 +249,13 @@ file
</div>
<div className="input">
<div className="text">
{group.name}
{' '}
{group.name}
{' '}
&nbsp;
<span className="mid_grey-text">
(
{group.id}
)
<span className="mid_grey-text">
(
{group.id}
)
</span>
</div>
</div>
@ -272,14 +265,14 @@ file
Stream
</div>
<div className="input">
<select onChange={(e) => this.props.snapcastActions.setGroupStream(group.id, e.target.value)} value={group.stream_id}>
<select onChange={(e) => actions.setGroupStream(group.id, e.target.value)} value={group.stream_id}>
{
streams.map((stream) => (
<option value={stream.id} key={stream.id}>
{stream.id}
{' '}
(
{stream.status}
{stream.id}
{' '}
(
{stream.status}
)
</option>
))
@ -295,12 +288,12 @@ file
<MuteControl
className="snapcast__group__mute-control"
mute={group.muted}
onMuteChange={(mute) => this.props.snapcastActions.setGroupMute(group.id, mute)}
onMuteChange={(mute) => actions.setGroupMute(group.id, mute)}
/>
<VolumeControl
className="snapcast__group__volume-control"
volume={group_volume}
onVolumeChange={(percent, old_percent) => this.props.snapcastActions.setGroupVolume(group.id, percent, old_percent)}
onVolumeChange={(percent, old_percent) => actions.setGroupVolume(group.id, percent, old_percent)}
/>
</div>
</div>
@ -323,9 +316,13 @@ file
}
const mapStateToProps = (state, ownProps) => ({
snapcast_enabled: state.pusher.config.snapcast_enabled,
pusher_connected: state.pusher.connected,
show_disconnected_clients: (state.ui.snapcast_show_disconnected_clients !== undefined ? state.ui.snapcast_show_disconnected_clients : false),
enabled: state.snapcast.enabled,
connected: state.snapcast.connected,
show_disconnected_clients: (
state.ui.snapcast_show_disconnected_clients !== undefined
? state.ui.snapcast_show_disconnected_clients
: false
),
streams: (state.snapcast.streams ? state.snapcast.streams : null),
groups: (state.snapcast.groups ? state.snapcast.groups : null),
clients: (state.snapcast.clients ? state.snapcast.clients : null),
@ -335,7 +332,6 @@ const mapDispatchToProps = (dispatch) => ({
coreActions: bindActionCreators(coreActions, dispatch),
uiActions: bindActionCreators(uiActions, dispatch),
snapcastActions: bindActionCreators(snapcastActions, dispatch),
pusherActions: bindActionCreators(pusherActions, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(Snapcast);
export default connect(mapStateToProps, mapDispatchToProps)(Snapcast);

View File

@ -1,4 +1,23 @@
export function setEnabled(enabled) {
return {
type: 'SNAPCAST_SET_ENABLED',
enabled,
};
}
export function connect() {
return {
type: 'SNAPCAST_CONNECT',
};
}
export function disconnect() {
return {
type: 'SNAPCAST_DISCONNECT',
};
}
export function getServer() {
return {
type: 'SNAPCAST_GET_SERVER',
@ -130,4 +149,4 @@ export function streamsLoaded(streams, flush = false) {
streams,
flush,
};
}
}

View File

@ -7,23 +7,158 @@ const uiActions = require('../ui/actions');
const pusherActions = require('../pusher/actions');
const snapcastActions = require('./actions');
const SnapcastMiddleware = (function () {
// A snapcast request is an alias of the Pusher request
const request = (store, params = null, response_callback = null, error_callback = null) => {
store.dispatch(
pusherActions.request(
'snapcast',
params,
response_callback,
error_callback,
),
);
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 'connection_added':
store.dispatch(pusherActions.connectionAdded(message.params.connection));
break;
}
}
}
};
const request = (store, method, params = null) => new Promise((resolve, reject) => {
const id = helpers.generateGuid();
const message = {
jsonrpc: '2.0',
id,
method,
};
if (params) {
message.params = params;
}
if (store.getState().ui.log_pusher) {
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) {
switch (action.type) {
case 'SNAPCAST_CONNECT':
if (socket != null) {
socket.close();
}
store.dispatch({ type: 'SNAPCAST_CONNECTING' });
var state = store.getState();
socket = new Mopidy({
webSocketUrl: `ws${window.location.protocol === 'https:' ? 's' : ''}://${state.snapcast.host}:${state.snapcast.port}/jsonrpc/`,
callingConvention: 'by-position-or-by-name',
});
socket.on((type, data) => handleMessage(socket, store, type, data));
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' }));
next(action);
break;
case 'SNAPCAST_DISCONNECT':
if (socket != null) socket.close();
socket = null;
store.dispatch({ type: 'SNAPCAST_DISCONNECTED' });
break;
case 'SNAPCAST_DISCONNECTED':
store.dispatch(uiActions.createNotification({ type: 'bad', content: 'Snapcast disconnected' }));
helpers.setFavicon('favicon_error.png');
break;
case 'SNAPCAST_DEBUG':
request(socket, store, action.call, action.value)
.then((response) => {
store.dispatch({ type: 'DEBUG', response });
});
break;
case 'SNAPCAST_GET_SERVER':
request(
store,
@ -358,7 +493,7 @@ const SnapcastMiddleware = (function () {
var group = snapcast.groups[action.id];
var change = action.percent - action.old_percent;
for (const client_id of group.clients_ids) {
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;
@ -393,7 +528,7 @@ const SnapcastMiddleware = (function () {
// Drop the prefix
action.method = action.method.replace('snapcast_', '');
switch (action.method) {
switch (action.method) {
case 'Client.OnConnect':
store.dispatch(snapcastActions.clientLoaded(
{
@ -462,7 +597,7 @@ const SnapcastMiddleware = (function () {
default:
return next(action);
}
};
};
}());
export default SnapcastMiddleware;

View File

@ -1,9 +1,12 @@
export default function reducer(snapcast = {}, action) {
switch (action.type) {
console.log(action);
switch (action.type) {
case 'SNAPCAST_SET_ENABLED':
return { ...snapcast, enabled: action.enabled };
case 'SNAPCAST_SERVER_LOADED':
var server = { ...action.server };
return { ...snapcast, server };
return { ...snapcast, server: action.server };
case 'SNAPCAST_CLIENTS_LOADED':
if (action.flush) {

View File

@ -117,6 +117,15 @@ Back
/>
<span className="label">Log Pusher</span>
</label>
<label>
<input
type="checkbox"
name="log_pusher"
checked={this.props.log_snapcast}
onChange={(e) => this.props.uiActions.set({ log_snapcast: !this.props.log_snapcast })}
/>
<span className="label">Log Snapcast</span>
</label>
</div>
</div>
<div className="field">
@ -224,6 +233,7 @@ const mapStateToProps = (state, ownProps) => ({
log_actions: (state.ui.log_actions ? state.ui.log_actions : false),
log_pusher: (state.ui.log_pusher ? state.ui.log_pusher : false),
log_mopidy: (state.ui.log_mopidy ? state.ui.log_mopidy : false),
log_snapcast: (state.ui.log_snapcast ? state.ui.log_snapcast : false),
test_mode: (state.ui.test_mode ? state.ui.test_mode : false),
debug_info: (state.ui.debug_info ? state.ui.debug_info : false),
debug_response: state.ui.debug_response,

View File

@ -73,7 +73,7 @@ class Settings extends React.Component {
// This forces our SW to bugger off and a new one is registered on refresh
navigator.serviceWorker.getRegistrations().then(
(registrations) => {
for (let registration of registrations) {
for (let registration of registrations) {
registration.unregister();
}
}
@ -445,17 +445,16 @@ Advanced
<div className="input">
<span className="text">
{this.props.pusher.version.current}
{' '}
installed
{' installed'}
{this.props.pusher.version.upgrade_available ? (
<span className="flag flag--dark">
<Icon name="cloud_download" className="blue-text" />
&nbsp; Upgrade available
{' Upgrade available'}
</span>
) : (
<span className="flag flag--dark">
<Icon name="check" className="green-text" />
&nbsp; Up-to-date
{' Up-to-date'}
</span>
)}
</span>