Fully operational mopidy middleware; Spotify middleware next

This commit is contained in:
James Barnsley
2016-10-16 12:37:37 +13:00
parent ec7d6ae895
commit 9fddcc9948
20 changed files with 335 additions and 386 deletions

22
src/js/bootstrap.js vendored
View File

@ -1,16 +1,24 @@
console.info('Bootstrapping...');
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import reducer from './reducers/index'
import mopidyMiddleware from './services/mopidy/middleware'
import { createStore, applyMiddleware, combineReducers } from 'redux'
import mopidy from './services/mopidy/reducer'
import spotify from './services/spotify/reducer'
import thunk from 'redux-thunk'
import mopidyMiddleware from './services/mopidy/middleware'
import spotifyMiddleware from './services/spotify/middleware'
let reducers = combineReducers({
mopidy,
spotify
});
// create our global store
let store = createStore(
reducer,
reducers,
{},
applyMiddleware( thunk, mopidyMiddleware )
applyMiddleware( thunk, mopidyMiddleware, spotifyMiddleware )
);
export default store;

View File

@ -18,19 +18,21 @@ class Album extends React.Component{
// on render
componentDidMount(){
this.loadAlbum( this.props.params.id );
this.loadAlbum( this.props.params.uri );
}
// when props changed
componentWillReceiveProps( nextProps ){
if( nextProps.params.id != this.props.params.id ){
this.loadAlbum( nextProps.params.id );
if( nextProps.params.uri != this.props.params.uri ){
this.loadAlbum( nextProps.params.uri );
}
}
loadAlbum( id ){
loadAlbum( uri ){
let self = this;
var id = uri.replace('spotify:album:','');
$.ajax({
method: 'GET',
cache: true,

18
src/js/components/AlbumLink.js Executable file
View File

@ -0,0 +1,18 @@
import React, { PropTypes } from 'react'
import FontAwesome from 'react-fontawesome'
import { Link } from 'react-router'
export default class AlbumLink extends React.Component{
constructor(props) {
super(props);
}
render(){
var link = '/album/' + this.props.album.uri;
return (
<Link to={link} className="album-link">{ this.props.album.name }</Link>
);
}
}

View File

@ -7,6 +7,8 @@ import { connect } from 'react-redux'
import * as actions from '../actions/index'
import * as mopidyActions from '../services/mopidy/actions'
import * as spotifyActions from '../services/spotify/actions'
import SpotifyAuthenticationFrame from './SpotifyAuthenticationFrame'
/**
@ -28,12 +30,9 @@ class App extends React.Component{
<div>
<ul role="nav">
<li><Link to="/queue">Now playing</Link></li>
<li><Link to="/album/6N51k5TP5pSZYPf7bLffLe">808's and Heartbreak</Link></li>
<li><Link to="/album/1PgfRdl3lPyACfUGH4pquG">A million</Link></li>
<li><Link to="/library/albums">Library: Albums</Link></li>
<li><Link to="/album">Album</Link></li>
<li><a onClick={this.handleClick}>Authorize</a></li>
</ul>
<SpotifyAuthenticationFrame />
{this.props.children}
</div>
);
@ -53,7 +52,8 @@ const mapStateToProps = (state, ownProps) => {
const mapDispatchToProps = (dispatch) => {
return {
actions: bindActionCreators(actions, dispatch),
mopidyActions: bindActionCreators(mopidyActions, dispatch)
mopidyActions: bindActionCreators(mopidyActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch)
}
}

34
src/js/components/ArtistList.js Executable file
View File

@ -0,0 +1,34 @@
import React, { PropTypes } from 'react'
import FontAwesome from 'react-fontawesome'
import { Link } from 'react-router'
export default class ArtistList extends React.Component{
constructor(props) {
super(props);
}
render(){
return (
<span className="artist-list">
{
this.props.artists.map( (artist, index) => {
var separator = null;
if( index == this.props.artists.length - 2 ){
separator = ' and ';
}else if( index < this.props.artists.length - 2 ){
separator = ', ';
}
var link = '/artist/' + artist.uri;
return (
<span className="artist" key={artist.uri}>
<Link to={ link }>{ artist.name }</Link>{ separator }
</span>
);
})
}
</span>
);
}
}

View File

@ -37,8 +37,12 @@ class Player extends React.Component{
return (
<div>
{ playButton }
<a onClick={() => this.props.actions.instruct('playback.previous')}><FontAwesome name="step-backward" /> </a>
<a onClick={() => this.props.actions.instruct('playback.next')}><FontAwesome name="step-forward" /> </a>
<a onClick={() => this.props.actions.instruct('playback.previous')}>
<FontAwesome name="step-backward" />
</a>&nbsp;
<a onClick={() => this.props.actions.instruct('playback.next')}>
<FontAwesome name="step-forward" />
</a>&nbsp;
{ consumeButton }
{ randomButton }
{ repeatButton }

View File

@ -7,6 +7,8 @@ import FontAwesome from 'react-fontawesome'
import TrackList from '../components/TrackList'
import Track from '../components/Track'
import Player from '../components/Player'
import ArtistList from '../components/ArtistList'
import AlbumLink from '../components/AlbumLink'
import * as actions from '../services/mopidy/actions'
class Queue extends React.Component{
@ -18,7 +20,11 @@ class Queue extends React.Component{
renderTrackInFocus(){
if( this.props.mopidy && this.props.mopidy.trackInFocus ){
return (
<div>{ this.props.mopidy.trackInFocus.track.name }</div>
<div>
<div>{ this.props.mopidy.trackInFocus.track.name }</div>
<div><ArtistList artists={ this.props.mopidy.trackInFocus.track.artists } /></div>
<div><AlbumLink album={ this.props.mopidy.trackInFocus.track.album } /></div>
</div>
);
}
return null;

View File

@ -0,0 +1,43 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { createStore, bindActionCreators } from 'redux'
import * as actions from '../services/spotify/actions'
class SpotifyAuthenticationFrame extends React.Component{
constructor(props) {
super(props);
}
handleClick(e){
console.log('handling click')
this.props.actions.authorize();
}
render(){
var src = '//jamesbarnsley.co.nz/spotmop.php?action=frame';
if( this.props.spotify.authorizing ){
src = '//jamesbarnsley.co.nz/spotmop.php?action=authorize&app='+location.protocol+'//'+window.location.host;
}
return (
<div>
<button onClick={(e) => this.handleClick(e)}>Authorize</button>
<iframe src={src}></iframe>
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
return state;
}
const mapDispatchToProps = (dispatch) => {
return {
actions: bindActionCreators(actions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(SpotifyAuthenticationFrame)

View File

@ -1,6 +1,8 @@
import React, { PropTypes } from 'react'
import FontAwesome from 'react-fontawesome'
import ArtistList from './ArtistList'
import AlbumLink from './AlbumLink'
export default class Track extends React.Component{
@ -24,9 +26,13 @@ export default class Track extends React.Component{
}
return (
<div className="track" onDoubleClick={ (e) => this.playTrack(e) }>
<FontAwesome name={selectedIcon} fixedWidth onClick={ (e) => this.toggleSelected(e) } />
#{this.props.track.uri}: {this.props.track.name}
<div
className="track"
onDoubleClick={ (e) => this.playTrack(e) }>
<FontAwesome name={selectedIcon} fixedWidth onClick={ (e) => this.toggleSelected(e) } />
{this.props.track.name}
<ArtistList artists={this.props.track.artists} />
{ this.props.track.album ? <AlbumLink album={this.props.track.album} /> : null }
</div>
);
}

View File

@ -24,8 +24,22 @@ class TrackList extends React.Component{
playTrack( index ){
var tracks = this.state.tracks;
console.log('playTrack', tracks[index].tlid)
this.props.actions.changeTrack( index )
this.props.actions.changeTrack( tracks[index].tlid )
}
deleteSelected(){
function isSelected( track ){
return ( typeof(track.selected) !== 'undefined' && track.selected );
}
var selectedTracks = this.state.tracks.filter(isSelected)
var selectedTracksTlids = [];
for( var i = 0; i < selectedTracks.length; i++ ){
selectedTracksTlids.push( selectedTracks[i].tlid )
}
this.props.actions.removeTracks( selectedTracksTlids )
}
componentWillReceiveProps( nextProps ){
@ -36,28 +50,31 @@ class TrackList extends React.Component{
let self = this;
if( this.state.tracks ){
return (
<ul>
{
this.state.tracks.map(
(track, index) => {
<div>
<ul>
{
this.state.tracks.map(
(track, index) => {
// flatten nested track objects (as in the case of TlTracks)
if( typeof(track.track) !== 'undefined' ){
for( var property in track.track ){
if( track.track.hasOwnProperty(property) ){
track[property] = track.track[property]
// flatten nested track objects (as in the case of TlTracks)
if( typeof(track.track) !== 'undefined' ){
for( var property in track.track ){
if( track.track.hasOwnProperty(property) ){
track[property] = track.track[property]
}
}
}
return <Track
key={index+'_'+track.uri}
track={track}
playTrack={() => self.playTrack(index)}
toggleSelected={() => self.toggleSelected(index)} />
}
return <Track
key={index+'_'+track.uri}
track={track}
playTrack={() => self.playTrack(index)}
toggleSelected={() => self.toggleSelected(index)} />
}
)
}
</ul>
)
}
</ul>
<button onClick={() => this.deleteSelected()}>Delete selected</button>
</div>
);
}
return null;

View File

@ -18,7 +18,7 @@ ReactDOM.render(
<Router history={hashHistory}>
<Route path="/" component={App}>
<Route path="album/:id" component={Album} />
<Route path="album/:uri" component={Album} />
<Route path="queue" component={Queue} />
</Route>

View File

@ -1,183 +0,0 @@
import Mopidy from 'mopidy'
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import FontAwesome from 'react-fontawesome'
import * as actions from '../services/mopidy/actions'
/**
* Mopidy service
*
* Handles internal requests and passes them on to our connection
**/
class MopidyService extends React.Component{
constructor( props ){
super(props);
/*
this.connection = new Mopidy({
webSocketUrl: "ws://tv.barnsley.nz:6680/mopidy/ws",
//webSocketUrl: "ws://music.james:6680/mopidy/ws",
callingConvention: 'by-position-or-by-name'
});
this.connection.on( (type, data) => this.handleMessage( type, data ) );
*/
}
componentDidMount(){
this.props.actions.connect();
}
handleMessage( type, data ){
switch( type ){
case 'state:online':
this.props.actions.updateStatus( true );
this.get( 'playback', 'State' );
this.get( 'playback', 'Volume' );
this.get( 'tracklist', 'Consume' );
this.get( 'tracklist', 'Random' );
this.get( 'tracklist', 'Repeat' );
this.get( 'tracklist', 'TlTracks' );
this.get( 'playback', 'CurrentTlTrack' );
break;
case 'state:offline':
this.props.actions.updateStatus( false );
break;
case 'event:tracklistChanged':
this.get( 'tracklist', 'TlTracks' );
break;
//case 'event:trackPlaybackEnded':
case 'event:playbackStateChanged':
case 'event:trackPlaybackStarted':
this.get( 'playback', 'State' );
this.get( 'playback', 'CurrentTlTrack' );
break;
case 'event:volumeChanged':
this.props.actions.updateVolume( data.volume );
break;
case 'event:optionsChanged':
this.get( 'tracklist', 'Consume' );
this.get( 'tracklist', 'Random' );
this.get( 'tracklist', 'Repeat' );
break;
default:
//console.log( 'MopidyService: Unhandled event', type, message );
}
}
/**
* Get something from Mopidy
*
* Sends request to Mopidy server, and updates our local storage on return
* @param string model Mopidy model (playback, tracklist, etc)
* @param string property the property to get (TlTracks, Consume, etc)
**/
get( model, property ){
console.log('MopidyService: '+model+'.get'+property);
let self = this;
this.connection[model]['get'+property]()
.then(
function( response ){
self.props.actions['update'+property]( response );
},
function( error ){
console.error( error );
}
);
}
/**
* Set something in Mopidy
*
* Sends request to Mopidy server, and updates our local storage on return
* @param string model Mopidy model (playback, tracklist, etc)
* @param string property the property to get (TlTracks, Consume, etc)
* @param mixed value
**/
set( model, property, value ){
console.log('MopidyServie: '+model+'.set'+property, value);
let self = this;
this.connection[model]['set'+property]( value )
.then(
function( response ){
self.props.actions['update'+property]( response );
},
function( error ){
console.error( error );
}
);
}
changeTlTrack( tlid ){
console.log('Changing track to '+ tlid)
}
render(){
console.log( this.props.mopidy );
var playButton = <a onClick={() => this.connection.playback.play()}><FontAwesome name="play" /> </a>
if( this.props.mopidy.state == 'playing' ){
playButton = <a onClick={() => this.connection.playback.pause()}><FontAwesome name="pause" /> </a>
}
var consumeButton = <a onClick={() => this.set('tracklist', 'Consume', [true])}>Consume </a>
if( this.props.mopidy.consume ){
consumeButton = <a onClick={() => this.set('tracklist', 'Consume', [false])}>Un-Consume </a>
}
var randomButton = <a onClick={() => this.set('tracklist', 'Random', [true])}>Random </a>
if( this.props.mopidy.random ){
randomButton = <a onClick={() => this.set('tracklist', 'Random', [false])}>Un-Random </a>
}
var repeatButton = <a onClick={() => this.set('tracklist', 'Repeat', [true])}>Repeat </a>
if( this.props.mopidy.repeat ){
repeatButton = <a onClick={() => this.set('tracklist', 'Repeat', [false])}>Un-Repeat </a>
}
return (
<div>
{ playButton }
<a onClick={() => this.connection.playback.previous()}><FontAwesome name="step-backward" /> </a>
<a onClick={() => this.connection.playback.next()}><FontAwesome name="step-forward" /> </a>
{ consumeButton }
{ randomButton }
{ repeatButton }
{ this.props.mopidy.volume }
</div>
);
}
}
/**
* Export our component
*
* We also integrate our global store, using connect()
**/
const mapStateToProps = (state, ownProps) => {
return state;
}
const mapDispatchToProps = (dispatch) => {
return {
actions: bindActionCreators(actions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(MopidyService)

View File

@ -1,11 +0,0 @@
import ServicesContainer from 'kioc';
import MopidyService from './MopidyService';
import SpotifyService from './SpotifyService';
var Services = new ServicesContainer();
Services.use(MopidyService, true);
//Services.use(SpotifyService, true);
export default Services;

View File

@ -1,79 +0,0 @@
import React, { PropTypes } from 'react'
import * as actions from '../actions/spotifyActions'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
class SpotifyService extends React.Component{
constructor(props){
super(props);
let self = this;
this.urlBase = 'https://api.spotify.com/v1/';
console.log('SpotifyProvider > constructor')
// listen for incoming messages from the authorization iframe
// this is triggered when authentication is granted from the popup
window.addEventListener('message', function(event){
if( !/^https?:\/\/jamesbarnsley\.co\.nz/.test(event.origin) ) return false;
self.handleMessage( event );
}, false);
}
handleMessage( event ){
// convert to json
var data = JSON.parse(event.data);
// fire event
this.props.actions.authorizeSpotifySuccess( data );
}
test(){
console.log('test stuff');
}
getAlbum( albumid ){
return $.when(
$.ajax({
method: 'GET',
cache: true,
url: this.urlBase+'albums/'+albumid
})
);
}
render(){
if( this.props.config.authorizingSpotify ){
var src = '//jamesbarnsley.co.nz/spotmop.php?action=authorize&app='+location.protocol+'//'+window.location.host;
return (
<div>
Authorizing...
<iframe id="authorization-frame" src={src}></iframe>
</div>
);
}
return null
}
}
/**
* Export our component
*
* We also integrate our global store, using connect()
**/
const mapStateToProps = (state, ownProps) => {
return state;
}
const mapDispatchToProps = (dispatch) => {
return {
actions: bindActionCreators(actions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(SpotifyService)

View File

@ -5,19 +5,35 @@
export function connect(){
return {
type: 'CONNECT'
type: 'MOPIDY_CONNECT'
}
}
export function disconnect(){
return {
type: 'DISCONNECT'
type: 'MOPIDY_DISCONNECT'
}
}
export function changeTrack( tlid ){
return {
type: 'MOPIDY_CHANGE_TRACK',
call: 'playback.play',
value: { tlid: tlid }
}
}
export function removeTracks( tlids ){
return {
type: 'MOPIDY_REMOVE_TRACKS',
call: 'tracklist.remove',
value: { tlid: tlids }
}
}
export function instruct( call, value ){
return {
type: 'INSTRUCT',
type: 'MOPIDY_INSTRUCT',
call: call,
value: value
}

View File

@ -4,27 +4,15 @@ import actions from './actions'
const MopidyMiddleware = (function(){
// container for the actual Mopidy socket
var socket = null;
const onOpen = (ws, store, token) => evt => {
console.log('opened')
//Send a handshake, or authenticate with remote end
//Tell the store we're connected
//store.dispatch(actions.connected());
}
const onClose = (ws, store) => evt => {
//Tell the store we've disconnected
//store.dispatch(actions.disconnected());
}
// handle all manner of socket messages
const handleMessage = (ws, store, type, data) => {
switch( type ){
case 'state:online':
//store.dispatch( actions.updateStatus( true ) );
store.dispatch({ type: 'MOPIDY_CONNECTED' });
instruct( ws, store, 'playback.getState' );
instruct( ws, store, 'playback.getVolume' );
instruct( ws, store, 'tracklist.getConsume' );
@ -35,7 +23,7 @@ const MopidyMiddleware = (function(){
break;
case 'state:offline':
store.dispatch( actions.updateStatus( false ) );
store.dispatch({ type: 'MOPIDY_DISCONNECTED' });
break;
case 'event:tracklistChanged':
@ -50,7 +38,7 @@ const MopidyMiddleware = (function(){
break;
case 'event:volumeChanged':
store.dispatch( actions.updateVolume( data.volume ) );
store.dispatch({ type: 'MOPIDY_Volume', data: data.volume });
break;
case 'event:optionsChanged':
@ -60,35 +48,22 @@ const MopidyMiddleware = (function(){
break;
default:
//console.log( 'MopidyService: Unhandled event', type, message );
//console.log( 'MopidyService: Unhandled message', type, message );
}
}
function doStuff( ws, store ){
console.log('doing stuff', store);
ws.playback.getState()
.then(
response => {
store.dispatch({ type: 'STATE', state: response })
},
error => {
console.error( error );
}
);
}
/**
* Get something from Mopidy
* Call something with Mopidy
*
* Sends request to Mopidy server, and updates our local storage on return
* @param object ws Websocket
* @param string model Mopidy model (playback, tracklist, etc)
* @param string property the property to get (TlTracks, Consume, etc)
* @param object ws = Mopidy class that wraps a Websocket
* @param string model = which Mopidy model (playback, tracklist, etc)
* @param string property = TlTracks, Consume, etc
* @param string value (optional) = value of the property to pass
**/
const instruct = ( ws, store, call, value = false ) => {
console.log('Mopidy: '+call, value);
var callParts = call.split('.');
var model = callParts[0];
var method = callParts[1];
@ -96,30 +71,38 @@ const MopidyMiddleware = (function(){
property = property.replace('get','');
property = property.replace('set','');
// if we have a value, we need to include in our payload
if( value ){
ws[model][method]( value )
.then(
response => store.dispatch({ type: property, model: model, data: response }),
response => store.dispatch({ type: 'MOPIDY_'+property, model: model, data: response }),
error => console.error( error )
);
// no value, so omit the payload
}else{
ws[model][method]()
.then(
response => store.dispatch({ type: property, model: model, data: response }),
response => store.dispatch({ type: 'MOPIDY_'+property, model: model, data: response }),
error => console.error( error )
);
}
}
/**
* Middleware
*
* This behaves like an action interceptor. We listen for specific actions
* and handle special functionality. If the action is not in our switch, then
* it just proceeds to the next middleware, or default functionality
**/
return store => next => action => {
switch(action.type) {
//The user wants us to connect
case 'CONNECT':
case 'MOPIDY_CONNECT':
if(socket != null) socket.close();
store.dispatch({ type: 'CONNECTING' });
store.dispatch({ type: 'MOPIDY_CONNECTING' });
socket = new Mopidy({
webSocketUrl: "ws://tv.barnsley.nz:6680/mopidy/ws",
@ -130,19 +113,20 @@ const MopidyMiddleware = (function(){
break;
//The user wants us to disconnect
case 'DISCONNECT':
case 'MOPIDY_DISCONNECT':
if(socket != null) socket.close();
socket = null;
store.dispatch({ type: 'DISCONNECTED' });
store.dispatch({ type: 'MOPIDY_DISCONNECTED' });
break;
//Send the 'SEND_MESSAGE' action down the websocket to the server
case 'INSTRUCT':
// send an instruction to the websocket
case 'MOPIDY_INSTRUCT':
case 'MOPIDY_CHANGE_TRACK':
case 'MOPIDY_REMOVE_TRACKS':
instruct( socket, store, action.call, action.value )
break;
//This action is irrelevant to us, pass it on to the next middleware
// This action is irrelevant to us, pass it on to the next middleware
default:
return next(action);
}

View File

@ -2,53 +2,56 @@
export default function reducer(mopidy = {}, action){
switch (action.type) {
case 'CONNECTING':
case 'MOPIDY_CONNECTING':
return Object.assign({}, mopidy, { connected: false, connecting: true });
case 'CONNECTED':
case 'MOPIDY_CONNECTED':
return Object.assign({}, mopidy, { connected: true, connecting: false });
case 'DISCONNECTED':
case 'MOPIDY_DISCONNECTED':
return Object.assign({}, mopidy, { connected: false, connecting: false });
case 'State':
case 'MOPIDY_CHANGE_TRACK':
return Object.assign({}, mopidy, {
tlid: action.tlid
});
/**
* Websocket-initiated actions
**/
case 'MOPIDY_State':
return Object.assign({}, mopidy, {
state: action.data
});
case 'Consume':
case 'MOPIDY_Consume':
return Object.assign({}, mopidy, {
consume: action.data
});
case 'Random':
case 'MOPIDY_Random':
return Object.assign({}, mopidy, {
random: action.data
});
case 'Repeat':
case 'MOPIDY_Repeat':
return Object.assign({}, mopidy, {
repeat: action.data
});
case 'TlTracks':
case 'MOPIDY_TlTracks':
return Object.assign({}, mopidy, {
tracks: action.data
tracks: action.data
});
case 'CurrentTlTrack':
case 'MOPIDY_CurrentTlTrack':
return Object.assign({}, mopidy, {
trackInFocus: action.data
trackInFocus: action.data
});
case 'Volume':
case 'MOPIDY_Volume':
return Object.assign({}, mopidy, {
volume: action.volume
});
case 'CHANGE_TRACK':
return Object.assign({}, mopidy, {
tlid: action.tlid
volume: action.data
});
default:

View File

@ -0,0 +1,30 @@
/**
* Actions and Action Creators
**/
export function authorize(){
return {
type: 'SPOTIFY_AUTHORIZING'
}
}
export function connect(){
return {
type: 'SPOTIFY_CONNECTING'
}
}
export function disconnect(){
return {
type: 'SPOTIFY_DISCONNECT'
}
}
export function loadAlbum( uri ){
return {
type: 'SPOTIFY_LOAD_ALBUM',
uri: uri
}
}

View File

@ -0,0 +1,28 @@
import actions from './actions'
const SpotifyMiddleware = (function(){
return store => next => action => {
switch(action.type) {
case 'SPOTIFY_CONNECT':
console.log('Spotify wants to connect')
break;
case 'SPOTIFY_DISCONNECT':
console.log('Spotify wants to DISconnect')
break;
// This action is irrelevant to us, pass it on to the next middleware
default:
return next(action);
}
}
})();
export default SpotifyMiddleware

View File

@ -0,0 +1,23 @@
export default function reducer(spotify = {}, action){
switch (action.type) {
case 'SPOTIFY_CONNECTING':
return Object.assign({}, spotify, { connected: false, connecting: true });
case 'SPOTIFY_AUTHORIZING':
return Object.assign({}, spotify, { authorizing: true });
case 'SPOTIFY_CONNECTED':
return Object.assign({}, spotify, { connected: true, connecting: false });
case 'SPOTIFY_DISCONNECTED':
return Object.assign({}, spotify, { connected: false, connecting: false });
default:
return spotify
}
}