Merge branch 'feature/tracks-index' into develop

This commit is contained in:
James Barnsley
2017-11-10 16:07:30 +13:00
42 changed files with 2228 additions and 925 deletions

154
build_tools/auth_lastfm.php Executable file
View File

@ -0,0 +1,154 @@
<?php
/**
* LastFM Authentication proxy
*
* To use:
* 1. Create a LastFM App (https://www.last.fm/api/account/create), and paste in your credentials below
* 2. Save this script to a publicly-accessible server
* 3. Set config in mopidy.conf to point to this script
**/
// LastFM app credentials
define('API_URL','http://ws.audioscrobbler.com/2.0/?format=json');
define('API_KEY','YOUR_KEY_HERE');
define('API_SECRET','YOUR_SECRET_HERE');
define('REDIRECT_URI','YOUR_REDIRECT_URI_HERE');
// Allow cross-domain requests
header("Access-Control-Allow-Origin: *");
// Set our cookies
if (isset($_GET['app'])){
setcookie('mopidy_iris', $_GET['app'], time()+3600);
}
/* ================================================================================= INIT ================ */
/* ======================================================================================================= */
if (isset($_GET['action'])){
switch ($_GET['action']){
case 'authorize':
header('Location: http://www.last.fm/api/auth/?api_key='.API_KEY.'&cb='.REDIRECT_URI);
exit;
case 'start_session':
$session = startSession($_GET['token']);
// Pass our error back to the popup opener
?>
<script type="text/javascript">
window.opener.postMessage( '<?php echo json_encode($session) ?>', "*");
window.close();
</script>
<?php
break;
case 'sign_request':
$data = $_GET;
$signed = signRequest($data);
echo json_encode($signed);
exit;
default:
echo 'Invalid action specified';
die();
}
} else {
echo "No action specified";
die();
}
/* ================================================================================= GETTERS ============= */
/* ======================================================================================================= */
/**
* Start a session
*
* @param $data = array
* @param $post = boolean (POST request)
**/
function startSession($token){
$ch = curl_init();
if (FALSE === $ch){
throw new Exception('Failed to initialize');
}
$data = signRequest(
array(
"method" => "auth.getSession",
"token" => $token
),
false
);
curl_setopt($ch, CURLOPT_URL,API_URL);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLINFO_HEADER_OUT, true);
curl_setopt($ch, CURLOPT_VERBOSE, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
if (curl_errno($ch)){
echo 'CURL Error: '. curl_error($ch);
}
curl_close($ch);
$response = json_decode($response, true);
// Append our other important values to successful signings
if (!$response['error']){
$response['api_key'] = API_KEY;
}
return $response;
}
/**
* Perform a signed request
*
* @param $data = array
* @param $post = boolean (POST request)
**/
function signRequest($data = array(), $post = true){
unset($data['action']);
$ch = curl_init();
if (FALSE === $ch){
throw new Exception('Failed to initialize');
}
// Make sure we've got our API key included
$request = array_merge(array("api_key" => API_KEY), $data);
// Loop all the values in our request and add to our signature
$signature = "";
ksort($request);
foreach ($request as $key => $value){
$signature.= $key.$value;
}
// Finalize the signature
$signature.= API_SECRET;
$signature = md5($signature);
$request["api_sig"] = $signature;
return $request;
}

View File

@ -1,5 +1,4 @@
<?php
/**
* Spotify Authentication proxy
*
@ -31,36 +30,41 @@ if (isset($_GET['code'])){
// go get our credentials
$response = getToken($_GET['code']);
$responseArray = json_decode( $response, true );
$response = json_decode( $response, true );
// add our code to the array of credentials, etc
$responseArray['authorization_code'] = $_GET['code'];
$response = json_encode($responseArray);
$response['authorization_code'] = $_GET['code'];
$response['origin'] = "auth_spotify";
// make sure we have a successful response
if( !isset($responseArray['access_token']) ){
if( !isset($response['access_token']) ){
echo 'Error!';
die();
}
// Pass our error back to the popup opener
// Pass our error back to the popup opener
?>
<script type="text/javascript">
window.opener.postMessage( '<?php echo $response ?>', "*");
window.opener.postMessage( '<?php echo json_encode($response) ?>', "*");
window.close();
</script>
<?php
// authorization error
} else if (isset($_GET['error'])){
$response = array(
"error" => $_GET["error"],
"origin" => "auth_spotify"
);
// Pass our error back to the popup opener
?>
<script type="text/javascript">
window.opener.postMessage("{\"error\": \"<?php echo $_GET['error'] ?>\"}", "*");
window.close();
</script>
<?php
// Pass our error back to the popup opener
?>
<script type="text/javascript">
window.opener.postMessage('<?php echo json_encode($response) ?>', "*");
window.close();
</script>
<?php
// refresh existing token
} else if (isset($_GET['action']) && $_GET['action'] == 'refresh' && $_GET['refresh_token']){
@ -171,4 +175,5 @@ function refreshToken($refresh_token){
curl_close ($ch);
return $response;
}
}

View File

@ -13,7 +13,7 @@ from core import IrisCore
from raven import Client
logger = logging.getLogger(__name__)
__version__ = '3.6.1'
__version__ = '3.6.1'
##
# Core extension class
@ -35,7 +35,8 @@ class Extension( ext.Extension ):
schema['enabled'] = config.Boolean()
schema['country'] = config.String()
schema['locale'] = config.String()
schema['authorization_url'] = config.String()
schema['spotify_authorization_url'] = config.String()
schema['lastfm_authorization_url'] = config.String()
return schema
def setup(self, registry):

View File

@ -259,7 +259,8 @@ class IrisCore(object):
"spotify_username": spotify_username,
"country": self.config['iris']['country'],
"locale": self.config['iris']['locale'],
"authorization_url": self.config['iris']['authorization_url']
"spotify_authorization_url": self.config['iris']['spotify_authorization_url'],
"lastfm_authorization_url": self.config['iris']['lastfm_authorization_url']
}
}

View File

@ -2,4 +2,5 @@
enabled = true
country = NZ
locale = en_NZ
authorization_url = https://jamesbarnsley.co.nz/auth_v2.php
spotify_authorization_url = https://jamesbarnsley.co.nz/auth_spotify.php
lastfm_authorization_url = https://jamesbarnsley.co.nz/auth_lastfm.php

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,154 @@
<?php
/**
* LastFM Authentication proxy
*
* To use:
* 1. Create a LastFM App (https://www.last.fm/api/account/create), and paste in your credentials below
* 2. Save this script to a publicly-accessible server
* 3. Set config in mopidy.conf to point to this script
**/
// LastFM app credentials
define('API_URL','http://ws.audioscrobbler.com/2.0/?format=json');
define('API_KEY','269cc2b1dfb1059a89a2db21ceb81d2a');
define('API_SECRET','1551231f57eb3b73801e31bc0e89ab09');
define('REDIRECT_URI','https://jamesbarnsley.nz/auth_lastfm.php?action=start_session');
// Allow cross-domain requests
header("Access-Control-Allow-Origin: *");
// Set our cookies
if (isset($_GET['app'])){
setcookie('mopidy_iris', $_GET['app'], time()+3600);
}
/* ================================================================================= INIT ================ */
/* ======================================================================================================= */
if (isset($_GET['action'])){
switch ($_GET['action']){
case 'authorize':
header('Location: http://www.last.fm/api/auth/?api_key='.API_KEY.'&cb='.REDIRECT_URI);
exit;
case 'start_session':
$session = startSession($_GET['token']);
// Pass our error back to the popup opener
?>
<script type="text/javascript">
window.opener.postMessage( '<?php echo json_encode($session) ?>', "*");
window.close();
</script>
<?php
break;
case 'sign_request':
$data = $_GET;
$signed = signRequest($data);
echo json_encode($signed);
exit;
default:
echo 'Invalid action specified';
die();
}
} else {
echo "No action specified";
die();
}
/* ================================================================================= GETTERS ============= */
/* ======================================================================================================= */
/**
* Start a session
*
* @param $data = array
* @param $post = boolean (POST request)
**/
function startSession($token){
$ch = curl_init();
if (FALSE === $ch){
throw new Exception('Failed to initialize');
}
$data = signRequest(
array(
"method" => "auth.getSession",
"token" => $token
),
false
);
curl_setopt($ch, CURLOPT_URL,API_URL);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLINFO_HEADER_OUT, true);
curl_setopt($ch, CURLOPT_VERBOSE, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
if (curl_errno($ch)){
echo 'CURL Error: '. curl_error($ch);
}
curl_close($ch);
$response = json_decode($response, true);
// Append our other important values to successful signings
if (!$response['error']){
$response['api_key'] = API_KEY;
}
return $response;
}
/**
* Perform a signed request
*
* @param $data = array
* @param $post = boolean (POST request)
**/
function signRequest($data = array(), $post = true){
unset($data['action']);
$ch = curl_init();
if (FALSE === $ch){
throw new Exception('Failed to initialize');
}
// Make sure we've got our API key included
$request = array_merge(array("api_key" => API_KEY), $data);
// Loop all the values in our request and add to our signature
$signature = "";
ksort($request);
foreach ($request as $key => $value){
$signature.= $key.$value;
}
// Finalize the signature
$signature.= API_SECRET;
$signature = md5($signature);
$request["api_sig"] = $signature;
return $request;
}

View File

@ -38,7 +38,7 @@
// Release details
// These are automatically injected by build.sh
var build = "1509392542";
var build = "1509609058";
var version = "3.6.1";
// Construct the script tag

20
src/js/bootstrap.js vendored
View File

