Files
Iris/src/js/views/Artist.js

639 lines
19 KiB
JavaScript
Executable File

import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { Route, Switch } from 'react-router-dom';
import ErrorMessage from '../components/ErrorMessage';
import Link from '../components/Link';
import TrackList from '../components/TrackList';
import AlbumGrid from '../components/AlbumGrid';
import Thumbnail from '../components/Thumbnail';
import Parallax from '../components/Parallax';
import ArtistGrid from '../components/ArtistGrid';
import RelatedArtists from '../components/RelatedArtists';
import FollowButton from '../components/Fields/FollowButton';
import ContextMenuTrigger from '../components/ContextMenuTrigger';
import DropdownField from '../components/Fields/DropdownField';
import FilterField from '../components/Fields/FilterField';
import Icon from '../components/Icon';
import Loader from '../components/Loader';
import * as coreActions from '../services/core/actions';
import * as uiActions from '../services/ui/actions';
import * as mopidyActions from '../services/mopidy/actions';
import {
uriSource,
sourceIcon,
titleCase,
} from '../util/helpers';
import { collate } from '../util/format';
import { sortItems, applyFilter, arrayOf } from '../util/arrays';
import { i18n, I18n } from '../locale';
import Button from '../components/Button';
import { trackEvent } from '../components/Trackable';
import {
makeItemSelector,
makeLoadingSelector,
} from '../util/selectors';
import { nice_number } from '../components/NiceNumber';
class Artist extends React.Component {
constructor(props) {
super(props);
this.state = {
albumsFilter: '',
tracksFilter: '',
};
}
componentDidMount() {
const {
uri,
coreActions: {
loadItem,
},
} = this.props;
this.setWindowTitle();
loadItem(uri, { full: true });
}
componentDidUpdate = ({
uri: prevUri,
artist: prevArtist,
}) => {
const {
uri,
artist,
coreActions: {
loadItem,
},
} = this.props;
if (uri !== prevUri) {
loadItem(uri, { full: true });
}
if (!prevArtist && artist) this.setWindowTitle(artist);
if (prevUri !== uri && artist) this.setWindowTitle(artist);
}
onChangeTracksFilter = (value) => {
this.onChangeFilter('tracks', value);
}
onChangeAlbumsFilter = (value) => {
this.onChangeFilter('albums', value);
}
onChangeFilter = (type, value) => {
const { uiActions: { set, hideContextMenu } } = this.props;
set({ [`artist_${type}_filter`]: value });
hideContextMenu();
trackEvent({ category: 'Artist', action: `Filter${type}`, label: value });
}
onChangeTracksSort = (value) => {
this.onChangeSort('tracks', value);
}
onChangeAlbumsSort = (value) => {
this.onChangeSort('albums', value);
}
onChangeSort = (type, value) => {
const {
[`${type}_sort`]: sort,
[`${type}_sort_reverse`]: sort_reverse,
uiActions: {
set,
hideContextMenu,
},
} = this.props;
let reverse = false;
if (value !== null && sort === value) {
reverse = !sort_reverse;
}
set({
[`artist_${type}_sort_reverse`]: reverse,
[`artist_${type}_sort`]: value,
});
hideContextMenu();
trackEvent({ category: 'Artist', action: `Sort${type}`, label: `${value} ${reverse ? 'DESC' : 'ASC'}` });
}
onPlayAll = () => {
const {
artist: {
uri,
tracks,
albums_uris,
},
uiActions: {
createNotification,
},
mopidyActions: {
playURIs,
},
} = this.props;
if ((!albums_uris || !albums_uris.length) && (!tracks || !tracks.length)) {
createNotification({ content: i18n('errors.no_results'), level: 'warning' });
return;
}
playURIs(arrayOf('uri', tracks) || albums_uris, uri);
}
setWindowTitle = (artist = this.props.artist) => {
const { uiActions: { setWindowTitle } } = this.props;
if (artist) {
setWindowTitle(i18n('artist.title_window', { name: artist.name }));
} else {
setWindowTitle(i18n('artist.title'));
}
}
handleContextMenu = (e) => {
const {
artist,
uri,
uiActions: { showContextMenu },
} = this.props;
showContextMenu({
e,
context: 'artist',
items: [artist],
uris: [uri],
tracklist_uri: uri,
});
}
inLibrary = () => {
const { uri } = this.props;
const libraryName = `${uriSource(uri)}_library_artists`;
const { [libraryName]: { items_uris } } = this.props;
return items_uris.indexOf(uri) > -1;
}
renderOverview = () => {
const {
uri,
uiActions,
artist,
albums_sort: sort,
albums_sort_reverse: sort_reverse,
filterType,
} = this.props;
const {
albumsFilter: filter,
} = this.state;
let {
tracks,
related_artists,
} = artist;
let { albums } = this.props;
if (sort && albums) {
albums = sortItems(albums, sort, sort_reverse);
}
if (filterType && albums) {
albums = applyFilter('type', filterType, albums);
}
if (filter && filter !== '') {
albums = applyFilter('name', filter, albums);
}
const sort_options = [
{
value: null,
label: i18n('artist.albums.sort.default'),
},
{
value: 'name',
label: i18n('artist.albums.sort.name'),
},
{
value: 'release_date',
label: i18n('artist.albums.sort.release_date'),
},
{
value: 'tracks',
label: i18n('artist.albums.sort.track_count'),
},
];
const filter_type_options = [
{
value: null,
label: i18n('artist.albums.filter.all'),
},
{
value: 'album',
label: i18n('artist.albums.filter.albums'),
},
{
value: 'single',
label: i18n('artist.albums.filter.singles'),
},
];
return (
<div className="body overview">
<div className={`top-tracks col col--w${related_artists && related_artists.length > 0 ? '70' : '100'}`}>
{tracks && <h4><I18n path="artist.overview.top_tracks" /></h4>}
<div className="list-wrapper">
<TrackList className="artist-track-list" uri={uri} tracks={tracks ? tracks.slice(0, 10) : []} />
</div>
</div>
<div className="col col--w5" />
{related_artists && related_artists.length > 0 && (
<div className="col col--w25 related-artists">
<h4><I18n path="artist.overview.related_artists.title" /></h4>
<div className="list-wrapper">
<RelatedArtists
artists={related_artists.slice(0, 6)}
uiActions={uiActions}
/>
</div>
<Button
to={`/artist/${encodeURIComponent(uri)}/related-artists`}
scrollTo="#sub-views-menu"
>
<I18n path="artist.overview.related_artists.more" />
</Button>
</div>
)}
<div className="cf" />
<div className="albums">
<h4>
<I18n path="artist.overview.albums" />
<div className="actions-wrapper">
<FilterField
initialValue={filter}
handleChange={(value) => this.setState({ albumsFilter: value })}
onSubmit={() => uiActions.hideContextMenu()}
/>
<DropdownField
icon="swap_vert"
name="Sort"
value={sort}
valueAsLabel
options={sort_options}
selected_icon={sort ? (sort_reverse ? 'keyboard_arrow_up' : 'keyboard_arrow_down') : null}
handleChange={this.onChangeAlbumsSort}
/>
<DropdownField
icon="filter_list"
name="Filter"
value={filterType}
valueAsLabel
options={filter_type_options}
handleChange={this.onChangeAlbumsFilter}
/>
</div>
</h4>
<section className="grid-wrapper no-top-padding">
<AlbumGrid albums={albums} />
</section>
</div>
</div>
);
}
renderTracks = () => {
const {
artist: {
uri,
},
tracks_sort: sort,
tracks_sort_reverse: sort_reverse,
} = this.props;
const { tracksFilter: filter } = this.state;
let { artist: { tracks } } = this.props;
if (sort && tracks) {
tracks = sortItems(tracks, sort, sort_reverse);
}
if (filter && filter !== '') {
tracks = applyFilter('name', filter, tracks);
}
const sort_options = [
{
value: 'name',
label: i18n('artist.tracks.sort.name'),
},
{
value: 'album',
label: i18n('artist.tracks.sort.album'),
},
];
return (
<div className="body related-artists">
<section className="list-wrapper no-top-padding">
<h4 className="no-bottom-margin">
<I18n path="artist.tracks.title" />
<div className="actions-wrapper">
<FilterField
initialValue={filter}
handleChange={(value) => this.setState({ tracksFilter: value })}
onSubmit={() => uiActions.hideContextMenu()}
/>
<DropdownField
icon="swap_vert"
name="Sort"
value={sort}
valueAsLabel
options={sort_options}
selected_icon={sort ? (sort_reverse ? 'keyboard_arrow_up' : 'keyboard_arrow_down') : null}
handleChange={this.onChangeTracksSort}
/>
</div>
</h4>
<TrackList
className="artist-track-list"
uri={uri}
tracks={tracks}
track_context="artist"
/>
</section>
</div>
);
}
renderRelatedArtists = () => {
const { artist } = this.props;
return (
<div className="body related-artists">
<section className="grid-wrapper no-top-padding">
<ArtistGrid artists={artist.related_artists} />
</section>
</div>
);
}
renderAbout = () => {
const {
artist: artistProp,
artists,
} = this.props;
const artist = collate(
artistProp,
{
artists,
},
);
const thumbnails = artist.images && Array.isArray(artist.images) && artist.images.map(
(image) => {
if (!image.huge) return null;
return (
<div className="tile thumbnail-wrapper" key={image.huge}>
<Thumbnail size="huge" canZoom fill images={image} />
</div>
);
}
);
return (
<div className="body about">
<div className="col col--w40 tiles artist-stats">
{thumbnails}
<div className="tile">
<span className="content">
<Icon type="fontawesome" name={sourceIcon(artist.uri)} />
<I18n
path="artist.about.source"
source={titleCase(uriSource(artist.uri))}
/>
</span>
</div>
{artist.followers && (
<div className="tile">
<span className="content">
<Icon type="fontawesome" name="users" />
<I18n path="specs.followers" count={nice_number(artist.followers)} />
</span>
</div>
)}
{artist.popularity && (
<div className="tile">
<span className="content">
<Icon type="fontawesome" name="fire" />
<I18n path="specs.popularity" percent={artist.popularity} />
</span>
</div>
)}
{artist.listeners && (
<div className="tile">
<span className="content">
<Icon type="fontawesome" name="headphones" />
<I18n path="specs.listeners" count={nice_number(artist.listeners)} />
</span>
</div>
)}
</div>
<div className="col col--w60 biography">
<section>
<br />
{artist.biography && (
<div className="biography-text">
<p>{artist.biography}</p>
<br />
<div className="mid_grey-text">
<I18n path="artist.about.wiki.published" date={artist.biography_publish_date} />
</div>
<div className="mid_grey-text">
<I18n path="artist.about.wiki.origin" />
<a
href={artist.biography_link}
target="_blank"
rel="noopener noreferrer"
>
{artist.biography_link}
</a>
</div>
</div>
)}
</section>
</div>
</div>
);
}
render = () => {
const {
uri,
loading,
artist,
history,
} = this.props;
if (loading) {
return <Loader body loading />;
} else if (!artist) {
return (
<ErrorMessage type="not-found" title="Not found">
<p>
<I18n path="errors.uri_not_found" uri={encodeURIComponent(uri)} />
</p>
</ErrorMessage>
);
}
const scheme = uriSource(uri);
const image = (artist.images && artist.images.length) ? artist.images[0].huge : null;
const is_spotify = (scheme === 'spotify');
return (
<div className="view artist-view preserve-3d">
<div className="intro preserve-3d">
<Parallax image={image} />
<div className="liner">
<div className="heading">
<div className="heading__thumbnail">
<Thumbnail size="medium" circle canZoom type="artist" image={image} />
</div>
<div className="heading__content">
<h1>{artist && artist.name}</h1>
<div className="actions">
<Button
type="primary"
onClick={this.onPlayAll}
tracking={{ category: 'Artist', action: 'Play' }}
>
<I18n path="actions.play" />
</Button>
{is_spotify && (
<FollowButton
uri={uri}
is_following={artist.in_library}
/>
)}
<ContextMenuTrigger className="white" onTrigger={this.handleContextMenu} />
</div>
</div>
</div>
<div className="sub-views" id="sub-views-menu">
<Link
exact
history={history}
activeClassName="sub-views__option--active"
className="sub-views__option"
to={`/artist/${encodeURIComponent(uri)}`}
scrollTo="#sub-views-menu"
>
<h4><I18n path="artist.overview.title" /></h4>
</Link>
{artist.tracks && artist.tracks.length > 10 && (
<Link
exact
history={history}
activeClassName="sub-views__option--active"
className="sub-views__option"
to={`/artist/${encodeURIComponent(uri)}/tracks`}
scrollTo="#sub-views-menu"
>
<h4><I18n path="artist.tracks.title" /></h4>
</Link>
)}
{artist.related_artists && (
<Link
exact
history={history}
activeClassName="sub-views__option--active"
className="sub-views__option"
to={`/artist/${encodeURIComponent(uri)}/related-artists`}
scrollTo="#sub-views-menu"
>
<h4><I18n path="artist.related_artists.title" /></h4>
</Link>
)}
<Link
exact
history={history}
activeClassName="sub-views__option--active"
className="sub-views__option"
to={`/artist/${encodeURIComponent(uri)}/about`}
scrollTo="#sub-views-menu"
>
<h4><I18n path="artist.about.title" /></h4>
</Link>
</div>
</div>
</div>
<div className="content-wrapper">
<Switch>
<Route exact path="/artist/:id/related-artists">
{this.renderRelatedArtists()}
</Route>
<Route exact path="/artist/:id/tracks">
{this.renderTracks()}
</Route>
<Route exact path="/artist/:id/about">
{this.renderAbout()}
</Route>
<Route exact path="/artist/:id">
{this.renderOverview()}
</Route>
</Switch>
</div>
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
const uri = decodeURIComponent(ownProps.match.params.uri);
const loadingSelector = makeLoadingSelector([`(.*)${uri}(.*)`]);
const artistSelector = makeItemSelector(uri);
const artist = artistSelector(state);
let albums = null;
if (artist && artist.albums_uris) {
const albumsSelector = makeItemSelector(artist.albums_uris);
albums = albumsSelector(state);
}
return {
uri,
artist,
albums,
loading: loadingSelector(state),
theme: state.ui.theme,
slim_mode: state.ui.slim_mode,
filterType: state.ui.artist_albums_filter,
albums_sort: state.ui.artist_albums_sort,
albums_sort_reverse: (!!state.ui.artist_albums_sort_reverse),
tracks_sort: state.ui.artist_tracks_sort,
tracks_sort_reverse: (!!state.ui.artist_tracks_sort_reverse),
spotify_authorized: state.spotify.authorization,
};
};
const mapDispatchToProps = (dispatch) => ({
coreActions: bindActionCreators(coreActions, dispatch),
uiActions: bindActionCreators(uiActions, dispatch),
mopidyActions: bindActionCreators(mopidyActions, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(Artist);