Files
Iris/src/js/views/discover/DiscoverRecommendations.js
2020-03-03 16:39:37 +13:00

557 lines
16 KiB
JavaScript
Executable File

import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import InputRange from 'react-input-range';
import AlbumGrid from '../../components/AlbumGrid';
import TrackList from '../../components/TrackList';
import Thumbnail from '../../components/Thumbnail';
import Parallax from '../../components/Parallax';
import DropdownField from '../../components/Fields/DropdownField';
import AddSeedField from '../../components/Fields/AddSeedField';
import URILink from '../../components/URILink';
import ContextMenuTrigger from '../../components/ContextMenuTrigger';
import RelatedArtists from '../../components/RelatedArtists';
import Icon from '../../components/Icon';
import * as uiActions from '../../services/ui/actions';
import * as mopidyActions from '../../services/mopidy/actions';
import * as spotifyActions from '../../services/spotify/actions';
import {
isLoading,
getFromUri,
uriType,
titleCase,
} from '../../util/helpers';
import { arrayOf } from '../../util/arrays';
class Discover extends React.Component {
constructor(props) {
super(props);
this._autocomplete_timer = false;
this.state = {
add_seed: '',
adding_seed: false,
seeds: [],
tunabilities: {
acousticness: {
enabled: false,
convert_to_decimal: true,
min: 0,
max: 100,
value: {
min: 25,
max: 75,
},
},
danceability: {
enabled: false,
convert_to_decimal: true,
min: 0,
max: 100,
value: {
min: 25,
max: 75,
},
},
energy: {
enabled: false,
convert_to_decimal: true,
min: 0,
max: 100,
value: {
min: 25,
max: 75,
},
},
instrumentalness: {
enabled: false,
convert_to_decimal: true,
min: 0,
max: 100,
value: {
min: 25,
max: 75,
},
},
key: {
enabled: false,
min: 0,
max: 11,
value: {
min: 3,
max: 8,
},
},
liveness: {
enabled: false,
convert_to_decimal: true,
min: 0,
max: 100,
value: {
min: 25,
max: 75,
},
},
loudness: {
enabled: false,
convert_to_decimal: true,
min: 0,
max: 100,
value: {
min: 25,
max: 75,
},
},
popularity: {
enabled: false,
min: 0,
max: 100,
value: {
min: 0,
max: 100,
},
},
speechiness: {
enabled: false,
convert_to_decimal: true,
description: 'The presence of spoken words in a track',
min: 0,
max: 100,
value: {
min: 25,
max: 75,
},
},
tempo: {
enabled: false,
convert_to_decimal: true,
min: 0,
max: 100,
value: {
min: 25,
max: 75,
},
},
valence: {
enabled: false,
convert_to_decimal: true,
description: 'The musical positiveness conveyed by a track',
min: 0,
max: 100,
value: {
min: 25,
max: 75,
},
},
},
};
}
componentDidMount() {
this.props.uiActions.setWindowTitle('Discover');
// We have seeds provided in the URL
if (this.props.match.params.seeds) {
this.handleURLSeeds(this.props.match.params.seeds);
}
}
componentWillReceiveProps(newProps, newState) {
// New seeds via URL
if (newProps.match.params.seeds != this.props.match.params.seeds) {
this.handleURLSeeds(newProps.match.params.seeds);
}
}
handleContextMenu(e) {
var uri = 'iris:discover';
if (this.state.seeds) {
uri += ':';
for (var i = 0; i < this.state.seeds.length; i++) {
if (i > 0) {
uri += ',';
}
uri += this.state.seeds[i].split(':').join('_');
}
}
const tracks = [];
if (this.props.recommendations.tracks_uris && this.props.tracks) {
for (var i = 0; i < this.props.recommendations.tracks_uris.length; i++) {
var uri = this.props.recommendations.tracks_uris[i];
if (this.props.tracks.hasOwnProperty(uri)) {
tracks.push(this.props.tracks[uri]);
}
}
}
const data = {
e,
context: 'track',
items: tracks,
uris: arrayOf('uri', tracks),
};
this.props.uiActions.showContextMenu(data);
}
handleURLSeeds(seeds_string = this.props.match.params.seeds) {
// Rejoin if we've had to uri-encode these as strings
// We'd need to do this if our URL has been encoded so the whole URL can become
// it's own URI (eg iris:discover:spotify_artist_1234) where we can't use ":"
const seeds = seeds_string.split('_').join(':').split(',');
for (let i = 0; i < seeds.length; i++) {
switch (uriType(seeds[i])) {
case 'artist':
this.props.spotifyActions.getArtist(seeds[i]);
break;
case 'track':
this.props.spotifyActions.getTrack(seeds[i]);
break;
}
}
this.setState({ seeds });
this.getRecommendations(seeds);
}
getRecommendations(seeds = this.state.seeds, tunabilities = this.state.tunabilities) {
if (seeds.length > 0) {
const digested_tunabilities = {};
for (const key in tunabilities) {
if (tunabilities.hasOwnProperty(key) && tunabilities[key].enabled) {
const tunability = tunabilities[key];
let { max } = tunability.value;
let { min } = tunability.value;
if (tunability.convert_to_decimal) {
max /= 100;
min /= 100;
}
digested_tunabilities[`${key}_max`] = max.toString();
digested_tunabilities[`${key}_min`] = min.toString();
}
}
this.props.spotifyActions.getRecommendations(seeds, 50, digested_tunabilities);
}
}
playTracks() {
this.props.mopidyActions.playURIs(this.props.recommendations.tracks_uris);
}
removeSeed(index) {
const { seeds } = this.state;
seeds.splice(index, 1);
this.setState({ seeds });
}
handleSelect(e, uri) {
const { seeds } = this.state;
seeds.push(uri);
this.setState({ seeds });
}
renderSeeds() {
const seeds_objects = [];
if (this.state.seeds.length > 0) {
for (let i = 0; i < this.state.seeds.length; i++) {
const uri = this.state.seeds[i];
switch (uriType(uri)) {
case 'track':
if (typeof (this.props.tracks[uri]) !== 'undefined') {
seeds_objects.push(this.props.tracks[uri]);
} else {
seeds_objects.push({
name: 'Loading...',
uri,
});
}
break;
case 'artist':
if (typeof (this.props.artists[uri]) !== 'undefined') {
seeds_objects.push(this.props.artists[uri]);
} else {
seeds_objects.push({
name: 'Loading...',
uri,
});
}
break;
case 'genre':
var name = getFromUri('genreid', uri);
seeds_objects.push({
name: (name.charAt(0).toUpperCase() + name.slice(1)).replace('-', ' '),
uri,
});
break;
}
}
}
return (
<div className="seeds">
{
seeds_objects.map((seed, index) => {
const type = uriType(seed.uri);
let images = null;
if (seed.images) {
if (type == 'artist') {
if (seed.images.length > 0) {
images = seed.images[0];
}
} else {
images = seed.images;
}
}
return (
<div className={`seed${seed.images ? ' has-thumbnail' : ''}`} key={seed.uri}>
{images ? <URILink className="thumbnail-wrapper" type={type} uri={seed.uri}><Thumbnail images={images} circle={seed.type == 'artist'} size="small" /></URILink> : null}
<div className="seed__details">
<div className="seed__label">
<span className="seed__label__text">{titleCase(type)}</span>
<Icon name="close" className="seed__label__remove" onClick={() => this.removeSeed(index)} />
</div>
<div className="seed__label__name">{seed.name}</div>
</div>
</div>
);
})
}
</div>
);
}
setTunability(name, value) {
const { tunabilities } = this.state;
tunabilities[name].value = value;
this.setState({ tunabilities });
}
toggleTunability(name) {
const { tunabilities } = this.state;
tunabilities[name].enabled = !tunabilities[name].enabled;
this.setState({ tunabilities });
}
renderTunabilities() {
const addable_tunabilities = [];
const enabled_tunabilities = [];
for (const key in this.state.tunabilities) {
if (this.state.tunabilities.hasOwnProperty(key)) {
const tunability = {
...this.state.tunabilities[key],
name: key,
};
if (tunability.enabled) {
enabled_tunabilities.push(tunability);
} else {
addable_tunabilities.push({
label: titleCase(tunability.name),
value: tunability.name,
});
}
}
}
return (
<div className="tunabilities">
{enabled_tunabilities.map((tunability) => (
<div className="field tunability range" key={tunability.name}>
<div className="tunability__label">
{titleCase(tunability.name)}
<span className="remove" onClick={(e) => this.toggleTunability(tunability.name)}>
<Icon name="close" />
</span>
</div>
<div className="tunability__input">
<InputRange
disabled={!tunability.enabled}
minValue={tunability.min}
maxValue={tunability.max}
value={tunability.value}
onChange={(value) => this.setTunability(tunability.name, value)}
/>
</div>
</div>
))}
</div>
);
}
renderResults() {
// Results not in
if (!this.props.recommendations || this.props.recommendations.albums_uris === undefined || this.props.recommendations.artists_uris === undefined) {
return <div className="content-wrapper recommendations-results" />;
}
const tracks = [];
if (this.props.recommendations.tracks_uris && this.props.tracks) {
for (var i = 0; i < this.props.recommendations.tracks_uris.length; i++) {
var uri = this.props.recommendations.tracks_uris[i];
if (this.props.tracks.hasOwnProperty(uri)) {
tracks.push(this.props.tracks[uri]);
}
}
}
const artists = [];
if (this.props.recommendations.artists_uris && this.props.artists) {
for (var i = 0; i < this.props.recommendations.artists_uris.length; i++) {
var uri = this.props.recommendations.artists_uris[i];
if (this.props.artists.hasOwnProperty(uri)) {
artists.push(this.props.artists[uri]);
}
}
}
const albums = [];
if (this.props.recommendations.albums_uris && this.props.albums) {
for (var i = 0; i < this.props.recommendations.albums_uris.length; i++) {
var uri = this.props.recommendations.albums_uris[i];
if (this.props.albums.hasOwnProperty(uri)) {
albums.push(this.props.albums[uri]);
}
}
}
// Complete records not yet in our index
if (tracks.length <= 0 && artists.length <= 0 && albums.length <= 0) {
return null;
}
var uri = 'iris:discover';
if (this.state.seeds) {
uri += ':';
for (var i = 0; i < this.state.seeds.length; i++) {
if (i > 0) {
uri += ',';
}
uri += this.state.seeds[i].split(':').join('_');
}
}
return (
<div className="content-wrapper recommendations-results">
<section className="col col--w70 tracks">
<h4>
Tracks
<div className="pull-right">
<ContextMenuTrigger onTrigger={(e) => this.handleContextMenu(e)} />
<button className="button button--primary" onClick={(e) => this.playTracks(e)}>Play all</button>
</div>
</h4>
<TrackList className="discover-track-list" uri={uri} tracks={tracks} />
</section>
<div className="col col--w5" />
<div className="col col--w25 others">
<section>
<h4>Artists</h4>
<RelatedArtists artists={artists} />
</section>
<br />
<br />
<section>
<h4>Albums</h4>
<AlbumGrid className="grid--mini" albums={albums} />
</section>
</div>
</div>
);
}
render() {
const is_loading = isLoading(this.props.load_queue, ['spotify_recommendations']);
const addable_tunabilities = [];
for (const key in this.state.tunabilities) {
if (this.state.tunabilities.hasOwnProperty(key)) {
const tunability = {
...this.state.tunabilities[key],
name: key,
};
if (!tunability.enabled) {
addable_tunabilities.push({
label: titleCase(tunability.name),
value: tunability.name,
});
}
}
}
return (
<div className="view discover-view preserve-3d">
<div className="intro preserve-3d">
{this.props.theme == 'dark' && <Parallax image="/iris/assets/backgrounds/discover.jpg" />}
<div className="intro__liner">
<h1>Explore new music</h1>
<h2>Add seeds and musical properties below to build your sound</h2>
<div className="intro__parameters">
{this.renderSeeds()}
{this.renderTunabilities()}
{this.state.seeds.length > 5 ? <p className="message error">Too many seeds! You can use up to a total of 5 seed tracks, artists and genres.</p> : null}
</div>
<div className="intro__actions">
<AddSeedField onSelect={(e, uri) => this.handleSelect(e, uri)} />
<DropdownField className="add-properties" name="Properties" options={addable_tunabilities} no_status_icon button="default" handleChange={(val) => { this.toggleTunability(val); }} />
<div className="intro__actions__separator" />
<span className={`submit button button--primary button--large${is_loading ? ' button--working' : ''}`} onClick={(e) => this.getRecommendations()}>
<Icon name="explore" />
&nbsp;
Find recommendations
</span>
</div>
</div>
</div>
{this.renderResults()}
</div>
);
}
}
const mapStateToProps = (state, ownProps) => ({
theme: state.ui.theme,
albums: state.core.albums,
artists: state.core.artists,
tracks: state.core.tracks,
genres: (state.core.genres ? state.core.genres : []),
authorized: state.spotify.authorization,
load_queue: state.ui.load_queue,
quick_search_results: (state.spotify.quick_search_results ? state.spotify.quick_search_results : { artists: [], tracks: [] }),
recommendations: (state.spotify.recommendations ? state.spotify.recommendations : {}),
});
const mapDispatchToProps = (dispatch) => ({
uiActions: bindActionCreators(uiActions, dispatch),
mopidyActions: bindActionCreators(mopidyActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(Discover);