@ -14,6 +14,7 @@ import coreMiddleware from './services/core/middleware'
import uiMiddleware from './services/ui/middleware'
import pusherMiddleware from './services/pusher/middleware'
import mopidyMiddleware from './services/mopidy/middleware'
import lastfmMiddleware from './services/lastfm/middleware'
import spotifyMiddleware from './services/spotify/middleware'
import localstorageMiddleware from './services/localstorage/middleware'
@ -31,8 +32,9 @@ let reducers = combineReducers({
// TODO: Look at using propTypes in the component for these falsy initial states
var initialState = {
core: {
current_tracklist: [],
current_tltrack: false,
queue: [],
queue_metadata: {},
current_track_uri: null,
albums: {},
artists: {},
playlists: {},
@ -63,7 +65,9 @@ var initialState = {
}
},
lastfm: {
connected: false
connected: false,
me: false,
authorization_url: 'https://jamesbarnsley.co.nz/auth_lastfm.php'
},
genius: {
connected: false
@ -72,7 +76,7 @@ var initialState = {
connected: false,
me: false,
autocomplete_results: {},
authorization_url: 'https://jamesbarnsley.co.nz/auth_v2.php'
authorization_url: 'https://jamesbarnsley.co.nz/auth_spotify.php'
}
};
@ -106,12 +110,18 @@ if (localStorage.getItem('spotify')){
initialState.spotify = Object.assign(initialState.spotify, storedSpotify );
}
// if we've got a stored version of lastfm state, load and merge
if (localStorage.getItem('lastfm')){
var storedLastfm = JSON.parse(localStorage.getItem('lastfm') );
initialState.lastfm = Object.assign(initialState.lastfm, storedLastfm );
}
console.log('Bootstrapping', initialState)
let store = createStore(
reducers,
initialState,
applyMiddleware(thunk, localstorageMiddleware, coreMiddleware, uiMiddleware, mopidyMiddleware, pusherMiddleware, spotifyMiddleware )
applyMiddleware(thunk, localstorageMiddleware, coreMiddleware, uiMiddleware, mopidyMiddleware, pusherMiddleware, spotifyMiddleware, lastfmMiddleware )
);
export default store;

View File

@ -68,12 +68,12 @@ class AddSeedField extends React.Component{
switch (helpers.uriType(item.uri)){
case 'artist':
this.props.coreActions.artistLoaded(item.uri,item)
break
this.props.coreActions.artistsLoaded(item);
break;
case 'track':
this.props.coreActions.trackLoaded(item.uri,item)
break
this.props.coreActions.tracksLoaded(item);
break;
}
}

View File

@ -10,6 +10,7 @@ import * as coreActions from '../services/core/actions'
import * as uiActions from '../services/ui/actions'
import * as pusherActions from '../services/pusher/actions'
import * as mopidyActions from '../services/mopidy/actions'
import * as lastfmActions from '../services/lastfm/actions'
import * as spotifyActions from '../services/spotify/actions'
import TrackList from './TrackList'
@ -41,10 +42,10 @@ class ContextMenu extends React.Component{
this.setState({ submenu_expanded: false })
$('body').addClass('context-menu-open')
var context = this.getContext()
var context = this.getContext(nextProps);
// if we're able to be in the library, run a check
if (this.props.spotify_authorized && context.source == 'spotify'){
if (nextProps.spotify_authorized && context.source == 'spotify'){
switch (nextProps.menu.context){
case 'artist':
case 'album':
@ -54,6 +55,14 @@ class ContextMenu extends React.Component{
}
}
// if we're able to be in the LastFM library, run a check
if (nextProps.lastfm_authorized && context.is_track && context.items_count == 1){
if (nextProps.menu.items[0].uri && this.props.tracks[nextProps.menu.items[0].uri] !== undefined){
var track = this.props.tracks[nextProps.menu.items[0].uri];
this.props.lastfmActions.getTrack(track);
}
}
// we DID have one prior, and now we don't
} else if (this.props.menu && !nextProps.menu){
$('body').removeClass('context-menu-open')
@ -73,41 +82,44 @@ class ContextMenu extends React.Component{
}
}
getContext(){
getContext(props = this.props){
var context = {
name: null,
nice_name: 'Unknown'
nice_name: 'Unknown',
is_track: false
}
if (this.props.menu && this.props.menu.context){
context.name = this.props.menu.context
context.nice_name = this.props.menu.context
if (props.menu && props.menu.context){
context.name = props.menu.context;
context.nice_name = props.menu.context;
// handle ugly labels
switch (this.props.menu.context){
switch (props.menu.context){
case 'playlist':
case 'editable-playlist':
context.nice_name = 'playlist'
context.nice_name = 'playlist';
break
case 'track':
case 'queue-track':
case 'playlist-track':
case 'editable-playlist-track':
context.nice_name = 'track'
context.nice_name = 'track';
context.is_track = true;
break
}
// Consider the object(s) themselves
// We can only really accommodate the first item. The only instances where
// there is multiple is tracklists, when they're all of the same source (except search?)
if (this.props.menu.items && this.props.menu.items.length > 0){
var item = this.props.menu.items[0]
context.item = item
context.items_count = this.props.menu.items.length
context.source = helpers.uriSource(item.uri)
context.type = helpers.uriType(item.uri)
context.in_library = this.inLibrary(item)
if (props.menu.items && props.menu.items.length > 0){
var item = props.menu.items[0]
context.item = item;
context.items_count = props.menu.items.length;
context.source = helpers.uriSource(item.uri);
context.type = helpers.uriType(item.uri);
context.in_library = this.inLibrary(item);
context.is_loved = this.isLoved(item);
}
}
@ -133,6 +145,23 @@ class ContextMenu extends React.Component{
return false
}
/**
* TODO: Currently the select track keys are the only details available. We need
* the actual track object reference (including name and artists) to getTrack from LastFM
**/
isLoved(item = null){
if (!item){
return false
}
if (this.props.tracks[item.uri] === undefined){
return false;
}
var track = this.props.tracks[item.uri];
return (track.userloved !== undefined && track.userloved == "1");
}
canBeInLibrary(){
if (!this.props.spotify_authorized){
return false
@ -190,6 +219,20 @@ class ContextMenu extends React.Component{
this.props.coreActions.addTracksToPlaylist(playlist_uri, this.props.menu.uris)
}
toggleLoved(e, is_loved){
this.props.uiActions.hideContextMenu()
if (is_loved){
this.props.lastfmActions.unloveTrack(this.props.menu.items[0])
} else {
this.props.lastfmActions.loveTrack(this.props.menu.items[0])
}
}
unloveTrack(e){
this.props.uiActions.hideContextMenu()
this.props.lastfmActions.unloveTrack(this.props.menu.items[0])
}
removeFromPlaylist(e){
this.props.uiActions.hideContextMenu()
this.props.coreActions.removeTracksFromPlaylist(this.props.menu.tracklist_uri, this.props.menu.indexes)
@ -445,6 +488,29 @@ class ContextMenu extends React.Component{
</span>
)
if (helpers.isLoading(this.props.load_queue,['lastfm_track.getInfo'])){
var toggle_loved = (
<span className="menu-item-wrapper">
<a className="menu-item">
<span className="label grey-text">
Love track
</span>
</a>
</span>
)
} else {
var toggle_loved = (
<span className="menu-item-wrapper">
<a className="menu-item" onClick={e => this.toggleLoved(e, context.is_loved)}>
<span className="label">
{context.is_loved ? 'Unlove' : 'Love'} track
</span>
</a>
</span>
)
}
var go_to_artist = (
<span className="menu-item-wrapper">
<a className="menu-item" onClick={e => this.goToArtist(e)}>
@ -580,6 +646,7 @@ class ContextMenu extends React.Component{
{context.items_count == 1 ? play_queue_item : null}
{context.items_count == 1 ? <div className="divider" /> : null}
{add_to_playlist}
{context.items_count == 1 ? toggle_loved : null}
{context.source == 'spotify' && context.items_count <= 5 ? go_to_recommendations : null}
{context.items_count == 1 ? go_to_track : null}
<div className="divider" />
@ -598,6 +665,7 @@ class ContextMenu extends React.Component{
{context.source == 'spotify' && context.items_count == 1 ? start_radio : null}
<div className="divider" />
{add_to_playlist}
{context.items_count == 1 ? toggle_loved : null}
{context.source == 'spotify' && context.items_count <= 5 ? go_to_recommendations : null}
{context.items_count == 1 ? go_to_track : null}
<div className="divider" />
@ -616,6 +684,7 @@ class ContextMenu extends React.Component{
{context.source == 'spotify' && context.items_count == 1 ? start_radio : null}
<div className="divider" />
{add_to_playlist}
{context.items_count == 1 ? toggle_loved : null}
{context.source == 'spotify' && context.items_count <= 5 ? go_to_recommendations : null}
{context.items_count == 1 ? go_to_track : null}
<div className="divider" />
@ -663,6 +732,7 @@ class ContextMenu extends React.Component{
const mapStateToProps = (state, ownProps) => {
return {
menu: state.ui.context_menu,
load_queue: state.ui.load_queue,
processes: state.ui.processes,
current_track: state.core.current_track,
current_tracklist: state.core.current_tracklist,
@ -675,7 +745,9 @@ const mapStateToProps = (state, ownProps) => {
spotify_library_albums: state.spotify.library_albums,
mopidy_library_albums: state.mopidy.library_albums,
playlists: state.core.playlists,
spotify_authorized: state.spotify.authorization
tracks: state.core.tracks,
spotify_authorized: state.spotify.authorization,
lastfm_authorized: state.lastfm.session
}
}
@ -685,6 +757,7 @@ const mapDispatchToProps = (dispatch) => {
uiActions: bindActionCreators(uiActions, dispatch),
pusherActions: bindActionCreators(pusherActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch),
lastfmActions: bindActionCreators(lastfmActions, dispatch),
mopidyActions: bindActionCreators(mopidyActions, dispatch)
}
}

View File

@ -41,7 +41,7 @@ class FollowButton extends React.Component{
}
if (!this.props.spotify_authorized){
return <button className={className+' disabled'} onClick={e => this.props.uiActions.createNotification('You must authorize Iris first','warning')}>{this.props.addText}</button>
return <button className={className+' disabled'} onClick={e => this.props.uiActions.createNotification('You must authorize Spotify first','warning')}>{this.props.addText}</button>
} else if (this.props.is_following === true){
return <button className={className+' destructive'} onClick={e => this.remove()}>{this.props.removeText}</button>
} else {

View File

@ -0,0 +1,124 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { createStore, bindActionCreators } from 'redux'
import ReactGA from 'react-ga'
import FontAwesome from 'react-fontawesome'
import Thumbnail from './Thumbnail'
import * as uiActions from '../services/ui/actions'
import * as lastfmActions from '../services/lastfm/actions'
class LastfmAuthenticationFrame extends React.Component{
constructor(props){
super(props);
this.state = {
authorizing: false
}
}
componentDidMount(){
let self = this;
// Listen for incoming messages from the authorization popup
window.addEventListener('message', function(event){
var data = JSON.parse(event.data);
// Only digest messages relevant to us
if (data.origin == 'auth_lastfm'){
self.handleMessage(event, data);
}
}, false);
}
handleMessage(event, data){
// Only allow incoming data from our authorized authenticator proxy
var authorization_domain = this.props.authorization_url.substring(0,this.props.authorization_url.indexOf('/',8))
if (event.origin != authorization_domain){
this.props.uiActions.createNotification('Authorization failed. '+event.origin+' is not the configured authorization_url.','bad')
return false
}
// Bounced with an error
if (data.error !== undefined){
this.props.uiActions.createNotification(data.message,'bad')
// No errors? We're in!
} else {
this.props.lastfmActions.authorizationGranted(data)
this.props.lastfmActions.getMe()
}
// Turn off our authorizing switch
this.setState({authorizing: false})
}
startAuthorization(){
var self = this
this.setState({authorizing: true})
// Open an authentication request window
var url = this.props.authorization_url+'?action=authorize'
var popup = window.open(url,"popup","height=580,width=350");
popup.name = "LastfmAuthenticationWindow";
// Start timer to check our popup's state
var timer = setInterval(checkPopup, 1000);
function checkPopup(){
// Popup has been closed
if (typeof(popup) !== 'undefined' && popup){
if (popup.closed){
self.setState({authorizing: false})
clearInterval(timer);
}
// Popup does not exist, so must have been blocked
} else {
self.props.uiActions.createNotification('Popup blocked. Please allow popups and try again.','bad')
self.setState({authorizing: false})
clearInterval(timer);
}
}
}
render(){
if (this.state.authorizing){
return (
<button className="working">
Authorizing...
</button>
)
} else if (this.props.authorized){
return (
<button className="destructive" onClick={() => this.props.lastfmActions.revokeAuthorization()}>Log out</button>
)
} else {
return (
<button className="primary" onClick={() => this.startAuthorization()}>Log in</button>
)
}
}
}
const mapStateToProps = (state, ownProps) => {
return {
authorization_url: state.lastfm.authorization_url,
authorized: state.lastfm.session,
authorizing: state.lastfm.authorizing
}
}
const mapDispatchToProps = (dispatch) => {
return {
uiActions: bindActionCreators(uiActions, dispatch),
lastfmActions: bindActionCreators(lastfmActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(LastfmAuthenticationFrame)

View File

@ -0,0 +1,62 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { Link } from 'react-router'
import { createStore, bindActionCreators } from 'redux'
import FontAwesome from 'react-fontawesome'
import * as helpers from '../helpers'
import * as uiActions from '../services/ui/actions'
import * as lastfmActions from '../services/lastfm/actions'
class FollowButton extends React.Component{
constructor(props){
super(props);
}
remove(){
this.props.lastfmActions.unloveTrack(this.props.uri, this.props.artist, this.props.track);
}
add(){
this.props.lastfmActions.loveTrack(this.props.uri, this.props.artist, this.props.track);
}
render(){
if (!this.props.uri){
return false;
}
var className = '';
// Inherit passed-down classes
if (this.props.className){
className += ' '+this.props.className;
}
if (!this.props.lastfm_authorized){
return <button className={className+' disabled'} onClick={e => this.props.uiActions.createNotification('You must authorize LastFM first','warning')}>{this.props.addText}</button>
} else if (this.props.is_loved && this.props.is_loved !== "0"){
return <button className={className+' destructive'} onClick={e => this.remove()}>{this.props.removeText}</button>
} else {
return <button className={className} onClick={e => this.add()}>{this.props.addText}</button>
}
}
}
const mapStateToProps = (state, ownProps) => {
return {
load_queue: state.ui.load_queue,
lastfm_authorized: state.lastfm.session
}
}
const mapDispatchToProps = (dispatch) => {
return {
uiActions: bindActionCreators(uiActions, dispatch),
lastfmActions: bindActionCreators(lastfmActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(FollowButton)

View File

@ -145,7 +145,7 @@ class Modal extends React.Component{
const mapStateToProps = (state, ownProps) => {
return {
current_track: (state.core.current_track !== undefined && state.core.tracks !== undefined && state.core.tracks[state.core.current_track] !== undefined ? state.core.tracks[state.core.current_track] : null),
current_track: (state.core.tracks[state.core.current_track_uri] !== undefined ? state.core.tracks[state.core.current_track_uri] : null),
uri_schemes: (state.mopidy.uri_schemes ? state.mopidy.uri_schemes : []),
search_uri_schemes: (state.ui.search_uri_schemes ? state.ui.search_uri_schemes : []),
volume: state.mopidy.volume,

View File

@ -135,7 +135,7 @@ class PlaybackControls extends React.Component{
const mapStateToProps = (state, ownProps) => {
return {
current_track: (state.core.current_track !== undefined && state.core.tracks !== undefined && state.core.tracks[state.core.current_track] !== undefined ? state.core.tracks[state.core.current_track] : null),
current_track: (state.core.tracks[state.core.current_track_uri] !== undefined ? state.core.tracks[state.core.current_track_uri] : null),
radio_enabled: (state.ui.radio && state.ui.radio.enabled ? true : false),
play_state: state.mopidy.play_state,
time_position: state.mopidy.time_position,

View File

@ -21,20 +21,20 @@ class SpotifyAuthenticationFrame extends React.Component{
}
componentDidMount(){
let self = this;
// Listen for incoming messages from the authorization iframe
// This is triggered when the popup posts a message, which is then passed to
// the iframe, and then passed on to the parent frame (our application)
// Listen for incoming messages from the authorization popup
window.addEventListener('message', function(event){
self.handleMessage(event)
var data = JSON.parse(event.data);
// Only digest messages relevant to us
if (data.origin == 'auth_spotify'){
self.handleMessage(event, data);
}
}, false);
}
handleMessage(event){
var data = JSON.parse(event.data)
handleMessage(event, data){
// Only allow incoming data from our authorized authenticator proxy
var authorization_domain = this.props.authorization_url.substring(0,this.props.authorization_url.indexOf('/',8))
@ -44,7 +44,7 @@ class SpotifyAuthenticationFrame extends React.Component{
}
// Spotify bounced with an error
if (typeof(data.error) !== 'undefined'){
if (data.error !== undefined){
this.props.uiActions.createNotification(data.error,'bad')
// No errors? We're in!
@ -80,7 +80,7 @@ class SpotifyAuthenticationFrame extends React.Component{
'playlist-read-collaborative',
'ugc-image-upload' // playlist image uploading
]
var popup = window.open(url+'&scope='+scopes.join('%20'),"popup","height=500,width=350");
var popup = window.open(url+'&scope='+scopes.join('%20'),"popup","height=580,width=350");
// Start timer to check our popup's state
var timer = setInterval(checkPopup, 1000);

View File

@ -202,7 +202,7 @@ class TrackList extends React.Component{
uris: selected_tracks_uris,
indexes: selected_tracks_indexes
}
this.props.uiActions.showContextMenu(data)
this.props.uiActions.showContextMenu(data);
}
handleSelection(e,track_key){

View File

@ -39,7 +39,7 @@ export default class URILink extends React.Component{
break;
default:
return null;
to = null;
}
return (

View File

@ -65,6 +65,7 @@ ReactDOM.render(
<Route path="queue/history" component={QueueHistory} />
<Route path="settings" component={Settings} />
<Route path="settings/debug" component={Debug} />
<Route path="settings(/:sub_view)" component={Settings} />
<Route path="search(/iris::search::query)" component={Search} />
<Route path="album/:uri" component={Album} />

View File

@ -81,8 +81,8 @@ export function set(data){
**/
export function reorderPlaylistTracks(uri, indexes, insert_before, snapshot_id = false){
var range = helpers.createRange(indexes );
switch(helpers.uriSource(uri )){
var range = helpers.createRange(indexes);
switch(helpers.uriSource(uri)){
case 'spotify':
return {
@ -221,11 +221,20 @@ export function getLibraryArtists(){
* Assets loaded
**/
export function albumLoaded(key,album){
export function loadedMore(parent_type, parent_key, records_type, records_data){
return {
type: 'ALBUM_LOADED',
key: key,
album: album
type: 'LOADED_MORE',
parent_type: parent_type,
parent_key: parent_key,
records_type: records_type,
records_data: records_data
}
}
export function tracksLoaded(tracks){
return {
type: 'TRACKS_LOADED',
tracks: tracks
}
}
@ -236,14 +245,6 @@ export function albumsLoaded(albums){
}
}
export function artistLoaded(key,artist){
return {
type: 'ARTIST_LOADED',
key: key,
artist: artist
}
}
export function artistsLoaded(artists){
return {
type: 'ALBUMS_LOADED',
@ -251,17 +252,16 @@ export function artistsLoaded(artists){
}
}
export function trackLoaded(key,track){
export function playlistsLoaded(playlists){
return {
type: 'TRACK_LOADED',
key: key,
track: track
type: 'PLAYLISTS_LOADED',
playlists: playlists
}
}
export function tracksLoaded(tracks){
export function usersLoaded(users){
return {
type: 'TRACKS_LOADED',
tracks: tracks
type: 'USERS_LOADED',
users: users
}
}

View File

@ -15,6 +15,7 @@ const CoreMiddleware = (function(){
* The actual middleware inteceptor
**/
return store => next => action => {
var core = store.getState().core;
switch(action.type){
@ -101,43 +102,6 @@ const CoreMiddleware = (function(){
next(action)
break
case 'TRACK_LOADED':
if (action.data) ReactGA.event({ category: 'Track', action: 'Load', label: action.key });
if (action.track.album && action.track.album.images && action.track.album.images.length > 0){
action.track.album.images = helpers.digestMopidyImages(store.getState().mopidy, action.track.album.images);
}
next(action)
break
case 'ALBUM_LOADED':
if (action.data) ReactGA.event({ category: 'Album', action: 'Load', label: action.key })
if (action.album.images && action.album.images.length > 0){
action.album.images = helpers.digestMopidyImages(store.getState().mopidy, action.album.images);
}
next(action)
break
case 'ALBUMS_LOADED':
if (action.data) ReactGA.event({ category: 'Albums', action: 'Load', label: action.albums.length+' items' })
for (var i = 0; i < action.albums.length; i++){
if (action.albums[i].images && action.albums[i].images.length > 0){
action.albums[i].images = helpers.digestMopidyImages(store.getState().mopidy, action.albums[i].images);
}
}
next(action)
break
case 'ARTIST_LOADED':
if (action.data) ReactGA.event({ category: 'Artist', action: 'Load', label: action.artist.uri })
next(action)
break
case 'PLAY_PLAYLIST':
ReactGA.event({ category: 'Playlist', action: 'Play', label: action.uri })
next(action)
@ -222,73 +186,6 @@ const CoreMiddleware = (function(){
next(action)
break
case 'PLAYLIST_LOADED':
if (action.data) ReactGA.event({ category: 'Playlist', action: 'Load', label: action.playlist.uri })
var playlist = Object.assign({}, action.playlist)
switch (helpers.uriSource(playlist.uri)){
case 'm3u':
playlist.can_edit = true
break
case 'spotify':
if (store.getState().spotify.authorization && store.getState().spotify.me){
playlist.can_edit = (helpers.getFromUri('playlistowner',playlist.uri) == store.getState().spotify.me.id)
}
}
// proceed as usual
action.playlist = playlist
next(action)
break
case 'PLAYLISTS_LOADED':
if (action.data) ReactGA.event({ category: 'Playlists', action: 'Load', label: action.playlists.length+' items' })
var playlists = []
for (var i = 0; i < action.playlists.length; i++){
var playlist = Object.assign({}, action.playlists[i])
switch (helpers.uriSource(playlist.uri)){
case 'm3u':
playlist.can_edit = true
break
case 'spotify':
if (store.getState().spotify.authorization && store.getState().spotify.me){
playlist.can_edit = (helpers.getFromUri('playlistowner',playlist.uri) == store.getState().spotify.me.id)
}
}
playlists.push(playlist)
}
// proceed as usual
action.playlists = playlists
next(action)
break
case 'MOPIDY_CURRENTTLTRACK':
if (action.data && action.data.track){
helpers.setWindowTitle(action.data.track, store.getState().mopidy.play_state);
// make sure our images use mopidy host:port
if (action.data.track.album && action.data.track.album.images && action.data.track.album.images.length > 0){
var images = Object.assign([], action.data.track.album.images)
for (var i = 0; i < images.length; i++){
if (typeof(images[i]) === 'string' && images[i].startsWith('/images/')){
images[i] = '//'+store.getState().mopidy.host+':'+store.getState().mopidy.port+images[i]
}
}
action.data.track.album.images = images
}
}
next(action)
break
// Get assets from all of our providers
case 'GET_LIBRARY_PLAYLISTS':
if (store.getState().spotify.connected){
@ -323,9 +220,410 @@ const CoreMiddleware = (function(){
break
case 'RESTART':
location.reload()
location.reload();
break;
/**
* Playlist manipulation
**/
case 'PLAYLIST_KEY_UPDATED':
var playlists = Object.assign({}, core.playlists);
if (playlists[action.key] === undefined){
dispatch(coreActions.handleException("Cannot change key of playlist not in index"));
}
// Delete our old playlist by key, and add by new key
var playlist = Object.assign({}, playlists[action.key]);
delete playlists[action.key];
playlists[action.new_key] = playlist;
store.dispatch({
type: 'UPDATE_PLAYLISTS_INDEX',
playlists: playlists
});
break;
case 'PLAYLIST_TRACKS_REORDERED':
var playlists = Object.assign({}, core.playlists);
var playlist = Object.assign({}, playlists[action.key]);
var tracks_uris = Object.assign([], playlist.tracks_uris);
// handle insert_before offset if we're moving BENEATH where we're slicing tracks
var insert_before = action.insert_before
if (insert_before > action.range_start){
insert_before = insert_before - action.range_length;
}
// cut our moved tracks into a new array
var tracks_to_move = tracks_uris.splice(action.range_start, action.range_length)
tracks_to_move.reverse()
for (i = 0; i < tracks_to_move.length; i++){
tracks_uris.splice(insert_before, 0, tracks_to_move[i])
}
var snapshot_id = null;
if (action.snapshot_id){
snapshot_id = action.snapshot_id;
}
// Update our playlist
playlist.tracks_uris = tracks_uris;
playlist.snapshot_id = snapshot_id;
// Trigger normal playlist updating
store.dispatch({
type: 'PLAYLISTS_LOADED',
playlists: [playlist]
});
break;
case 'PLAYLIST_TRACKS_REMOVED':
var playlists = Object.assign({}, core.playlists);
var playlist = Object.assign({}, playlists[action.key]);
var tracks_uris = Object.assign([], playlist.tracks_uris);
var indexes = action.tracks_indexes.reverse()
for(var i = 0; i < indexes.length; i++){
tracks_uris.splice(indexes[i], 1);
}
var snapshot_id = null;
if (action.snapshot_id){
snapshot_id = action.snapshot_id;
}
// Update our playlist
playlist.tracks_uris = tracks_uris;
playlist.snapshot_id = snapshot_id;
// Trigger normal playlist updating
store.dispatch({
type: 'PLAYLISTS_LOADED',
playlists: [playlist]
});
break;
/**
* Queue and playback info
**/
case 'CURRENT_TRACK_LOADED':
store.dispatch({
type: 'TRACKS_LOADED',
tracks: [action.current_track]
});
next(action);
break;
case 'QUEUE_LOADED':
store.dispatch({
type: 'TRACKS_LOADED',
tracks: action.tracks
});
action.tracks_uris = helpers.arrayOf('uri',action.tracks);
next(action);
break;
/**
* Index actions
* These modify our asset indexes, which are used globally
**/
// Array wrapper for TRACKS_LOADED
case 'TRACK_LOADED':
store.dispatch({
type: 'TRACKS_LOADED',
tracks: [action.track]
});
break;
// Array wrapper for ALBUMS_LOADED
case 'ALBUM_LOADED':
store.dispatch({
type: 'ALBUMS_LOADED',
albums: [action.album]
});
break;
// Array wrapper for ARTISTS_LOADED
case 'ARTIST_LOADED':
store.dispatch({
type: 'ARTISTS_LOADED',
artists: [action.artist]
});
break;
// Array wrapper for PLAYLISTS_LOADED
case 'PLAYLIST_LOADED':
store.dispatch({
type: 'PLAYLISTS_LOADED',
playlists: [action.playlist]
});
break;
// Array wrapper for USERS_LOADED
case 'USER_LOADED':
store.dispatch({
type: 'USERS_LOADED',
users: [action.user]
});
break;
case 'TRACKS_LOADED':
var tracks = Object.assign({}, core.tracks);
for (var i = 0; i < action.tracks.length; i++){
var track = Object.assign({}, helpers.formatTracks(action.tracks[i]));
if (tracks[track.uri]){
track = Object.assign({}, tracks[track.uri], track);
}
if (track.album && track.album.images && track.album.images.length > 0){
track.album.images = helpers.digestMopidyImages(store.getState().mopidy, track.album.images);
track.images = track.album.images;
}
tracks[track.uri] = track;
}
// Update index
store.dispatch({
type: 'UPDATE_TRACKS_INDEX',
tracks: tracks
});
next(action);
break;
case 'ALBUMS_LOADED':
var albums = Object.assign({}, core.albums);
var tracks_loaded = [];
for (var i = 0; i < action.albums.length; i++){
var album = Object.assign({}, action.albums[i]);
if (albums[album.uri]){
album = Object.assign({}, albums[album.uri], album);
}
if (album.images && album.images.length > 0){
album.images = helpers.digestMopidyImages(store.getState().mopidy, album.images);
}
// Load our tracks
if (album.tracks){
var tracks = helpers.formatTracks(album.tracks);
var tracks_uris = helpers.arrayOf('uri', tracks);
album.tracks_uris = tracks_uris;
delete album.tracks;
tracks_loaded = [...tracks_loaded, ...tracks];
}
albums[album.uri] = album;
}
// Load these new tracks
store.dispatch({
type: 'TRACKS_LOADED',
tracks: tracks_loaded
});
// Update index
store.dispatch({
type: 'UPDATE_ALBUMS_INDEX',
albums: albums
});
next(action);
break
case 'ARTISTS_LOADED':
var artists = Object.assign({}, core.artists);
var tracks_loaded = [];
for (var i = 0; i < action.artists.length; i++){
var artist = action.artists[i];
if (artists[artist.uri]){
// if we've already got images, remove and add as additional_images
// this is to prevent LastFM overwriting Spotify images
if (artists[artist.uri].images){
artist.images_additional = artist.images
delete artist.images
}
artist = Object.assign({}, artists[artist.uri], artist);
}
if (artist.tracks){
var tracks = helpers.formatTracks(artist.tracks);
var tracks_uris = helpers.arrayOf('uri', tracks);
artist.tracks_uris = tracks_uris;
delete artist.tracks;
tracks_loaded = [...tracks_loaded, ...tracks];
}
// Update index
artists[artist.uri] = artist;
}
// Load our tracks
store.dispatch({
type: 'TRACKS_LOADED',
tracks: tracks_loaded
});
store.dispatch({
type: 'UPDATE_ARTISTS_INDEX',
artists: artists
});
next(action);
break;
case 'PLAYLISTS_LOADED':
var playlists = Object.assign({}, core.playlists);
var tracks_loaded = [];
for (var i = 0; i < action.playlists.length; i++){
var playlist = Object.assign({}, action.playlists[i]);
// Detect editability
switch (helpers.uriSource(playlist.uri)){
case 'm3u':
playlist.can_edit = true
break
case 'spotify':
if (store.getState().spotify.authorization && store.getState().spotify.me){
playlist.can_edit = (helpers.getFromUri('playlistowner',playlist.uri) == store.getState().spotify.me.id)
}
}
if (playlists[playlist.uri] !== undefined){
playlist = Object.assign({}, playlists[playlist.uri], playlist);
}
// Load our tracks
if (playlist.tracks){
var tracks = helpers.formatTracks(playlist.tracks);
var tracks_uris = helpers.arrayOf('uri', tracks);
playlist.tracks_uris = tracks_uris;
delete playlist.tracks;
tracks_loaded = [...tracks_loaded, ...tracks];
}
// Update index
playlists[playlist.uri] = playlist;
}
// Load our tracks
store.dispatch({
type: 'TRACKS_LOADED',
tracks: tracks_loaded
});
store.dispatch({
type: 'UPDATE_PLAYLISTS_INDEX',
playlists: playlists
});
next(action);
break;
case 'USERS_LOADED':
var users = Object.assign({}, core.users);
for (var i = 0; i < action.users.length; i++){
var user = Object.assign({}, action.users[i]);
if (users[user.uri]){
user = Object.assign({}, users[user.uri], user);
}
users[user.uri] = user;
}
// Update index
store.dispatch({
type: 'UPDATE_USERS_INDEX',
users: users
});
next(action);
break;
/**
* Loaded more linked assets
* Often fired during lazy-loading or async asset grabbing.
* We link the parent to these indexed records by {type}s_uris
**/
case 'LOADED_MORE':
console.log(action);
var parent_type_plural = action.parent_type+'s';
var parent_index = Object.assign({}, core[action.parent_type+'s']);
var parent = Object.assign({}, parent_index[action.parent_key]);
if (action.records_data.items !== undefined){
var records = action.records_data.items;
} else if (action.records_data.tracks !== undefined){
var records = action.records_data.tracks;
} else if (action.records_data.artists !== undefined){
var records = action.records_data.artists;
} else if (action.records_data.albums !== undefined){
var records = action.records_data.albums;
} else if (action.records_data.playlists !== undefined){
var records = action.records_data.playlists;
} else {
var records = action.records_data;
}
if (action.records_type == 'track'){
records = helpers.formatTracks(records);
}
var records_type_plural = action.records_type+'s';
var records_index = Object.assign({});
var records_uris = helpers.arrayOf('uri', records);
// Append our records_uris array with our new records
var uris = records_uris;
if (parent[records_type_plural+'_uris'] !== undefined){
uris = [...parent[records_type_plural+'_uris'], ...uris];
}
parent[records_type_plural+'_uris'] = uris;
if (action.records_data.next !== undefined){
parent[records_type_plural+'_more'] = action.records_data.next;
}
// Parent loaded (well, changed)
var parent_action = {
type: parent_type_plural.toUpperCase()+'_LOADED'
};
parent_action[parent_type_plural] = [parent];
store.dispatch(parent_action);
// Records loaded
var records_action = {
type: records_type_plural.toUpperCase()+'_LOADED'
};
records_action[records_type_plural] = records;
store.dispatch(records_action);
next(action);
break;
// This action is irrelevant to us, pass it on to the next middleware
default:
return next(action)

View File

@ -11,94 +11,15 @@ export default function reducer(core = {}, action){
* Current track and tracklist
**/
case 'MOPIDY_TLTRACKS':
if (!action.data ) return core
var tracklist = []
for (var i = 0; i < action.data.length; i++){
var tltrack = helpers.formatTracks(action.data[i]);
// load our metadata (if we have any for that tlid)
if (core.queue_metadata !== undefined && core.queue_metadata['tlid_'+tltrack.tlid] !== undefined){
var metadata = core.queue_metadata['tlid_'+tltrack.tlid]
} else {
var metadata = {}
}
var current_tlid = null;
if (core.current_track && core.tracks && core.tracks[core.current_track] !== undefined && core.tracks[core.current_track].tlid !== undefined){
current_tlid = core.tracks[core.current_track].tlid;
}
var track = Object.assign(
{},
tltrack,
metadata,
{
playing: (tltrack.tlid == current_tlid)
})
tracklist.push(track)
}
tracklist = helpers.formatTracks(tracklist);
return Object.assign({}, core, { current_tracklist: tracklist });
case 'MOPIDY_CURRENTTLTRACK':
if (!action.data) return core
var current_tracklist = []
Object.assign(current_tracklist, core.current_tracklist)
for (var i = 0; i < current_tracklist.length; i++){
Object.assign(
current_tracklist[i],
{ playing: (current_tracklist[i].tlid == action.data.tlid ) }
)
}
case 'CURRENT_TRACK_LOADED':
return Object.assign({}, core, {
current_tracklist: current_tracklist,
current_track: action.data.track.uri
current_track_uri: action.current_track_uri
});
case 'TRACK_LOADED':
if (!action.key || !action.track) return core
var tracks = Object.assign({}, core.tracks)
if (tracks[action.key]){
var track = Object.assign(
{},
tracks[action.key],
helpers.formatTracks(action.track)
);
} else {
var track = Object.assign(
{},
helpers.formatTracks(action.track)
);
}
tracks[action.key] = track
return Object.assign({}, core, { tracks: tracks });
case 'TRACKS_LOADED':
var tracks = Object.assign({}, core.tracks)
for (var i = 0; i < action.tracks.length; i++){
var track = action.tracks[i]
if (tracks[track.uri] !== undefined){
track = Object.assign(
{},
tracks[track.uri],
track
);
}
tracks[track.uri] = helpers.formatTracks(track);
}
return Object.assign({}, core, { tracks: tracks });
case 'QUEUE_LOADED':
return Object.assign({}, core, {
queue: action.tracks_uris
});
case 'PUSHER_QUEUE_METADATA':
case 'PUSHER_QUEUE_METADATA_CHANGED':
@ -181,37 +102,27 @@ export default function reducer(core = {}, action){
/**
* Albums
* Index updates
* These actions are only ever called by middleware after we've digested one more many assets
* and appended to their relevant index.
**/
case 'ALBUM_LOADED':
var albums = Object.assign([], core.albums)
case 'UPDATE_TRACKS_INDEX':
return Object.assign({}, core, { tracks: action.tracks });
if (albums[action.key]){
var album = Object.assign({}, albums[action.key], action.album)
} else {
var album = Object.assign({}, action.album)
}
case 'UPDATE_ALBUMS_INDEX':
return Object.assign({}, core, { albums: action.albums });
album.tracks = helpers.formatTracks(album.tracks);
albums[action.key] = album
case 'UPDATE_ARTISTS_INDEX':
return Object.assign({}, core, { artists: action.artists });
return Object.assign({}, core, { albums: albums });
case 'UPDATE_PLAYLISTS_INDEX':
return Object.assign({}, core, { playlists: action.playlists });
case 'ALBUMS_LOADED':
var albums = Object.assign([], core.albums)
case 'UPDATE_USERS_INDEX':
return Object.assign({}, core, { users: action.users });
for (var i = 0; i < action.albums.length; i++){
var album = action.albums[i]
if (albums[album.uri]){
album = Object.assign({}, albums[album.uri], album)
}
album.tracks = helpers.formatTracks(album.tracks);
albums[album.uri] = album
}
return Object.assign({}, core, { albums: albums });
case 'NEW_RELEASES_LOADED':
if (!action.uris){
@ -232,54 +143,6 @@ export default function reducer(core = {}, action){
});
/**
* Artists
**/
case 'ARTIST_LOADED':
var artists = Object.assign([], core.artists)
if (artists[action.key]){
// if we've already got images, remove and add as additional_images
// this is to prevent LastFM overwriting Spotify images
if (artists[action.key].images){
action.artist.images_additional = action.artist.images
delete action.artist.images
}
var artist = Object.assign({}, artists[action.key], action.artist)
if (artist.tracks){
artist.tracks = helpers.formatTracks(artist.tracks);
}
} else {
var artist = Object.assign({}, action.artist)
if (artist.tracks){
artist.tracks = helpers.formatTracks(artist.tracks);
}
}
artists[action.key] = artist
return Object.assign({}, core, { artists: artists });
case 'ARTISTS_LOADED':
var artists = Object.assign([], core.artists)
for (var i = 0; i < action.artists.length; i++){
var artist = action.artists[i]
if (typeof(artists[artist.uri]) !== 'undefined'){
artist = Object.assign({}, artists[artist.uri], artist)
}
if (artist.tracks){
artist.tracks = helpers.formatTracks(artist.tracks);
}
artists[artist.uri] = artist
}
return Object.assign({}, core, { artists: artists });
case 'ARTIST_ALBUMS_LOADED':
var artists = Object.assign([], core.artists)
var albums_uris = []
@ -298,22 +161,6 @@ export default function reducer(core = {}, action){
return Object.assign({}, core, { artists: artists });
/**
* User profiles
**/
case 'USER_LOADED':
var users = Object.assign([], core.users)
if (users[action.key]){
var user = Object.assign({}, users[action.key], action.user)
} else {
var user = Object.assign({}, action.user)
}
users[action.key] = user
return Object.assign({}, core, { users: users });
case 'USER_PLAYLISTS_LOADED':
var users = Object.assign([], core.users)
var playlists_uris = []
@ -338,119 +185,6 @@ export default function reducer(core = {}, action){
* Playlists
**/
case 'PLAYLIST_LOADED':
case 'PLAYLIST_UPDATED':
var playlists = Object.assign([], core.playlists)
if (typeof(playlists[action.key]) !== 'undefined'){
var existing_playlist = Object.assign({}, playlists[action.key])
if (existing_playlist.tracks && action.playlist.tracks){
var tracks = [...existing_playlist.tracks, ...action.playlist.tracks]
} else if (existing_playlist.tracks){
var tracks = existing_playlist.tracks
} else if (action.playlist.tracks){
var tracks = action.playlist.tracks
} else {
var tracks = []
}
var merged_playlist = Object.assign(
{},
existing_playlist,
action.playlist,
{
tracks: helpers.formatTracks(tracks)
}
)
} else {
var merged_playlist = Object.assign({}, action.playlist)
}
playlists[action.key] = merged_playlist
return Object.assign({}, core, { playlists: playlists })
case 'PLAYLIST_KEY_UPDATED':
var playlists = Object.assign([], core.playlists)
// URI not in our index? No change needed then
if (typeof(playlists[action.key]) === 'undefined'){
return core
}
// Delete our old playlist by key, and add by new key
var playlist = Object.assign({}, playlists[action.key])
delete playlists[action.key]
playlists[playlist.uri] = playlist
return Object.assign({}, core, { playlists: playlists })
case 'PLAYLISTS_LOADED':
var playlists = Object.assign([], core.playlists)
for (var i = 0; i < action.playlists.length; i++){
var loaded_playlist = action.playlists[i]
if (typeof(playlists[loaded_playlist.uri]) !== 'undefined'){
var existing_playlist = Object.assign({}, playlists[loaded_playlist.uri])
if (existing_playlist.tracks && loaded_playlist.tracks){
var tracks = [...existing_playlist.tracks, ...loaded_playlist.tracks]
} else if (existing_playlist.tracks){
var tracks = existing_playlist.tracks
} else if (loaded_playlist.tracks){
var tracks = loaded_playlist.tracks
} else {
var tracks = []
}
var merged_playlist = Object.assign(
{},
existing_playlist,
loaded_playlist,
{
tracks: helpers.formatTracks(tracks)
}
)
} else {
var merged_playlist = loaded_playlist
}
playlists[merged_playlist.uri] = merged_playlist
}
return Object.assign({}, core, { playlists: playlists });
case 'PLAYLIST_LOADED_MORE_TRACKS':
var playlists = Object.assign([], core.playlists)
var playlist = Object.assign(
{},
playlists[action.key],
{
tracks: [...playlists[action.key].tracks, ...helpers.formatTracks(action.data.items)],
tracks_more: action.data.next,
tracks_total: action.data.total
}
)
playlists[action.key] = playlist
return Object.assign({}, core, { playlists: playlists });
case 'PLAYLIST_TRACKS_REMOVED':
var playlists = Object.assign([], core.playlists)
var playlist = Object.assign({}, playlists[action.key])
var tracks = Object.assign([], playlist.tracks)
var indexes = action.tracks_indexes.reverse()
for(var i = 0; i < indexes.length; i++){
tracks.splice(indexes[i], 1 )
}
var snapshot_id = null
if (action.snapshot_id ) snapshot_id = action.snapshot_id
Object.assign(playlist, { tracks: tracks, snapshot_id: snapshot_id })
playlists[action.key] = playlist
return Object.assign({}, core, { playlists: playlists });
case 'PLAYLIST_TRACKS':
var playlists = Object.assign([], core.playlists)
var playlist = Object.assign({}, playlists[action.key], { tracks: helpers.formatTracks(action.tracks) })
@ -458,29 +192,6 @@ export default function reducer(core = {}, action){
playlists[action.key] = playlist
return Object.assign({}, core, { playlists: playlists });
case 'PLAYLIST_TRACKS_REORDERED':
var playlists = Object.assign([], core.playlists)
var playlist = Object.assign({}, playlists[action.key])
var tracks = Object.assign([], playlist.tracks)
// handle insert_before offset if we're moving BENEATH where we're slicing tracks
var insert_before = action.insert_before
if (insert_before > action.range_start ) insert_before = insert_before - action.range_length
// cut our moved tracks into a new array
var tracks_to_move = tracks.splice(action.range_start, action.range_length)
tracks_to_move.reverse()
for(i = 0; i < tracks_to_move.length; i++){
tracks.splice(insert_before, 0, tracks_to_move[i])
}
var snapshot_id = null
if (action.snapshot_id ) snapshot_id = action.snapshot_id
Object.assign(playlist, { tracks: tracks, snapshot_id: snapshot_id })
playlists[action.key] = playlist
return Object.assign({}, core, { playlists: playlists });
case 'LIBRARY_PLAYLISTS_LOADED':
if (core.library_playlists){
var library_playlists = [...core.library_playlists, ...action.uris]

View File

@ -64,8 +64,8 @@ export function getTrackLyrics(uri, url){
dispatch({
type: 'TRACK_LOADED',
key: uri,
track: {
uri: uri,
lyrics: null,
lyrics_url: null
}
@ -89,8 +89,8 @@ export function getTrackLyrics(uri, url){
dispatch({
type: 'TRACK_LOADED',
key: uri,
track: {
uri: uri,
lyrics: lyrics_html,
lyrics_url: url
}
@ -131,8 +131,8 @@ export function findTrackLyrics(track){
}
dispatch({
type: 'TRACK_LOADED',
key: track.uri,
track: {
uri: track.uri,
lyrics_results: lyrics_results
}
});

View File

@ -1,59 +1,245 @@
var coreActions = require('../core/actions')
var uiActions = require('../ui/actions')
var helpers = require('../../helpers')
var coreActions = require('../core/actions');
var uiActions = require('../ui/actions');
var helpers = require('../../helpers');
/**
* Send an ajax request to the Spotify API
* Send an ajax request to the LastFM API
*
* @param dispatch obj
* @param getState obj
* @param endpoint params = the url params to send
* @param dispatch = obj
* @param getState = obj
* @param params = string, the url params to send
* @params signed = boolean, whether we've got a signed request with baked-in api_key
**/
const sendRequest = (dispatch, getState, params ) => {
const sendRequest = (dispatch, getState, params, signed = false) => {
return new Promise((resolve, reject) => {
var loader_key = helpers.generateGuid()
dispatch(uiActions.startLoading(loader_key, 'lastfm_'+params))
var loader_key = helpers.generateGuid();
var method = params.substring(params.indexOf("method=")+7, params.length);
method = method.substring(0, method.indexOf("&"));
dispatch(uiActions.startLoading(loader_key, 'lastfm_'+method));
var config = {
method: 'GET',
cache: true,
timeout: 30000,
url: '//ws.audioscrobbler.com/2.0/?format=json&api_key=4320a3ef51c9b3d69de552ac083c55e3&'+params
url: '//ws.audioscrobbler.com/2.0/?format=json&'+params
}
// Signed requests don't need our api_key as the proxy has it's own
if (!signed){
config.url += '&api_key=4320a3ef51c9b3d69de552ac083c55e3';
} else {
config.method = 'POST';
}
$.ajax(config).then(
response => {
dispatch(uiActions.stopLoading(loader_key))
resolve(response)
dispatch(uiActions.stopLoading(loader_key));
if (response.error){
reject({
config: config,
error: response
});
} else {
resolve(response);
}
},
(xhr, status, error) => {
dispatch(uiActions.stopLoading(loader_key))
dispatch(coreActions.handleException(
'LastFM: '+xhr.responseText,
{
config: config,
error: error,
status: status,
xhr: xhr
}
));
reject(error)
dispatch(uiActions.stopLoading(loader_key));
// Snatch a more meaningful error
var description = null;
if (xhr.responseJSON.message){
description = xhr.responseJSON.message;
}
reject({
config: config,
error: error,
description: description,
status: status,
xhr: xhr
});
}
)
})
}
export function connect(){
/**
* Send a SIGNED ajax request to the LastFM API
*
* @param dispatch = obj
* @param getState = obj
* @param params = string, the url params to send
* @param signed = boolean
**/
const sendSignedRequest = (dispatch, getState, params) => {
return new Promise((resolve, reject) => {
// Not authorized
if (!getState().lastfm.session){
reject({
params: params,
error: "No active LastFM session"
});
}
var loader_key = helpers.generateGuid();
var method = params.substring(params.indexOf("method=")+7, params.length);
method = method.substring(0, method.indexOf("&"));
dispatch(uiActions.startLoading(loader_key, 'lastfm_'+method));
params += "&sk="+getState().lastfm.session.key;
var config = {
method: 'GET',
cache: false,
timeout: 30000,
url: getState().lastfm.authorization_url+"?action=sign_request&"+params
}
// Get our server proxy to sign our request
$.ajax(config).then(
response => {
dispatch(uiActions.stopLoading(loader_key));
// Now we have signed params, we can make the actual request
sendRequest(dispatch, getState, response.params, true)
.then(
response => {
resolve(response);
},
error => {
reject(error);
}
);
},
(xhr, status, error) => {
dispatch(uiActions.stopLoading(loader_key));
reject(error)
}
);
})
}
export function set(data){
return {
type: 'LASTFM_SET',
data: data
}
}
/**
* Handle authorization process
**/
export function authorizationGranted(data){
data.session.expiry = new Date().getTime() + 3600;
return {
type: 'LASTFM_AUTHORIZATION_GRANTED',
data: data
}
}
export function revokeAuthorization(){
return { type: 'LASTFM_AUTHORIZATION_REVOKED' }
}
/**
* Signed requests
**/
export function loveTrack(uri, artist, track){
return (dispatch, getState) => {
dispatch({ type: 'LASTFM_CONNECTING' })
sendRequest(dispatch, getState, 'method=artist.getInfo&artist=')
artist = encodeURIComponent(artist);
var params = 'method=track.love&track='+track+'&artist='+artist;
sendSignedRequest(dispatch, getState, params)
.then(
response => {
dispatch({ type: 'LASTFM_CONNECTED' })
dispatch({
type: 'TRACK_LOADED',
track: {
uri: uri,
userloved: true
}
});
}
)
}
}
export function unloveTrack(uri, artist, track){
return (dispatch, getState) => {
artist = encodeURIComponent(artist);
var params = 'method=track.unlove&track='+track+'&artist='+artist;
sendSignedRequest(dispatch, getState, params)
.then(
response => {
dispatch({
type: 'TRACK_LOADED',
track: {
uri: uri,
userloved: false
}
});
}
)
}
}
export function scrobble(track){
return (dispatch, getState) => {
var track_name = track.name;
var artist_name = "Unknown";
if (track.artists){
artist_name = track.artists[0].name;
}
var artist_name = encodeURIComponent(artist_name);
var params = 'method=track.scrobble';
params += '&track='+track_name+'&artist='+artist_name;
params += '&timestamp='+Math.floor(Date.now() / 1000);
sendSignedRequest(dispatch, getState, params)
.then(
response => {
console.log("Scrobbled", response);
},
error => {
dispatch(coreActions.handleException(
'Could not scrobble track',
error,
(error.description ? error.description : null)
));
}
)
}
}
/**
* Non-signed requests
**/
export function getMe(){
return (dispatch, getState) => {
var params = 'method=user.getInfo&user='+getState().lastfm.session.name
sendRequest(dispatch, getState, params)
.then(
response => {
if (response.user){
dispatch({
type: 'LASTFM_USER_LOADED',
user: response.user
});
dispatch({ type: 'LASTFM_CONNECTED' })
}
}
)
}
@ -73,8 +259,8 @@ export function getArtist(uri, artist, mbid = false){
if (response.artist){
dispatch({
type: 'ARTIST_LOADED',
key: uri,
artist: {
uri: uri,
images: response.artist.image,
bio: response.artist.bio,
listeners: parseInt(response.artist.stats.listeners),
@ -114,19 +300,34 @@ export function getAlbum(artist, album, mbid = false){
}
}
export function getTrack(artist, track){
export function getTrack(track, artist_name = null, track_name = null){
return (dispatch, getState) => {
dispatch({ type: 'LASTFM_TRACK_LOADED', data: false });
artist = encodeURIComponent(artist );
sendRequest(dispatch, getState, 'method=track.getInfo&track='+track+'&artist='+artist)
if (track){
track_name = track.name;
if (track.artists){
artist_name = track.artists[0].name;
}
}
artist_name = encodeURIComponent(artist_name);
var params = 'method=track.getInfo&track='+track_name+'&artist='+artist_name;
if (getState().lastfm.session){
params += '&username='+getState().lastfm.session.name;
}
sendRequest(dispatch, getState, params)
.then(
response => {
if (response.track){
var merged_track = Object.assign(
{},
{
uri: track.uri
},
response.track,
track
);
dispatch({
type: 'LASTFM_TRACK_LOADED',
data: response.track
type: 'TRACK_LOADED',
track: merged_track
});
}
}

View File

@ -0,0 +1,43 @@
import ReactGA from 'react-ga'
var helpers = require('./../../helpers')
var lastfmActions = require('./actions')
var uiActions = require('../ui/actions')
var pusherActions = require('../pusher/actions')
const LastfmMiddleware = (function(){
/**
* The actual middleware inteceptor
**/
return store => next => action => {
var state = store.getState();
switch(action.type){
case 'LASTFM_USER_LOADED':
var user = Object.assign(
{},
action.user,
{
uri: "lastfm:user:"+action.user.name
}
);
store.dispatch({
type: "USERS_LOADED",
users: [user]
});
next(action);
break;
// This action is irrelevant to us, pass it on to the next middleware
default:
return next(action);
}
}
})();
export default LastfmMiddleware

View File

@ -9,6 +9,22 @@ export default function reducer(lastfm = {}, action){
case 'LASTFM_CONNECTED':
return Object.assign({}, lastfm, { connected: true, connecting: false });
case 'LASTFM_SET':
return Object.assign({}, lastfm, action.data)
case 'LASTFM_AUTHORIZATION_GRANTED':
return Object.assign({}, lastfm, {
authorizing: false,
session: action.data.session
})
case 'LASTFM_AUTHORIZATION_REVOKED':
return Object.assign({}, lastfm, {
authorizing: false,
session: false,
me: false
});
default:
return lastfm
}

View File

@ -167,6 +167,28 @@ const localstorageMiddleware = (function(){
);
localStorage.setItem('ui', JSON.stringify(ui))
break
case 'LASTFM_AUTHORIZATION_GRANTED':
var lastfm = JSON.parse(localStorage.getItem('lastfm') );
lastfm = Object.assign(
{},
{
session: action.data.session
}
);
localStorage.setItem('lastfm', JSON.stringify(lastfm));
break;
case 'LASTFM_AUTHORIZATION_REVOKED':
var lastfm = JSON.parse(localStorage.getItem('lastfm') );
lastfm = Object.assign(
{},
{
session: null
}
);
localStorage.setItem('lastfm', JSON.stringify(lastfm));
break;
}
}

View File

@ -1259,11 +1259,11 @@ const MopidyMiddleware = (function(){
type: 'PLAYLIST_KEY_UPDATED',
key: action.key,
new_key: response.uri
})
hashHistory.push(global.baseURL+'playlist/'+response.uri)
});
hashHistory.push(global.baseURL+'playlist/'+encodeURIComponent(response.uri));
}
store.dispatch(uiActions.createNotification('Saved'))
store.dispatch(uiActions.createNotification('Saved'));
})
});
break
@ -1703,17 +1703,17 @@ const MopidyMiddleware = (function(){
* ======================================================================================
**/
case 'MOPIDY_TLTRACKS':
store.dispatch({
type: 'QUEUE_LOADED',
tracks: helpers.formatTracks(action.data)
})
break;
case 'MOPIDY_CURRENTTLTRACK':
if (action.data && action.data.track){
var track = helpers.formatTracks(action.data);
// Fire off our universal track index loader
store.dispatch({
type: 'TRACK_LOADED',
key: track.uri,
track: track
});
// We've got Spotify running, and it's a spotify track - go straight to the source!
if (helpers.uriSource(track.uri) == 'spotify' && store.getState().spotify.enabled){
store.dispatch(spotifyActions.getTrack(track.uri))
@ -1722,9 +1722,16 @@ const MopidyMiddleware = (function(){
} else {
store.dispatch(mopidyActions.getImages('tracks',[track.uri]))
}
}
next(action);
// Set our window title to the track title
helpers.setWindowTitle(track, store.getState().mopidy.play_state);
store.dispatch({
type: 'CURRENT_TRACK_LOADED',
current_track: track,
current_track_uri: track.uri
});
}
break;
case 'MOPIDY_GET_TRACK':

View File

@ -5,6 +5,7 @@ var helpers = require('../../helpers.js')
var coreActions = require('../core/actions.js')
var uiActions = require('../ui/actions.js')
var pusherActions = require('./actions.js')
var lastfmActions = require('../lastfm/actions.js')
var spotifyActions = require('../spotify/actions.js')
const PusherMiddleware = (function(){
@ -431,7 +432,10 @@ const PusherMiddleware = (function(){
store.dispatch(spotifyActions.set({
locale: (action.config.locale ? action.config.locale : null),
country: (action.config.country ? action.config.country : null),
authorization_url: (action.config.authorization_url ? action.config.authorization_url : null)
authorization_url: (action.config.spotify_authorization_url ? action.config.spotify_authorization_url : null)
}))
store.dispatch(lastfmActions.set({
authorization_url: (action.config.lastfm_authorization_url ? action.config.lastfm_authorization_url : null)
}))
next(action )

View File

@ -192,25 +192,8 @@ export function set(data){
export function connect(){
return (dispatch, getState) => {
dispatch({ type: 'SPOTIFY_CONNECTING' });
// send a generic request to ensure spotify is up and running
// there is no 'test' or 'ping' endpoint on the Spotify API
sendRequest(dispatch, getState, 'browse/categories?limit=1' )
.then(
response => {
dispatch({
type: 'SPOTIFY_CONNECTED'
});
},
error => {
dispatch(coreActions.handleException(
'Could not connect to Spotify',
error
));
}
);
dispatch(getMe());
}
}
@ -249,10 +232,6 @@ export function importAuthorization(data){
**/
export function getMe(){
return (dispatch, getState) => {
// flush out the previous store value
dispatch({ type: 'SPOTIFY_ME_LOADED', data: false });
sendRequest(dispatch, getState, 'me' )
.then(
response => {
@ -260,12 +239,14 @@ export function getMe(){
type: 'SPOTIFY_ME_LOADED',
data: response
});
dispatch({ type: 'SPOTIFY_CONNECTED' });
},
error => {
dispatch(coreActions.handleException(
'Could not load your profile',
error
));
dispatch({ type: 'SPOTIFY_DISCONNECTED' });
}
);
}
@ -279,11 +260,7 @@ export function getMe(){
**/
export function getTrack(uri){
return (dispatch, getState) => {
// flush out the previous store value
dispatch({ type: 'SPOTIFY_TRACK_LOADED', data: false });
sendRequest(dispatch, getState, 'tracks/'+ helpers.getFromUri('trackid', uri) )
sendRequest(dispatch, getState, 'tracks/'+ helpers.getFromUri('trackid', uri))
.then(
response => {
let track = Object.assign(
@ -524,6 +501,40 @@ export function getURL(url, action_name, key = false){
}
}
export function loadMore(url, loaded_more_action = null, custom_action = null){
return (dispatch, getState) => {
sendRequest(dispatch, getState, url)
.then(
response => {
if (loaded_more_action){
dispatch(coreActions.loadedMore(
loaded_more_action.parent_type,
loaded_more_action.parent_key,
loaded_more_action.records_type,
response
));
} else if (custom_action){
dispatch({
type: custom_action.type,
key: custom_action.key,
data: response
});
} else {
dispatch(coreActions.handleException(
'No callback handler for loading more items'
));
}
},
error => {
dispatch(coreActions.handleException(
'Could not load more '+callback_action.parent_type+' '+callback_action.records_type+'s',
error
));
}
);
}
}
export function clearSearchResults(){
return {
type: 'SPOTIFY_CLEAR_SEARCH_RESULTS'
@ -692,6 +703,13 @@ export function following(uri, method = 'GET'){
var asset_name = helpers.uriType(uri);
var endpoint, data
switch(asset_name){
case 'track':
if (method == 'GET'){
endpoint = 'me/tracks/contains/?ids='+ helpers.getFromUri('trackid', uri)
} else {
endpoint = 'me/tracks/?ids='+ helpers.getFromUri('trackid', uri)
}
break
case 'album':
if (method == 'GET'){
endpoint = 'me/albums/contains/?ids='+ helpers.getFromUri('albumid', uri)
@ -724,7 +742,7 @@ export function following(uri, method = 'GET'){
break
}
sendRequest(dispatch, getState, endpoint, method, data )
sendRequest(dispatch, getState, endpoint, method, data)
.then(
response => {
if (response ) is_following = response
@ -1170,7 +1188,7 @@ export function playArtistTopTracks(uri){
* ======================================================================================
**/
export function getUser(uri){
export function getUser(uri, and_playlists = false){
return (dispatch, getState) => {
// get the user
@ -1178,9 +1196,8 @@ export function getUser(uri){
.then(
response => {
dispatch({
type: 'USER_LOADED',
key: response.uri,
user: response
type: 'USERS_LOADED',
users: [response]
});
},
error => {
@ -1191,7 +1208,9 @@ export function getUser(uri){
}
)
dispatch(getUserPlaylists(uri))
if (and_playlists){
dispatch(getUserPlaylists(uri));
}
}
}
@ -1221,14 +1240,11 @@ export function getUserPlaylists(user_uri){
}
dispatch({
type: 'PLAYLISTS_LOADED',
playlists: playlists
});
dispatch({
type: 'SPOTIFY_USER_PLAYLISTS_LOADED',
key: user_uri,
data: response
type: 'LOADED_MORE',
parent_type: 'user',
parent_key: user_uri,
records_type: 'playlist',
records_data: response
});
},
error => {

View File

@ -27,10 +27,7 @@ const SpotifyMiddleware = (function(){
store.dispatch(spotifyActions.getAllLibraryPlaylists())
}
// Get the current logged-in user
store.dispatch(spotifyActions.getMe())
next(action)
next(action);
break
case 'SPOTIFY_AUTHORIZATION_GRANTED':
@ -416,10 +413,9 @@ const SpotifyMiddleware = (function(){
ReactGA.event({category: 'Spotify', action: 'Authorization verified', label: action.data.id});
store.dispatch({
type: 'USER_LOADED',
key: action.data.uri,
user: action.data
})
type: 'USERS_LOADED',
users: [action.data]
});
next(action);
break;

View File

@ -85,7 +85,14 @@ class Album extends React.Component{
}
loadMore(){
this.props.spotifyActions.getURL(this.props.album.tracks_more, 'SPOTIFY_ALBUM_LOADED_MORE' );
this.props.spotifyActions.loadMore(
this.props.album.tracks_more,
{
parent_type: 'album',
parent_key: this.props.album.uri,
records_type: 'track'
}
);
}
play(){
@ -120,6 +127,16 @@ class Album extends React.Component{
}
}
var tracks = [];
if (this.props.album.tracks_uris && this.props.tracks){
for (var i = 0; i < this.props.album.tracks_uris.length; i++){
var uri = this.props.album.tracks_uris[i]
if (this.props.tracks.hasOwnProperty(uri)){
tracks.push(this.props.tracks[uri])
}
}
}
return (
<div className="view album-view content-wrapper">
<div className="thumbnail-wrapper">
@ -135,8 +152,7 @@ class Album extends React.Component{
{ !this.props.slim_mode && artists.length > 0 ? <li><ArtistSentence artists={artists} /></li> : null }
{ this.props.album.release_date ? <li><Dater type="date" data={ this.props.album.release_date } /></li> : null }
<li>
{ this.props.album.tracks_total ? this.props.album.tracks_total : '0' } tracks,&nbsp;
{ this.props.album.tracks ? <Dater type="total-time" data={this.props.album.tracks} /> : '0 mins' }
{tracks ? <span>{tracks.length} tracks, <Dater type="total-time" data={tracks} /></span> : '0 tracks, 0 mins' }
</li>
</ul>
</div>
@ -148,7 +164,7 @@ class Album extends React.Component{
</div>
<section className="list-wrapper">
{ this.props.album.tracks ? <TrackList className="album-track-list" tracks={ this.props.album.tracks } uri={this.props.params.uri} /> : null }
<TrackList className="album-track-list" tracks={tracks} uri={this.props.params.uri} />
<LazyLoadListener loading={this.props.album.tracks_more} loadMore={ () => this.loadMore() }/>
</section>
@ -169,6 +185,7 @@ const mapStateToProps = (state, ownProps) => {
return {
slim_mode: state.ui.slim_mode,
load_queue: state.ui.load_queue,
tracks: state.core.tracks,
artists: state.core.artists,
album: (state.core.albums && state.core.albums[uri] !== undefined ? state.core.albums[uri] : false ),
albums: state.core.albums,

View File

@ -78,7 +78,14 @@ class Artist extends React.Component{
}
loadMore(){
this.props.spotifyActions.getURL(this.props.artist.albums_more, 'SPOTIFY_ARTIST_ALBUMS_LOADED', this.props.params.uri );
this.props.spotifyActions.loadMore(
this.props.artist.albums_more,
{
parent_type: 'artist',
parent_key: this.props.params.uri,
records_type: 'album'
}
);
}
inLibrary(){
@ -161,12 +168,23 @@ class Artist extends React.Component{
)
default:
var tracks = [];
if (this.props.artist.tracks_uris && this.props.tracks){
for (var i = 0; i < this.props.artist.tracks_uris.length; i++){
var uri = this.props.artist.tracks_uris[i]
if (this.props.tracks.hasOwnProperty(uri)){
tracks.push(this.props.tracks[uri])
}
}
}
return (
<div className="body overview">
<div className={related_artists.length > 0 ? "col w70" : "col w100"}>
<h4>Top tracks</h4>
<div className="list-wrapper">
{ this.props.artist.tracks ? <TrackList className="artist-track-list" uri={this.props.params.uri} tracks={this.props.artist.tracks} /> : null }
<TrackList className="artist-track-list" uri={this.props.params.uri} tracks={tracks} />
</div>
</div>
@ -179,7 +197,7 @@ class Artist extends React.Component{
<h4>Albums</h4>
<section className="grid-wrapper no-top-padding">
<AlbumGrid albums={albums} />
<LazyLoadListener loading={this.props.artist.albums_more} loadMore={ () => this.loadMore() }/>
<LazyLoadListener loading={this.props.artist.albums_more} loadMore={() => this.loadMore()} />
</section>
</div>
)
@ -263,8 +281,9 @@ const mapStateToProps = (state, ownProps) => {
return {
slim_mode: state.ui.slim_mode,
load_queue: state.ui.load_queue,
artist: (state.core.artists && state.core.artists[uri] !== undefined ? state.core.artists[uri] : false),
artists: (state.core.artists ? state.core.artists : []),
artist: (state.core.artists[uri] !== undefined ? state.core.artists[uri] : false),
tracks: state.core.tracks,
artists: state.core.artists,
spotify_library_artists: state.spotify.library_artists,
local_library_artists: state.mopidy.library_artists,
albums: (state.core.albums ? state.core.albums : []),

View File

@ -14,6 +14,7 @@ import LazyLoadListener from '../components/LazyLoadListener'
import FollowButton from '../components/FollowButton'
import Header from '../components/Header'
import ContextMenuTrigger from '../components/ContextMenuTrigger'
import URILink from '../components/URILink'
import * as helpers from '../helpers'
import * as coreActions from '../services/core/actions'
@ -74,7 +75,14 @@ class Playlist extends React.Component{
}
loadMore(){
this.props.spotifyActions.getURL(this.props.playlist.tracks_more, 'PLAYLIST_LOADED_MORE_TRACKS', this.props.playlist.uri );
this.props.spotifyActions.loadMore(
this.props.playlist.tracks_more,
{
parent_type: 'playlist',
parent_key: this.props.playlist.uri,
records_type: 'track'
}
);
}
play(){
@ -94,20 +102,20 @@ class Playlist extends React.Component{
// TODO: Once deletion occurs, remove playlist from global playlists list
delete(){
this.props.mopidyActions.deletePlaylist(this.props.playlist.uri )
this.props.mopidyActions.deletePlaylist(this.props.playlist.uri);
}
reorderTracks(indexes, index){
this.props.coreActions.reorderPlaylistTracks(this.props.playlist.uri, indexes, index, this.props.playlist.snapshot_id )
this.props.coreActions.reorderPlaylistTracks(this.props.playlist.uri, indexes, index, this.props.playlist.snapshot_id);
}
removeTracks(tracks_indexes){
this.props.coreActions.removeTracksFromPlaylist(this.props.playlist.uri, tracks_indexes )
this.props.coreActions.removeTracksFromPlaylist(this.props.playlist.uri, tracks_indexes);
}
inLibrary(){
var library = helpers.uriSource(this.props.params.uri)+'_library_playlists'
return (this.props[library] && this.props[library].indexOf(this.props.params.uri) > -1)
var library = helpers.uriSource(this.props.params.uri)+'_library_playlists';
return (this.props[library] && this.props[library].indexOf(this.props.params.uri) > -1);
}
renderActions(){
@ -167,6 +175,16 @@ class Playlist extends React.Component{
)
}
var tracks = [];
if (this.props.playlist.tracks_uris && this.props.tracks){
for (var i = 0; i < this.props.playlist.tracks_uris.length; i++){
var uri = this.props.playlist.tracks_uris[i]
if (this.props.tracks.hasOwnProperty(uri)){
tracks.push(this.props.tracks[uri])
}
}
}
return (
<div className="view playlist-view content-wrapper">
<div className="thumbnail-wrapper">
@ -178,13 +196,13 @@ class Playlist extends React.Component{
{ this.props.playlist.description ? <h2 className="description grey-text" dangerouslySetInnerHTML={{__html: this.props.playlist.description}}></h2> : null }
<ul className="details">
{ !this.props.slim_mode ? <li className="has-tooltip"><FontAwesome name={helpers.sourceIcon(this.props.params.uri )} /><span className="tooltip">{helpers.uriSource(this.props.params.uri )} playlist</span></li> : null }
{ this.props.playlist.owner && !this.props.slim_mode ? <li><Link to={'/user/'+this.props.playlist.owner.uri}>{this.props.playlist.owner.id}</Link></li> : null }
{ !this.props.slim_mode ? <li className="has-tooltip"><FontAwesome name={helpers.sourceIcon(this.props.params.uri )} /><span className="tooltip">{helpers.uriSource(this.props.params.uri)} playlist</span></li> : null }
{ this.props.playlist.owner && !this.props.slim_mode ? <li><URILink type="user" uri={this.props.playlist.owner.uri}>{this.props.playlist.owner.id}</URILink></li> : null }
{ this.props.playlist.followers ? <li>{this.props.playlist.followers.total.toLocaleString()} followers</li> : null }
{ this.props.playlist.last_modified ? <li>Edited <Dater type="ago" data={this.props.playlist.last_modified} /></li> : null }
<li>
{ this.props.playlist.tracks_total ? this.props.playlist.tracks_total : '0'} tracks,&nbsp;
{ this.props.playlist.tracks ? <Dater type="total-time" data={this.props.playlist.tracks} /> : '0 mins' }
{ this.props.playlist.tracks_total ? this.props.playlist.tracks_total : tracks.length} tracks,&nbsp;
<Dater type="total-time" data={tracks} />
</li>
</ul>
</div>
@ -192,7 +210,7 @@ class Playlist extends React.Component{
{ this.renderActions() }
<section className="list-wrapper">
{ this.props.playlist.tracks ? <TrackList uri={this.props.params.uri} className="playlist-track-list" context={context} tracks={this.props.playlist.tracks} removeTracks={ tracks_indexes => this.removeTracks(tracks_indexes) } reorderTracks={ (indexes, index) => this.reorderTracks(indexes, index) } /> : null }
<TrackList uri={this.props.params.uri} className="playlist-track-list" context={context} tracks={tracks} removeTracks={ tracks_indexes => this.removeTracks(tracks_indexes) } reorderTracks={ (indexes, index) => this.reorderTracks(indexes, index) } />
<LazyLoadListener loading={this.props.playlist.tracks_more} loadMore={ () => this.loadMore() }/>
</section>
</div>
@ -212,7 +230,8 @@ const mapStateToProps = (state, ownProps) => {
return {
slim_mode: state.ui.slim_mode,
load_queue: state.ui.load_queue,
playlist: (state.core.playlists && state.core.playlists[uri] !== undefined ? state.core.playlists[uri] : false ),
tracks: state.core.tracks,
playlist: (state.core.playlists[uri] !== undefined ? state.core.playlists[uri] : false ),
spotify_library_playlists: state.spotify.library_playlists,
local_library_playlists: state.mopidy.library_playlists,
mopidy_connected: state.mopidy.connected,

View File

@ -92,6 +92,31 @@ class Queue extends React.Component{
}
}
var tracks = [];
if (this.props.queue && this.props.tracks){
for (var i = 0; i < this.props.queue.length; i++){
var uri = this.props.queue[i];
if (this.props.tracks.hasOwnProperty(uri)){
var track = this.props.tracks[uri];
track.playing = (track.uri == this.props.current_track_uri);
tracks.push(track);
}
}
}
// Merge our metadata with each track
for (var i = 0; i < tracks.length; i++){
var track = tracks[i];
if (this.props.queue_metadata["tlid_"+track.tlid] !== undefined){
track = Object.assign(
{},
track,
this.props.queue_metadata["tlid_"+track.tlid]
);
tracks[i] = track;
}
}
var options = (
<span>
{this.props.spotify_enabled ? <button className="no-hover" onClick={e => this.props.uiActions.openModal('edit_radio')}>
@ -133,11 +158,11 @@ class Queue extends React.Component{
show_source_icon={true}
context="queue"
className="queue-track-list"
tracks={this.props.current_tracklist}
removeTracks={tracks => this.removeTracks(tracks )}
playTracks={tracks => this.playTracks(tracks )}
playTrack={track => this.playTrack(track )}
reorderTracks={(indexes, index) => this.reorderTracks(indexes, index) } />
tracks={tracks}
removeTracks={tracks => this.removeTracks(tracks)}
playTracks={tracks => this.playTracks(tracks)}
playTrack={track => this.playTrack(track)}
reorderTracks={(indexes, index) => this.reorderTracks(indexes, index)} />
</section>
</div>
@ -158,8 +183,11 @@ const mapStateToProps = (state, ownProps) => {
spotify_enabled: state.spotify.enabled,
radio: state.core.radio,
radio_enabled: (state.core.radio && state.core.radio.enabled ? true : false),
current_tracklist: state.core.current_tracklist,
current_track: (state.core.current_track !== undefined && state.core.tracks !== undefined && state.core.tracks[state.core.current_track] !== undefined ? state.core.tracks[state.core.current_track] : null)
tracks: state.core.tracks,
queue: state.core.queue,
queue_metadata: state.core.queue_metadata,
current_track_uri: state.core.current_track_uri,
current_track: (state.core.tracks[state.core.current_track_uri] !== undefined ? state.core.tracks[state.core.current_track_uri] : null)
}
}

View File

@ -6,6 +6,7 @@ import { bindActionCreators } from 'redux'
import FontAwesome from 'react-fontawesome'
import SpotifyAuthenticationFrame from '../components/SpotifyAuthenticationFrame'
import LastfmAuthenticationFrame from '../components/LastfmAuthenticationFrame'
import ConfirmationButton from '../components/ConfirmationButton'
import PusherConnectionList from '../components/PusherConnectionList'
import URISchemesList from '../components/URISchemesList'
@ -14,11 +15,13 @@ import Header from '../components/Header'
import Parallax from '../components/Parallax'
import Icon from '../components/Icon'
import Thumbnail from '../components/Thumbnail'
import URILink from '../components/URILink'
import * as coreActions from '../services/core/actions'
import * as uiActions from '../services/ui/actions'
import * as pusherActions from '../services/pusher/actions'
import * as mopidyActions from '../services/mopidy/actions'
import * as lastfmActions from '../services/lastfm/actions'
import * as spotifyActions from '../services/spotify/actions'
class Settings extends React.Component {
@ -108,21 +111,46 @@ class Settings extends React.Component {
if (user){
return (
<Link className="user" to={global.baseURL+'user/'+user.uri}>
<URILink className="user" type="user" uri={user.uri}>
<Thumbnail circle={true} size="small" images={user.images} />
<span className="user-name">
{user.display_name ? user.display_name : user.id}
{!this.props.spotify.authorization ? <span className="grey-text">&nbsp;&nbsp;(Limited access)</span> : null}
</span>
</Link>
</URILink>
)
} else {
return (
<Link className="user">
<URILink className="user">
<Thumbnail circle={true} size="small" />
<span className="user-name">
Unknown
</span>
</Link>
</URILink>
)
}
}
renderLastfmUser(){
var user = this.props.core.users["lastfm:user:"+this.props.lastfm.session.name];
if (user){
return (
<URILink className="user" type="user" uri={user.uri}>
<Thumbnail circle={true} size="small" images={user.image} />
<span className="user-name">
{user.realname ? user.realname : user.name}
</span>
</URILink>
)
} else {
return (
<URILink className="user" type="user" uri={false}>
<Thumbnail circle={true} size="small" />
<span className="user-name">
Unknown
</span>
</URILink>
)
}
}
@ -138,13 +166,11 @@ class Settings extends React.Component {
}
renderServiceStatus(service){
let colour = 'red'
let icon = 'close'
let name = service.charAt(0).toUpperCase() + service.slice(1).toLowerCase()
let text = 'Disconnected'
let tooltip = null
service = this.props[service]
if (service.connecting){
@ -156,11 +182,6 @@ class Settings extends React.Component {
colour = 'red'
text = 'Not installed'
tooltip = 'Mopidy-Spotify is not installed or enabled'
} else if (service.connected && name == 'Spotify' && !service.authorization){
icon = 'lock'
colour = 'orange'
text = 'Limited access'
tooltip = 'Authorize Iris for full Spotify functionality'
} else if (service.connected){
icon = 'check'
colour = 'green'
@ -211,10 +232,13 @@ class Settings extends React.Component {
<section className="content-wrapper">
<h4 className="underline">Services</h4>
<div className="services">
{this.renderServiceStatus('mopidy')}
{this.renderServiceStatus('pusher')}
{this.renderServiceStatus('spotify')}
{this.renderServiceStatus('lastfm')}
</div>
<h4 className="underline">System</h4>
@ -301,6 +325,7 @@ class Settings extends React.Component {
</div>
</div>
</div>
<div className="field">
<div className="name">Authorization</div>
<div className="input">
@ -310,6 +335,24 @@ class Settings extends React.Component {
</div>
</div>
<h4 className="underline">LastFM</h4>
{this.props.lastfm.session ? <div className="field current-user">
<div className="name">Current user</div>
<div className="input">
<div className="text">
{ this.renderLastfmUser() }
</div>
</div>
</div> : null}
<div className="field">
<div className="name">Authorization</div>
<div className="input">
<LastfmAuthenticationFrame />
</div>
</div>
<h4 className="underline">Advanced</h4>
<div className="field checkbox">
@ -398,6 +441,7 @@ const mapDispatchToProps = (dispatch) => {
uiActions: bindActionCreators(uiActions, dispatch),
pusherActions: bindActionCreators(pusherActions, dispatch),
mopidyActions: bindActionCreators(mopidyActions, dispatch),
lastfmActions: bindActionCreators(lastfmActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch)
}
}

View File

@ -11,6 +11,7 @@ import Thumbnail from '../components/Thumbnail'
import ArtistSentence from '../components/ArtistSentence'
import ArtistGrid from '../components/ArtistGrid'
import FollowButton from '../components/FollowButton'
import LastfmLoveButton from '../components/LastfmLoveButton'
import Dater from '../components/Dater'
import LazyLoadListener from '../components/LazyLoadListener'
import ContextMenuTrigger from '../components/ContextMenuTrigger'
@ -19,6 +20,7 @@ import * as helpers from '../helpers'
import * as uiActions from '../services/ui/actions'
import * as mopidyActions from '../services/mopidy/actions'
import * as spotifyActions from '../services/spotify/actions'
import * as lastfmActions from '../services/lastfm/actions'
import * as geniusActions from '../services/genius/actions'
class Track extends React.Component{
@ -39,20 +41,29 @@ class Track extends React.Component{
componentWillReceiveProps(nextProps){
// if our URI has changed, fetch new album
// if our URI has changed, fetch new track
if (nextProps.params.uri != this.props.params.uri){
this.loadTrack(nextProps )
this.loadTrack(nextProps)
// if mopidy has just connected AND we're a local album, go get
// if mopidy has just connected AND we're not a Spotify track, go get
} else if (!this.props.mopidy_connected && nextProps.mopidy_connected){
if (helpers.uriSource(this.props.params.uri ) != 'spotify'){
if (helpers.uriSource(this.props.params.uri) != 'spotify'){
this.loadTrack(nextProps);
}
}
// We don't have lyrics, and we have just received our artists
if (!nextProps.track.lyrics_results && !this.props.track.artists && nextProps.track.artists){
this.props.geniusActions.findTrackLyrics(nextProps.track);
// We have just received our full track info (with artists)
if (!this.props.track.artists && nextProps.track.artists){
// Ready to load LastFM
if (nextProps.lastfm_authorized){
this.props.lastfmActions.getTrack(nextProps.track);
}
// Ready to load lyrics
if (!nextProps.track.lyrics_results){
this.props.geniusActions.findTrackLyrics(nextProps.track);
}
}
}
@ -89,9 +100,18 @@ class Track extends React.Component{
break;
}
// We don't have lyrics, but the track (and artists) is already loaded
if (props.track && !props.track.lyrics_results && props.track.artists){
this.props.geniusActions.findTrackLyrics(props.track);
// We have artist info already
if (props.track && props.track.artists){
// Get the LastFM version of this track (provided we have artist info)
if (props.lastfm_authorized){
this.props.lastfmActions.getTrack(props.track);
}
// Ready for lyrics
if (props.track && !props.track.lyrics_results){
this.props.geniusActions.findTrackLyrics(props.track);
}
}
}
@ -211,7 +231,8 @@ class Track extends React.Component{
<div className="actions">
<button className="primary" onClick={e => this.play()}>Play</button>
{this.props.slim_mode ? null : <ContextMenuTrigger onTrigger={e => this.handleContextMenu(e)} />}
<LastfmLoveButton uri={this.props.params.uri} artist={this.props.track.artists[0].name} track={this.props.track.name} addText="Love" removeText="Unlove" is_loved={this.props.track.userloved} />
<ContextMenuTrigger onTrigger={e => this.handleContextMenu(e)} />
</div>
{this.renderLyricsSelector()}
@ -240,6 +261,7 @@ const mapStateToProps = (state, ownProps) => {
albums: state.core.albums,
spotify_library_albums: state.spotify.library_albums,
local_library_albums: state.mopidy.library_albums,
lastfm_authorized: state.lastfm.session,
spotify_authorized: state.spotify.authorization,
mopidy_connected: state.mopidy.connected
};
@ -250,6 +272,7 @@ const mapDispatchToProps = (dispatch) => {
uiActions: bindActionCreators(uiActions, dispatch),
uiActions: bindActionCreators(uiActions, dispatch),
mopidyActions: bindActionCreators(mopidyActions, dispatch),
lastfmActions: bindActionCreators(lastfmActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch),
geniusActions: bindActionCreators(geniusActions, dispatch)
}

View File

@ -33,18 +33,20 @@ class User extends React.Component{
loadUser(props = this.props){
if (!props.user){
this.props.spotifyActions.getUser(props.params.uri);
this.props.spotifyActions.getUser(props.params.uri, true);
this.props.spotifyActions.following(props.params.uri);
}
// We got a user, but we haven't fetched their playlists yet
if (props.user && !props.user.playlists_uris){
this.props.spotifyActions.getUserPlaylists(props.params.uri);
}
}
loadMore(){
this.props.spotifyActions.getURL(this.props.user.playlists_more, 'SPOTIFY_USER_PLAYLISTS_LOADED', this.props.params.uri);
this.props.spotifyActions.loadMore(
this.props.user.playlists_more,
{
parent_type: 'user',
parent_key: this.props.params.uri,
records_type: 'playlist'
}
);
}
isMe(){
@ -105,7 +107,7 @@ class User extends React.Component{
<section className="grid-wrapper">
<h4>Playlists</h4>
<PlaylistGrid playlists={playlists} />
<LazyLoadListener enabled={this.props.user.playlists_more} loadMore={ () => this.loadMore() }/>
<LazyLoadListener loading={this.props.user.playlists_more} loadMore={() => this.loadMore()} />
</section>
</div>
</div>
@ -120,8 +122,7 @@ const mapStateToProps = (state, ownProps) => {
spotify_authorized: state.spotify.authorization,
me: state.spotify.me,
playlists: state.core.playlists,
user: (state.core.users && state.core.users[uri] !== undefined ? state.core.users[uri] : false),
users: state.core.users
user: (state.core.users[uri] !== undefined ? state.core.users[uri] : false)
};
}

View File

@ -30,7 +30,14 @@ class DiscoverCategory extends React.Component{
}
loadMore(){
this.props.spotifyActions.getURL(this.props.category.playlists_more, 'SPOTIFY_CATEGORY_PLAYLISTS_LOADED', 'category:'+this.props.params.id );
this.props.spotifyActions.loadMore(
this.props.category.playlists_more,
null,
{
type: 'SPOTIFY_CATEGORY_PLAYLISTS_LOADED',
key: 'category:'+this.props.params.id
}
);
}
render(){

View File

@ -27,7 +27,14 @@ class DiscoverNewReleases extends React.Component{
}
loadMore(){
this.props.spotifyActions.getURL(this.props.new_releases_more, 'SPOTIFY_NEW_RELEASES_LOADED');
this.props.spotifyActions.loadMore(
this.props.new_releases_more,
null,
{
type: 'SPOTIFY_NEW_RELEASES_LOADED',
key: null
}
);
}
playAlbum(e,album){