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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -100,7 +100,7 @@
// Release details
// These are automatically injected to built HTML
var build = "1571765182";
var build = "1571860001";
var version = "3.41.2";
// Construct the script tag

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>