Merge branch 'enqueue'

This commit is contained in:
James Barnsley
2017-04-07 10:35:06 +12:00
15 changed files with 345 additions and 127 deletions

View File

@ -91,13 +91,15 @@ class WebsocketHandler(tornado.websocket.WebSocketHandler):
mem.iris.send_message(self.connection_id, response)
else:
response = {
'error': 'Method "'+message['method']+'" does not exist',
'status': 0,
'message': 'Method "'+message['method']+'" does not exist',
'request_id': request_id
}
mem.iris.send_message(self.connection_id, response)
else:
response = {
'error': 'Method key missing from request',
'status': 0,
'message': 'Method key missing from request',
'request_id': request_id
}
mem.iris.send_message(self.connection_id, response)

View File

@ -139,7 +139,7 @@ class ContextMenu extends React.Component{
return (helpers.uriSource(this.props.menu.items[0].uri) == 'spotify')
}
toggleInLibrary(in_library){
toggleInLibrary(e, in_library){
this.props.uiActions.hideContextMenu()
if (in_library){
this.props.spotifyActions.following(this.props.menu.items[0].uri, 'DELETE')
@ -148,13 +148,13 @@ class ContextMenu extends React.Component{
}
}
playQueueItem(){
playQueueItem(e){
this.props.uiActions.hideContextMenu()
var tracks = this.props.menu.items;
this.props.mopidyActions.changeTrack( tracks[0].tlid )
}
removeFromQueue(){
removeFromQueue(e){
this.props.uiActions.hideContextMenu()
var tracks = this.props.menu.items;
var tracks_tlids = [];
@ -164,37 +164,37 @@ class ContextMenu extends React.Component{
this.props.mopidyActions.removeTracks( tracks_tlids );
}
playURIs(){
playURIs(e){
this.props.uiActions.hideContextMenu()
this.props.mopidyActions.playURIs(this.props.menu.uris, this.props.menu.tracklist_uri)
}
playURIsNext(){
playPlaylist(e){
this.props.uiActions.hideContextMenu()
this.props.mopidyActions.enqueueURIsNext(this.props.menu.uris, this.props.menu.tracklist_uri)
this.props.mopidyActions.playPlaylist(this.props.menu.uris[0])
}
addToQueue(){
addToQueue(e, next = false){
this.props.uiActions.hideContextMenu()
this.props.mopidyActions.enqueueURIs(this.props.menu.uris, this.props.menu.tracklist_uri)
this.props.mopidyActions.enqueueURIs(this.props.menu.uris, this.props.menu.tracklist_uri, next)
}
addTracksToPlaylist(playlist_uri){
addTracksToPlaylist(e, playlist_uri){
this.props.uiActions.hideContextMenu()
this.props.uiActions.addTracksToPlaylist(playlist_uri, this.props.menu.uris)
}
removeFromPlaylist(){
removeFromPlaylist(e){
this.props.uiActions.hideContextMenu()
this.props.uiActions.removeTracksFromPlaylist(this.props.menu.tracklist_uri, this.props.menu.indexes)
}
startRadio(){
startRadio(e){
this.props.uiActions.hideContextMenu()
this.props.pusherActions.startRadio(this.props.menu.uris)
}
goToArtist(){
goToArtist(e){
if (!this.props.menu.items || this.props.menu.items.length <= 0 || !this.props.menu.items[0].artists || this.props.menu.items[0].artists.length <= 0){
return null
} else {
@ -203,7 +203,7 @@ class ContextMenu extends React.Component{
}
}
goToUser(){
goToUser(e){
if (!this.props.menu.items || this.props.menu.items.length <= 0){
return null
} else {
@ -223,7 +223,7 @@ class ContextMenu extends React.Component{
this.props.uiActions.hideContextMenu()
}
closeAndDeselectTracks(){
closeAndDeselectTracks(e){
this.props.uiActions.hideContextMenu();
// TODO
}
@ -331,6 +331,14 @@ class ContextMenu extends React.Component{
</span>
)
var play_playlist = (
<span className="menu-item-wrapper">
<a className="menu-item" onClick={e => this.playPlaylist(e)}>
<span className="label">Play</span>
</a>
</span>
)
var play_queue_item = (
<span className="menu-item-wrapper">
<a className="menu-item" onClick={e => this.playQueueItem(e)}>
@ -341,7 +349,7 @@ class ContextMenu extends React.Component{
var play_uris_next = (
<span className="menu-item-wrapper">
<a className="menu-item" onClick={e => this.playURIsNext(e)}>
<a className="menu-item" onClick={e => this.addToQueue(e, true)}>
<span className="label">Play next</span>
</a>
</span>
@ -367,7 +375,7 @@ class ContextMenu extends React.Component{
var toggle_in_library = (
<span className="menu-item-wrapper">
<a className="menu-item" onClick={e => this.toggleInLibrary(context.in_library)}>
<a className="menu-item" onClick={e => this.toggleInLibrary(e, context.in_library)}>
<span className="label">
{context.in_library ? 'Remove from library' : 'Add to library'}
</span>
@ -451,7 +459,7 @@ class ContextMenu extends React.Component{
case 'playlist':
return (
<div>
{play_uris}
{play_playlist}
{this.canBeInLibrary() ? toggle_in_library : null}
{context.source == 'spotify' ? go_to_user : null}
{copy_uris}

View File

@ -74,12 +74,15 @@ class DebugInfo extends React.Component{
<div className="item">
Notifications: {this.props.ui.notifications ? Object.keys(this.props.ui.notifications).length : '0'}
</div>
<div className="item">
Processes: {this.props.ui.processes ? Object.keys(this.props.ui.processes).length : '0'}
</div>
<br />
<div className="item">
_testMode: {window._testMode ? 'on' : 'off'}
</div>
<div className="item">
Touch: {helpers.isTouchDevice() ? 'yes' : 'no'}
Touch: {helpers.isTouchDevice() ? 'on' : 'off'}
</div>
{this.renderLoadQueue()}
</div>

View File

@ -11,25 +11,65 @@ export default class Notifications extends React.Component{
renderNotifications(){
if (!this.props.notifications || this.props.notifications.length <= 0) return null
// we only care about the last notification
var notification = this.props.notifications[this.props.notifications.length-1]
return (
<span>
{
this.props.notifications.map(notification => {
switch (notification.type){
case 'shortcut':
return (
<div className="shortcut-notification" key={notification.key}>
<FontAwesome name={notification.content} />
</div>
)
if (notification.is_shortcut){
return (
<div className="shortcut-notification">
<FontAwesome name={notification.type} />
</div>
)
} else {
return (
<div className={notification.type+" notification"}>
<FontAwesome name="close" className="close-button" onClick={ e => this.props.uiActions.removeNotification(notification.id) } />
{ notification.content }
</div>
)
}
default:
return (
<div className={notification.type+" notification"} key={notification.key}>
<FontAwesome name="close" className="close-button" onClick={ e => this.props.uiActions.removeNotification(notification.key) } />
{ notification.content }
</div>
)
}
})
}
</span>
)
}
renderProcesses(){
if (!this.props.processes || this.props.processes.length <= 0) return null
var processes = this.props.processes
var items = []
for (var key in processes){
if (processes.hasOwnProperty(key)){
if (processes[key].cancelling){
items.push(
<div className="process notification cancelling" key={key}>
Cancelling
</div>
)
} else {
items.push(
<div className="process notification" key={key}>
<FontAwesome name="close" className="close-button" onClick={ e => this.props.uiActions.cancelProcess(key) } />
{ processes[key].content }
</div>
)
}
}
}
return (
<span>
{items}
</span>
)
}
// do we want the loading of everything to be displayed?
// not likely...
renderLoader(){
if (!this.props.load_queue){
return null
@ -59,7 +99,7 @@ export default class Notifications extends React.Component{
return (
<div className="notifications">
{this.renderNotifications()}
{this.renderLoader()}
{this.renderProcesses()}
</div>
)
}

View File

@ -266,7 +266,7 @@ export let asURIs = function(items){
* Merge duplicated items in an array
*
* @param list Array the unclean array
* @param key string = the unique key (uri, tlid, etc)
* @param key string = the unique key (id, uri, tlid, etc)
**/
export let mergeDuplicates = function(list, key){
var clean_list = [];

View File

@ -57,24 +57,30 @@ export function playURIs( uris, from_uri = null ){
}
}
export function enqueueURIsNext( uris, from_uri = null ){
if( typeof(uris) !== 'object' ) uris = [uris]
return {
type: 'MOPIDY_ENQUEUE_URIS_NEXT',
uris: uris,
from_uri: from_uri
}
}
export function enqueueURIs( uris, from_uri = null, at_position = false ){
export function enqueueURIs( uris, from_uri = null, next = false, at_position = null ){
return {
type: 'MOPIDY_ENQUEUE_URIS',
uris: uris,
at_position: at_position,
next: next,
from_uri: from_uri
}
}
export function playPlaylist(uri){
return {
type: 'MOPIDY_PLAY_PLAYLIST',
uri: uri
}
}
export function playAlbum(uri){
return {
type: 'MOPIDY_PLAY_ALBUM',
uri: uri
}
}
export function removeTracks( tlids ){
return {
type: 'MOPIDY_INSTRUCT',

View File

@ -4,8 +4,9 @@ import * as helpers from '../../helpers'
var mopidyActions = require('./actions.js')
var uiActions = require('../ui/actions.js')
var lastfmActions = require('../lastfm/actions.js')
var spotifyActions = require('../spotify/actions.js')
var pusherActions = require('../pusher/actions.js')
var lastfmActions = require('../lastfm/actions.js')
const MopidyMiddleware = (function(){
@ -219,38 +220,46 @@ const MopidyMiddleware = (function(){
icon: (store.getState().ui.current_track ? helpers.getTrackIcon( store.getState().ui.current_track ) : false)
}
store.dispatch( pusherActions.deliverBroadcast(data) )
break;
case 'MOPIDY_ENQUEUE_URIS':
// create batches of 20 uris to load
var all_uris = Object.assign([], action.uris)
var batched_uris = []
while (all_uris.length > 0){
batched_uris.push( all_uris.splice(0,20) )
}
// run each batch
for (var i = 0; i < batched_uris.length; i++){
var value = { uris: batched_uris[i] }
if (action.at_position){
value.at_position = action.at_position
}
instruct( socket, store, 'tracklist.add', value )
.then( response => {
var tlids = []
for (var i = 0; i < response.length; i++){
tlids.push(response[i].tlid)
}
store.dispatch( pusherActions.addQueueMetadata(tlids, action.from_uri) )
console.info('Added '+tlids.length+' URI(s) to queue')
})
}
break
case 'MOPIDY_ENQUEUE_URIS_NEXT':
case 'MOPIDY_PLAY_PLAYLIST':
if (helpers.uriSource(action.uri) == 'spotify'){
// playlist already in index
if (store.getState().ui.playlists.hasOwnProperty(action.uri)){
// make sure we didn't get this playlist from Mopidy-Spotify
// if we did, we'd have a cached version on server so no need to fetch
if (!store.getState().ui.playlists[action.uri].is_mopidy){
store.dispatch(uiActions.startProcess('MOPIDY_ENQUEUE_URIS', 'Fetching tracks'))
store.dispatch(spotifyActions.getAllPlaylistTracks(action.uri))
break
}
// not loaded, so we need to fetch
} else {
store.dispatch(uiActions.startProcess('MOPIDY_ENQUEUE_URIS', 'Fetching tracks'))
store.dispatch(spotifyActions.getAllPlaylistTracks(action.uri))
break
}
}
// default to load it as per usual
store.dispatch(mopidyActions.playURIs([action.uri], action.uri))
break
case 'SPOTIFY_ALL_PLAYLIST_TRACKS_LOADED_FOR_PLAYING':
var uris = []
for (var i = 0; i < action.tracks.length; i++){
uris.push(action.tracks[i].track.uri)
}
store.dispatch(mopidyActions.playURIs(uris, action.uri))
break
case 'MOPIDY_ENQUEUE_URIS':
var uris_added = 0
var remaining_uris = Object.assign([], action.uris)
var current_track = store.getState().ui.current_track
var current_tracklist = store.getState().ui.current_tracklist
var current_track_index = -1
@ -264,18 +273,64 @@ const MopidyMiddleware = (function(){
}
}
var at_position = null
if( current_track_index > -1 ) at_position = current_track_index + 1
let process_batch = function(){
instruct( socket, store, 'tracklist.add', { uris: action.uris, at_position: at_position } )
.then( response => {
var tlids = []
for (var i = 0; i < response.length; i++){
tlids.push(response[i].tlid)
// process is not running
if (store.getState().ui.processes && store.getState().ui.processes[action.type]){
if (store.getState().ui.processes[action.type].cancelling){
// recognise as cancelled
store.dispatch(uiActions.stopProcess(action.type))
return false
}
store.dispatch( pusherActions.addQueueMetadata(tlids, action.from_uri) )
store.dispatch( uiActions.createNotification('Added '+tlids.length+' URI(s) to queue') )
})
} else {
return false
}
// update our process details
store.dispatch(uiActions.startProcess(action.type, 'Adding '+remaining_uris.length+' URI(s)'))
var params = {uris: remaining_uris.splice(0,5)}
if (action.next && current_track_index > -1){
params.at_position = current_track_index + uris_added + 1
} else if (action.at_position){
params.at_position = action.at_position
}
instruct(socket, store, 'tracklist.add', params)
.then( response => {
// append our counter
uris_added += response.length
// add metadata to queue
var tlids = []
for (var i = 0; i < response.length; i++){
tlids.push(response[i].tlid)
}
store.dispatch(pusherActions.addQueueMetadata(tlids, action.from_uri))
// still more URIs? run again in 100ms
// this gives our server time to handle other requests
// crude, but prevents locking the server
if (remaining_uris.length > 0){
setTimeout(
function(){
process_batch()
},
100
)
// all done
} else {
store.dispatch(uiActions.stopProcess(action.type))
}
})
}
// start processing
store.dispatch(uiActions.startProcess(action.type, 'Adding '+remaining_uris.length+' URI(s)'))
process_batch()
break
case 'MOPIDY_PLAY_URIS':
@ -285,29 +340,37 @@ const MopidyMiddleware = (function(){
store.dispatch( pusherActions.stopRadio() )
}
// add our first track
instruct( socket, store, 'tracklist.add', { uri: action.uris[0], at_position: 0 } )
.then( response => {
var first_uri = action.uris[0]
// treat empty response as a failed lookup
if( !response || response.length <= 0 ){
store.dispatch( uiActions.createNotification('Failed to load URI(s)', 'bad') )
console.error(action)
}else{
// play it
store.dispatch( mopidyActions.changeTrack( response[0].tlid ) );
// add our first track
instruct(socket, store, 'tracklist.add', { uri: first_uri, at_position: 0 })
.then(response => {
// play it (only if we got a successful lookup)
if (response.length > 0){
store.dispatch(mopidyActions.changeTrack(response[0].tlid));
var tlids = []
for (var i = 0; i < response.length; i++){
tlids.push(response[i].tlid)
}
store.dispatch( pusherActions.addQueueMetadata(tlids, action.from_uri) )
store.dispatch(pusherActions.addQueueMetadata(tlids, action.from_uri))
} else {
store.dispatch(uiActions.createNotification('Failed to add some URI(s)', 'bad'))
}
// add the rest of our uris (if any)
action.uris.shift();
if( action.uris.length > 0 ){
store.dispatch( mopidyActions.enqueueURIs( action.uris, action.from_uri, 1 ) )
}
// add the rest of our uris (if any)
action.uris.shift();
if( action.uris.length > 0 ){
// wait 100ms so the server can trigger track_changed etc
// this means our UI feels snappier as the first track shows up quickly
setTimeout(
function(){
store.dispatch(mopidyActions.enqueueURIs( action.uris, action.from_uri, 1 ))
},
100
)
}
})
break;
@ -453,7 +516,8 @@ const MopidyMiddleware = (function(){
type: 'playlist',
name: response.name,
uri: response.uri,
source: (source == 'spotify' ? 'local' : source),
source: source,
is_mopidy: true,
last_modified: response.last_modified,
tracks_total: ( response.tracks ? response.tracks.length : 0 )
}
@ -477,6 +541,7 @@ const MopidyMiddleware = (function(){
response,
{
type: 'playlist',
is_mopidy: true,
tracks: ( response.tracks ? response.tracks : [] ),
tracks_total: ( response.tracks ? response.tracks.length : [] )
}
@ -701,6 +766,7 @@ const MopidyMiddleware = (function(){
var album = Object.assign(
{},
{
is_mopidy: true,
artists: response[uri][0].artists,
tracks: response[uri],
tracks_total: response[uri].length
@ -729,6 +795,7 @@ const MopidyMiddleware = (function(){
{ images: [] },
response[0].album,
{
is_mopidy: true,
artists: response[0].artists,
tracks: response,
tracks_total: response.length
@ -832,6 +899,7 @@ const MopidyMiddleware = (function(){
{},
(response ? response[0].artists[0] : {}),
{
is_mopidy: true,
albums_uris: helpers.asURIs(albums),
tracks: response.slice(0,10)
}

View File

@ -890,7 +890,7 @@ export function createPlaylist( name, is_public ){
}
}
export function savePlaylist( uri, name, is_public ){
export function savePlaylist(uri, name, is_public){
return (dispatch, getState) => {
sendRequest( dispatch, getState, 'users/'+ getState().spotify.me.id +'/playlists/'+ helpers.getFromUri('playlistid',uri), 'PUT', { name: name, public: is_public } )
@ -906,7 +906,7 @@ export function savePlaylist( uri, name, is_public ){
}
}
export function getPlaylist( uri ){
export function getPlaylist(uri){
return (dispatch, getState) => {
// get the main playlist object
@ -919,6 +919,7 @@ export function getPlaylist( uri ){
description = response.description
description = description.split('<a href="spotify:artist:').join('<a href="#'+global.baseURL+'artist/spotify:artist:')
description = description.split('<a href="spotify:album:').join('<a href="#'+global.baseURL+'album/spotify:album:')
description = description.split('<a href="spotify:user:').join('<a href="#'+global.baseURL+'user/spotify:user:')
}
var playlist = Object.assign(
@ -942,7 +943,44 @@ export function getPlaylist( uri ){
}
}
function loadNextPlaylistsBatch( dispatch, getState, playlists, lastResponse ){
/**
* Get all tracks for a playlist
*
* Recursively get .next until we have all tracks
**/
function loadNextPlaylistTracksBatch(dispatch, getState, uri, tracks, lastResponse){
if( lastResponse.next ){
sendRequest(dispatch, getState, lastResponse.next)
.then( response => {
tracks = [...tracks, ...response.items]
loadNextPlaylistTracksBatch(dispatch, getState, uri, tracks, response)
});
}else{
dispatch({
type: 'SPOTIFY_ALL_PLAYLIST_TRACKS_LOADED_FOR_PLAYING',
uri: uri,
tracks: tracks
});
}
}
export function getAllPlaylistTracks(uri){
return (dispatch, getState) => {
sendRequest(dispatch, getState, 'users/'+ helpers.getFromUri('userid',uri) +'/playlists/'+ helpers.getFromUri('playlistid',uri) +'/tracks?market='+getState().spotify.country)
.then( response => {
loadNextPlaylistTracksBatch(dispatch, getState, uri, response.items, response)
});
}
}
/**
* Get all of our playlists in one foul swoop
*
* Recursively gets playlists until no more .next value. Fails when user has
* a lot of playlists, and we hit the API limits
**/
function loadNextPlaylistsBatch(dispatch, getState, playlists, lastResponse){
if( lastResponse.next ){
sendRequest( dispatch, getState, lastResponse.next )
.then( response => {
@ -966,7 +1004,7 @@ export function getAllLibraryPlaylists(){
}
}
export function toggleFollowingPlaylist( uri, method ){
export function toggleFollowingPlaylist(uri, method){
if( method == 'PUT' ) var new_state = 1
if( method == 'DELETE' ) var new_state = 0

View File

@ -224,22 +224,21 @@ export function createBrowserNotification( data ){
}
}
export function createNotification(content, type = 'default', is_shortcut = false){
export function createNotification(content, type = 'default', key = helpers.generateGuid()){
return {
type: 'CREATE_NOTIFICATION',
notification: {
id: helpers.generateGuid(),
key: key,
type: type,
content: content,
is_shortcut: is_shortcut
content: content
}
}
}
export function removeNotification( id ){
export function removeNotification(key){
return {
type: 'REMOVE_NOTIFICATION',
id: id
key: key
}
}
@ -262,3 +261,25 @@ export function stopLoading(key){
key: key
}
}
export function startProcess(key,content){
return {
type: 'START_PROCESS',
key: key,
content: content
}
}
export function cancelProcess(key){
return {
type: 'CANCEL_PROCESS',
key: key
}
}
export function stopProcess(key){
return {
type: 'STOP_PROCESS',
key: key
}
}

View File

@ -271,9 +271,9 @@ const UIMiddleware = (function(){
// start a timeout to remove this notification
var timeout = setTimeout(
function(){
store.dispatch(uiActions.removeNotification(action.notification.id))
store.dispatch(uiActions.removeNotification(action.notification.key))
},
3000
(action.notification.type == 'shortcut' ? 1000 : 3000)
)
next(action)

View File

@ -711,15 +711,16 @@ export default function reducer(ui = {}, action){
case 'CREATE_NOTIFICATION':
var notifications = [...ui.notifications, action.notification]
notifications = helpers.mergeDuplicates(notifications,'key')
return Object.assign({}, ui, { notifications: notifications })
case 'REMOVE_NOTIFICATION':
var notifications = Object.assign([], ui.notifications)
function getByID( notification ){
return notification.id === action.id
function getByKey( notification ){
return notification.key === action.key
}
var index = notifications.findIndex(getByID)
var index = notifications.findIndex(getByKey)
if( index > -1 ) notifications.splice(index, 1)
return Object.assign({}, ui, { notifications: notifications })
@ -727,7 +728,7 @@ export default function reducer(ui = {}, action){
/**
* Loader
* Loading and processes
**/
case 'START_LOADING':
@ -742,6 +743,34 @@ export default function reducer(ui = {}, action){
}
return Object.assign({}, ui, {load_queue: load_queue})
case 'START_PROCESS':
var processes = Object.assign({}, (ui.processes ? ui.processes : []))
processes[action.key] = {
key: action.key,
content: action.content
}
return Object.assign({}, ui, {processes: processes})
case 'CANCEL_PROCESS':
var processes = Object.assign({}, (ui.processes ? ui.processes : {}))
if (processes[action.key]){
processes[action.key] = Object.assign(
{},
processes[action.key],
{
cancelling: true
}
)
}
return Object.assign({}, ui, {processes: processes})
case 'STOP_PROCESS':
var processes = Object.assign({}, (ui.processes ? ui.processes : {}))
if (processes[action.key]){
delete processes[action.key]
}
return Object.assign({}, ui, {processes: processes})
default:
return ui

View File

@ -106,10 +106,10 @@ class App extends React.Component{
case 32: // spacebar
if (this.props.play_state == 'playing'){
this.props.mopidyActions.pause()
this.props.uiActions.createNotification(null, 'pause', true)
this.props.uiActions.createNotification('pause', 'shortcut', 'shortcut')
}else{
this.props.mopidyActions.play()
this.props.uiActions.createNotification(null, 'play', true)
this.props.uiActions.createNotification('play', 'shortcut', 'shortcut')
}
break;
@ -158,6 +158,7 @@ class App extends React.Component{
<Notifications
uiActions={this.props.uiActions}
notifications={this.props.notifications}
processes={this.props.processes}
load_queue={this.props.load_queue} />
{this.props.debug_info ? <DebugInfo /> : null}
</div>
@ -174,6 +175,7 @@ class App extends React.Component{
const mapStateToProps = (state, ownProps) => {
return {
notifications: (state.ui.notifications ? state.ui.notifications : []),
processes: (state.ui.processes ? state.ui.processes : {}),
load_queue: (state.ui.load_queue ? state.ui.load_queue : {}),
mopidy_connected: state.mopidy.connected,
spotify_authorized: state.spotify.authorized,

View File

@ -103,7 +103,9 @@ class Debug extends React.Component{
<div className="field">
<div className="name"></div>
<div className="input">
<a className="button secondary" onClick={e => this.props.uiActions.createNotification('Test '+Math.floor(Date.now() / 1000))}>Create notification</a>
<a className="button secondary" onClick={e => this.props.uiActions.createNotification('Test notification')}>Create notification</a>
<a className="button secondary" onClick={e => this.props.uiActions.startProcess('test_process', 'Test process')}>Start process</a>
<a className="button secondary" onClick={e => this.props.uiActions.stopProcess('test_process')}>Stop process</a>
</div>
</div>
</form>

View File

@ -70,8 +70,7 @@ class Playlist extends React.Component{
play(){
ReactGA.event({ category: 'Playlist', action: 'Play', label: this.props.playlist.uri })
var tracks_uris = helpers.asURIs(this.props.playlist.tracks)
this.props.mopidyActions.playURIs(tracks_uris, this.props.params.uri)
this.props.mopidyActions.playPlaylist(this.props.playlist.uri)
}
follow(){

View File

@ -44,7 +44,7 @@
background: $red;
}
&.loading {
&.process {
position: relative;
padding-right: 18px;