Playlist now 100% functional

This commit is contained in:
James Barnsley
2021-08-14 12:38:14 +12:00
parent cd39c3bafa
commit 3c055724b6
2 changed files with 285 additions and 417 deletions

View File

@ -51,9 +51,7 @@ const Artist = ({
useEffect(
() => {
if (uri) {
loadArtist(uri, { full: true });
}
if (uri) loadArtist(uri, { full: true });
},
[uri],
);
@ -81,15 +79,13 @@ const Artist = ({
playURIs(arrayOf('uri', tracks) || albums_uris, uri);
}
const handleContextMenu = (e) => {
showContextMenu({
e,
context: 'artist',
items: [artist],
uris: [uri],
tracklist_uri: uri,
});
}
const handleContextMenu = (e) => showContextMenu({
e,
context: 'artist',
items: [artist],
uris: [uri],
tracklist_uri: uri,
});
if (loading) {
return <Loader body loading />;

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import ErrorMessage from '../components/ErrorMessage';
@ -20,7 +20,6 @@ import * as uiActions from '../services/ui/actions';
import * as mopidyActions from '../services/mopidy/actions';
import * as spotifyActions from '../services/spotify/actions';
import { uriSource } from '../util/helpers';
import { trackEvent } from '../components/Trackable';
import { i18n, I18n } from '../locale';
import { makeItemSelector, makeLoadingSelector, getSortSelector } from '../util/selectors';
import { sortItems, applyFilter } from '../util/arrays';
@ -28,126 +27,158 @@ import { decodeUri, encodeUri } from '../util/format';
const SORT_KEY = 'playlist_tracks';
class Playlist extends React.Component {
constructor(props) {
super(props);
const Actions = ({
encodedUri,
playlist: {
uri,
can_edit,
name,
in_library,
},
onPlay,
handleContextMenu,
}) => {
switch (uriSource(uri)) {
case 'm3u':
return (
<div className="actions">
<Button
type="primary"
onClick={onPlay}
tracking={{ category: 'Playlist', action: 'Play' }}
>
<I18n path="actions.play" />
</Button>
<Button
to={`/playlist/${encodedUri}/edit`}
tracking={{ category: 'Playlist', action: 'Edit' }}
>
<I18n path="actions.edit" />
</Button>
<PinButton item={{ uri, name }} />
<ContextMenuTrigger onTrigger={handleContextMenu} />
</div>
);
let { uri } = props;
case 'spotify':
if (can_edit) {
return (
<div className="actions">
<Button
type="primary"
onClick={onPlay}
tracking={{ category: 'Playlist', action: 'Play' }}
>
<I18n path="actions.play" />
</Button>
<Button
to={`/playlist/${encodedUri}/edit`}
tracking={{ category: 'Playlist', action: 'Edit' }}
>
<I18n path="actions.edit" />
</Button>
<PinButton item={{ uri, name }} />
<ContextMenuTrigger onTrigger={handleContextMenu} />
</div>
);
}
return (
<div className="actions">
<Button
type="primary"
onClick={onPlay}
tracking={{ category: 'Playlist', action: 'Play' }}
>
<I18n path="actions.play" />
</Button>
<FollowButton
uri={uri}
is_following={in_library}
/>
<PinButton item={{ uri, name }} />
<ContextMenuTrigger onTrigger={handleContextMenu} />
</div>
);
this.state = {
filter: '',
};
default:
return (
<div className="actions">
<Button
type="primary"
onClick={onPlay}
tracking={{ category: 'Playlist', action: 'Play' }}
>
<I18n path="actions.play" />
</Button>
<PinButton item={{ uri, name }} />
<ContextMenuTrigger onTrigger={handleContextMenu} />
</div>
);
}
}
// Spotify upgraded their playlists URI to remove user component (Sept 2018)
// We accept the old format, and redirect to the new one
if (uri.includes('spotify:user:')) {
uri = uri.replace(/spotify:user:([^:]*?):/i, 'spotify:');
props.history.push(`/playlist/${encodeUri(uri)}`);
const Playlist = ({
loading,
loading_tracks,
slim_mode,
history,
uri,
encodedUri,
name,
playlist: playlistProp,
sortField,
sortReverse,
coreActions: {
loadPlaylist,
reorderPlaylistTracks,
removeTracksFromPlaylist,
addPinned,
removePinned,
},
uiActions: {
setSort,
setWindowTitle,
showContextMenu,
hideContextMenu,
createNotification,
},
mopidyActions: {
playPlaylist,
},
}) => {
const [playlist, setPlaylist] = useState({});
const [filter, setFilter] = useState('');
useEffect(
() => {
if (uri) loadPlaylist(uri, { full: true, name });
},
[uri],
);
useEffect(() => {
if (playlistProp && playlist && playlistProp.moved_to !== playlist.moved_to) {
history.push(`/playlist/${encodeUri(playlistProp.moved_to)}`);
}
}
}, [playlistProp])
componentDidMount() {
const {
coreActions: {
loadPlaylist,
},
uri,
name,
} = this.props;
this.setWindowTitle();
setTimeout(() => loadPlaylist(uri, { full: true, name }), 1);
}
componentDidUpdate = ({
uri: prevUri,
playlist: prevPlaylist,
}) => {
const {
uri,
name,
playlist,
coreActions: {
loadPlaylist,
},
history: {
push,
},
} = this.props;
if (prevPlaylist && playlist && prevPlaylist.moved_to !== playlist.moved_to) {
push(`/playlist/${encodeUri(playlist.moved_to)}`);
useEffect(() => {
if (playlistProp) {
setWindowTitle(i18n('playlist.title_window', { name: playlistProp.name }));
} else {
setWindowTitle(i18n('playlist.title'));
}
setPlaylist(playlistProp);
}, [playlistProp]);
if (uri !== prevUri) {
loadPlaylist(uri, { full: true, name });
}
if (!prevPlaylist && playlist) this.setWindowTitle(playlist);
if (prevUri !== uri && playlist) this.setWindowTitle(playlist);
}
setWindowTitle = (playlist = this.props.playlist) => {
const { uiActions: { setWindowTitle } } = this.props;
setWindowTitle(
playlist ? i18n('playlist.title_window', { name: playlist.name }) : i18n('playlist.title'),
);
}
handleContextMenu = (e) => {
const {
uiActions: {
showContextMenu,
},
playlist,
uri,
name,
} = this.props;
showContextMenu({
e,
context: 'playlist',
items: [{ name, ...playlist }],
uris: [uri],
});
}
play = () => {
const {
mopidyActions: {
playPlaylist,
},
playlist: {
uri,
},
} = this.props;
playPlaylist(uri);
}
// TODO: Once deletion occurs, remove playlist from global playlists list
delete = () => {
const {
mopidyActions: {
deletePlaylist,
},
playlist: {
uri,
},
} = this.props;
deletePlaylist(uri);
}
onChangeSort = (field) => {
const {
sortField,
sortReverse,
uiActions: {
setSort,
hideContextMenu,
},
} = this.props;
const handleContextMenu = (e) => showContextMenu({
e,
context: 'playlist',
items: [{ name, ...playlist }],
uris: [uri],
});
const onChangeSort = (field) => {
let reverse = false;
if (field !== null && sortField === field) {
reverse = !sortReverse;
@ -155,25 +186,12 @@ class Playlist extends React.Component {
setSort(SORT_KEY, field, reverse);
hideContextMenu();
}
};
reorderTracks = (indexes, index) => {
const {
coreActions: {
reorderPlaylistTracks,
},
uiActions: {
createNotification,
},
playlist: {
uri,
snapshot_id,
tracks,
},
sortField,
sortReverse,
} = this.props;
const { filter } = this.state;
const onPlay = () => playPlaylist(uri);
const reorderTracks = (indexes, index) => {
const { snapshot_id, tracks } = playlist;
if (sortField !== 'sort_id' || filter !== '') {
createNotification({
@ -193,286 +211,140 @@ class Playlist extends React.Component {
reorderPlaylistTracks(uri, indexes, index, snapshot_id);
}
removeTracks = (tracks_indexes) => {
const {
coreActions: {
removeTracksFromPlaylist,
},
playlist: {
uri,
},
} = this.props;
const removeTracks = (tracks_indexes) => {
removeTracksFromPlaylist(uri, tracks_indexes);
}
inLibrary = () => {
const { uri } = this.props;
const libraryName = `${uriSource(uri)}_library_playlists`;
const { [libraryName]: library } = this.props;
if (!library) return false;
return library.indexOf(uri) > -1;
if (loading) {
return <Loader body loading />;
}
togglePinned = () => {
const {
uri,
coreActions: {
addPinned,
removePinned,
},
} = this.props;
if (this.isPinned()) {
removePinned(uri);
} else {
addPinned(uri);
}
}
renderActions = () => {
const {
uri,
encodedUri,
playlist: {
can_edit,
name,
in_library,
},
} = this.props;
switch (uriSource(uri)) {
case 'm3u':
return (
<div className="actions">
<Button
type="primary"
onClick={this.play}
tracking={{ category: 'Playlist', action: 'Play' }}
>
<I18n path="actions.play" />
</Button>
<Button
to={`/playlist/${encodedUri}/edit`}
tracking={{ category: 'Playlist', action: 'Edit' }}
>
<I18n path="actions.edit" />
</Button>
<PinButton item={{ uri, name }} />
<ContextMenuTrigger onTrigger={this.handleContextMenu} />
</div>
);
case 'spotify':
if (can_edit) {
return (
<div className="actions">
<Button
type="primary"
onClick={this.play}
tracking={{ category: 'Playlist', action: 'Play' }}
>
<I18n path="actions.play" />
</Button>
<Button
to={`/playlist/${encodedUri}/edit`}
tracking={{ category: 'Playlist', action: 'Edit' }}
>
<I18n path="actions.edit" />
</Button>
<PinButton item={{ uri, name }} />
<ContextMenuTrigger onTrigger={this.handleContextMenu} />
</div>
);
}
return (
<div className="actions">
<Button
type="primary"
onClick={this.play}
tracking={{ category: 'Playlist', action: 'Play' }}
>
<I18n path="actions.play" />
</Button>
<FollowButton
uri={uri}
is_following={in_library}
/>
<PinButton item={{ uri, name }} />
<ContextMenuTrigger onTrigger={this.handleContextMenu} />
</div>
);
default:
return (
<div className="actions">
<Button
type="primary"
onClick={this.play}
tracking={{ category: 'Playlist', action: 'Play' }}
>
<I18n path="actions.play" />
</Button>
<PinButton item={{ uri, name }} />
<ContextMenuTrigger onTrigger={this.handleContextMenu} />
</div>
);
}
}
render = () => {
const {
uri,
playlist,
loading,
loading_tracks,
slim_mode,
sortField,
sortReverse,
} = this.props;
const {
filter,
} = this.state;
if (loading) {
return <Loader body loading />;
}
if (!playlist) {
return (
<ErrorMessage type="not-found" title="Not found">
<p>
<I18n path="errors.uri_not_found" uri={uri} />
</p>
</ErrorMessage>
);
}
let context = 'playlist';
if (playlist.can_edit) {
context = 'editable-playlist';
}
let {
playlist: {
tracks,
},
} = this.props;
if (sortField && tracks) {
tracks = sortItems(tracks, sortField, sortReverse);
}
if (filter && filter !== '') {
tracks = applyFilter('name', filter, tracks);
}
const sort_options = [
{
value: 'sort_id',
label: i18n('playlist.tracks.sort.sort_id'),
},
{
value: 'name',
label: i18n('playlist.tracks.sort.name'),
},
{
value: 'artist',
label: i18n('playlist.tracks.sort.artist'),
},
{
value: 'album',
label: i18n('playlist.tracks.sort.album'),
},
];
if (!playlist) {
return (
<div className="view playlist-view content-wrapper preserve-3d">
<div className="thumbnail-wrapper">
<Thumbnail size="large" glow canZoom images={playlist.images} type="playlist" />
</div>
<div className="title">
<h1>{playlist.name}</h1>
{playlist.description && (
<h2
className="description"
dangerouslySetInnerHTML={{ __html: playlist.description }}
/>
)}
<ul className="details details--one-line">
{!slim_mode && (
<li className="source">
<SourceIcon uri={playlist.uri} />
</li>
)}
{playlist.user && (
<li>
<URILink
type="user"
uri={playlist.user.uri}
>
{playlist.user.name}
</URILink>
</li>
)}
<li>
<I18n path="specs.tracks" count={playlist.tracks ? playlist.tracks.length : 0} />
</li>
{!slim_mode && playlist.tracks && playlist.tracks_total > 0 && (
<li><Dater type="total-time" data={playlist.tracks} /></li>
)}
{!slim_mode && playlist.followers !== undefined && (
<li>
<I18n path="specs.followers" count={nice_number(playlist.followers)} />
</li>
)}
{!slim_mode && playlist.last_modified_date && (
<li>
<I18n path="specs.edited" date={dater('ago', playlist.last_modified_date)} />
</li>
)}
</ul>
</div>
{this.renderActions()}
<h4 className="no-bottom-margin">
<I18n path="playlist.tracks.title" />
{loading_tracks && <Loader loading mini />}
<div className="actions-wrapper">
<FilterField
initialValue={filter}
handleChange={(value) => this.setState({ filter: value })}
onSubmit={() => uiActions.hideContextMenu()}
/>
<DropdownField
icon="swap_vert"
name="Sort"
value={sortField}
valueAsLabel
options={sort_options}
selected_icon={sortField ? (sortReverse ? 'keyboard_arrow_up' : 'keyboard_arrow_down') : null}
handleChange={this.onChangeSort}
/>
</div>
</h4>
<section className="list-wrapper no-top-padding">
<TrackList
uri={playlist.uri}
className="playlist-track-list"
track_context={context}
tracks={tracks}
removeTracks={this.removeTracks}
reorderTracks={this.reorderTracks}
/>
</section>
</div>
<ErrorMessage type="not-found" title="Not found">
<p>
<I18n path="errors.uri_not_found" uri={uri} />
</p>
</ErrorMessage>
);
}
let context = 'playlist';
if (playlist.can_edit) context = 'editable-playlist';
let tracks = playlist?.tracks || [];
if (sortField && tracks) tracks = sortItems(tracks, sortField, sortReverse);
if (filter && filter !== '') tracks = applyFilter('name', filter, tracks);
const sort_options = [
{
value: 'sort_id',
label: i18n('playlist.tracks.sort.sort_id'),
},
{
value: 'name',
label: i18n('playlist.tracks.sort.name'),
},
{
value: 'artist',
label: i18n('playlist.tracks.sort.artist'),
},
{
value: 'album',
label: i18n('playlist.tracks.sort.album'),
},
];
return (
<div className="view playlist-view content-wrapper preserve-3d">
<div className="thumbnail-wrapper">
<Thumbnail size="large" glow canZoom images={playlist.images} type="playlist" />
</div>
<div className="title">
<h1>{playlist.name}</h1>
{playlist.description && (
<h2
className="description"
dangerouslySetInnerHTML={{ __html: playlist.description }}
/>
)}
<ul className="details details--one-line">
{!slim_mode && (
<li className="source">
<SourceIcon uri={playlist.uri} />
</li>
)}
{playlist.user && (
<li>
<URILink
type="user"
uri={playlist.user.uri}
>
{playlist.user.name}
</URILink>
</li>
)}
<li>
<I18n path="specs.tracks" count={playlist.tracks ? playlist.tracks.length : 0} />
</li>
{!slim_mode && playlist.tracks && playlist.tracks_total > 0 && (
<li><Dater type="total-time" data={playlist.tracks} /></li>
)}
{!slim_mode && playlist.followers !== undefined && (
<li>
<I18n path="specs.followers" count={nice_number(playlist.followers)} />
</li>
)}
{!slim_mode && playlist.last_modified_date && (
<li>
<I18n path="specs.edited" date={dater('ago', playlist.last_modified_date)} />
</li>
)}
</ul>
</div>
<Actions
encodedUri={encodedUri}
playlist={playlist}
onPlay={onPlay}
handleContextMenu={handleContextMenu}
/>
<h4 className="no-bottom-margin">
<I18n path="playlist.tracks.title" />
{loading_tracks && <Loader loading mini />}
<div className="actions-wrapper">
<FilterField
initialValue={filter}
handleChange={setFilter}
onSubmit={hideContextMenu}
/>
<DropdownField
icon="swap_vert"
name="Sort"
value={sortField}
valueAsLabel
options={sort_options}
selected_icon={sortField ? (sortReverse ? 'keyboard_arrow_up' : 'keyboard_arrow_down') : null}
handleChange={onChangeSort}
/>
</div>
</h4>
<section className="list-wrapper no-top-padding">
<TrackList
uri={playlist.uri}
className="playlist-track-list"
track_context={context}
tracks={tracks}
removeTracks={removeTracks}
reorderTracks={reorderTracks}
/>
</section>
</div>
);
}
const mapStateToProps = (state, ownProps) => {