Sorting playlist tracks, fixes #568

This commit is contained in:
James Barnsley
2020-11-07 16:46:09 +13:00
parent 1395ed7662
commit fdd3bf3bc9
16 changed files with 681 additions and 367 deletions

View File

@ -7,6 +7,7 @@ import * as mopidyActions from '../services/mopidy/actions';
import * as uiActions from '../services/ui/actions';
import { isTouchDevice } from '../util/helpers';
import { arrayOf } from '../util/arrays';
import { i18n } from '../locale';
class TrackList extends React.Component {
constructor(props) {
@ -105,14 +106,18 @@ class TrackList extends React.Component {
);
}
handleDrop(e, track_key) {
if (this.props.dragger && this.props.dragger.active) {
// if this tracklist handles sorting, handle it
if (this.props.reorderTracks !== undefined) {
const indexes = this.props.dragger.victims_indexes;
const tracks = this.digestTracksKeys([track_key]);
return this.props.reorderTracks(indexes, tracks[0].index);
}
handleDrop = (e, track_key) => {
const {
dragger: {
active,
victims_indexes,
} = {},
reorderTracks: doReorderTracks,
} = this.props;
if (active && doReorderTracks) {
const tracks = this.digestTracksKeys([track_key]);
return doReorderTracks(victims_indexes, tracks[0].index);
}
this.touch_dragging_tracks_keys = false;
}
@ -280,42 +285,53 @@ class TrackList extends React.Component {
return false;
}
playTracks(tracks_keys = null) {
if (tracks_keys !== null) {
var selected_tracks = this.digestTracksKeys(tracks_keys);
} else {
var selected_tracks = this.digestTracksKeys();
}
const selected_tracks_indexes = arrayOf('index', selected_tracks);
playTracks = (tracks_keys = null) => {
const {
uri,
uiActions: {
createNotification,
},
mopidyActions: {
playURIs,
},
playTracks: doPlayTracks,
} = this.props;
const selected_tracks = tracks_keys !== null
? this.digestTracksKeys(tracks_keys)
: this.digestTracksKeys();
if (selected_tracks.length <= 0) {
return this.props.uiActions.createNotification({ content: 'No tracks selected', level: 'error' });
createNotification({ content: i18n('errors.nothing_selected'), level: 'error' });
return;
}
// Our parent handles playing
if (this.props.playTracks !== undefined) {
return this.props.playTracks(selected_tracks);
// Default to playing the URIs
if (doPlayTracks) {
doPlayTracks(selected_tracks);
return;
}
const selected_tracks_uris = arrayOf('uri', selected_tracks);
return this.props.mopidyActions.playURIs(selected_tracks_uris, this.props.uri);
playURIs(selected_tracks_uris, uri);
}
removeTracks() {
removeTracks = () => {
const {
uiActions: {
createNotification,
},
removeTracks: doRemoveTracks,
} = this.props;
const selected_tracks = this.digestTracksKeys();
// Our parent has a handler for this
if (this.props.removeTracks !== undefined) {
const selected_tracks_indexes = arrayOf('index', selected_tracks);
return this.props.removeTracks(selected_tracks_indexes);
// No handler? We can't really do anything then, so notify user
if (!doRemoveTracks) {
createNotification({ content: `Cannot delete ${selected_tracks.length > 1 ? 'these tracks' : 'this track'}`, level: 'error' });
return;
}
this.props.uiActions.createNotification({ content: `Cannot delete ${selected_tracks.length > 1 ? 'these tracks' : 'this track'}`, level: 'error' });
const selected_tracks_indexes = arrayOf('index', selected_tracks);
doRemoveTracks(selected_tracks_indexes);
}
/**
* Build the track key
* This is our unique reference to a track in a particular tracklist
@ -333,7 +349,6 @@ class TrackList extends React.Component {
return key;
}
/**
* Digest our selected tracks
*
@ -379,7 +394,6 @@ class TrackList extends React.Component {
return array;
}
render() {
if (!this.props.tracks || Object.prototype.toString.call(this.props.tracks) !== '[object Array]') {
return null;
@ -398,25 +412,25 @@ class TrackList extends React.Component {
const track_key = this.buildTrackKey(track, index);
track.key = track_key;
return (
<Track
show_source_icon={this.props.show_source_icon}
key={track_key}
mini_zones={this.props.slim_mode || isTouchDevice()}
track={track}
track_context={this.props.track_context}
can_sort={this.props.track_context == 'queue' || this.props.track_context == 'editable-playlist'}
selected={this.props.selected_tracks.includes(track_key)}
play_state={this.props.play_state}
dragger={this.props.dragger}
handleClick={(e) => this.handleClick(e, track_key)}
handleDoubleClick={(e) => this.handleDoubleClick(e, track_key)}
handleContextMenu={(e) => this.handleContextMenu(e, track_key)}
handleDrag={(e) => this.handleDrag(e, track_key)}
handleDrop={(e) => this.handleDrop(e, track_key)}
handleTap={(e) => this.handleTap(e, track_key)}
handleDoubleTap={(e) => this.handleDoubleTap(e, track_key)}
handleTouchDrag={(e) => this.handleTouchDrag(e, track_key)}
/>
<Track
show_source_icon={this.props.show_source_icon}
key={track_key}
mini_zones={this.props.slim_mode || isTouchDevice()}
track={track}
track_context={this.props.track_context}
can_sort={this.props.track_context == 'queue' || this.props.track_context == 'editable-playlist'}
selected={this.props.selected_tracks.includes(track_key)}
play_state={this.props.play_state}
dragger={this.props.dragger}
handleClick={(e) => this.handleClick(e, track_key)}
handleDoubleClick={(e) => this.handleDoubleClick(e, track_key)}
handleContextMenu={(e) => this.handleContextMenu(e, track_key)}
handleDrag={(e) => this.handleDrag(e, track_key)}
handleDrop={(e) => this.handleDrop(e, track_key)}
handleTap={(e) => this.handleTap(e, track_key)}
handleDoubleTap={(e) => this.handleDoubleTap(e, track_key)}
handleTouchDrag={(e) => this.handleTouchDrag(e, track_key)}
/>
);
},
)
@ -426,7 +440,7 @@ class TrackList extends React.Component {
}
}
const mapStateToProps = (state, ownProps) => ({
const mapStateToProps = (state) => ({
play_state: state.mopidy.play_state,
slim_mode: state.ui.slim_mode,
selected_tracks: state.ui.selected_tracks,

View File

@ -14,6 +14,10 @@ errors:
required: Required
unknown_error: Unknown error
need_to_be_online: You need to be online load this resource
nothing_selected: Nothing selected
cannot_reorder:
title: Cannot reorder items
description: Remove custom sort and try again
actions:
play: Play
play_all: Play all
@ -39,6 +43,9 @@ actions:
delete: Delete
remove: Remove
cancel: Cancel
created: Created %{name}
saved: Saved %{name}
deleted: Deleted %{name}
common:
popularity: Popularity
name: Name
@ -281,6 +288,15 @@ playlist:
title: Playlist
title_window: '%{name} (playlist)'
title_plural: Playlists
tracks:
title: Tracks
sort:
default: Default
sort_id: As loaded
added_at: Date added
name: Name
artist: Artist
album: Album
queue_history:
title: Playback history
search:

View File

@ -15,6 +15,7 @@ import {
formatTracks,
formatTrack,
formatSimpleObject,
injectSortId,
} from '../../util/format';
import { handleException } from './actions';
@ -284,6 +285,8 @@ const CoreMiddleware = (function () {
break;
}
// This applies our new sort order based on the origional request (rather than a response)
// This means we don't need to re-fetch the whole playlist after every sort.
case 'PLAYLIST_TRACKS_REORDERED': {
const {
key,
@ -306,9 +309,11 @@ const CoreMiddleware = (function () {
tracks.splice(insert_before, 0, tracks_to_move[i]);
}
console.log({ action, tracks: injectSortId(tracks) });
store.dispatch(coreActions.itemLoaded({
...playlist,
tracks,
tracks: injectSortId(tracks),
snapshot_id,
}));
break;

View File

@ -22,6 +22,7 @@ import {
formatArtists,
formatArtist,
formatPlaylist,
injectSortId,
} from '../../util/format';
import {
arrayOf,
@ -1177,7 +1178,7 @@ const MopidyMiddleware = (function () {
...(fullTracks.length ? fullTracks[0] : {}),
};
});
playlist.tracks = formatTracks(tracks);
playlist.tracks = injectSortId(formatTracks(tracks));
store.dispatch(coreActions.itemLoaded(playlist));
});
}
@ -1188,13 +1189,9 @@ const MopidyMiddleware = (function () {
case 'MOPIDY_ADD_PLAYLIST_TRACKS':
request(store, 'playlists.lookup', { uri: action.key })
.then((response) => {
const tracks = [];
for (let i = 0; i < action.tracks_uris.length; i++) {
tracks.push({
__model__: 'Track',
uri: action.tracks_uris[i],
});
}
const tracks = injectSortId(
action.tracks_uris.map((uri) => ({ __model__: 'Track', uri })),
);
const playlist = { ...response };
if (playlist.tracks) {
@ -1204,7 +1201,7 @@ const MopidyMiddleware = (function () {
}
request(store, 'playlists.save', { playlist })
.then((response) => {
.then(() => {
store.dispatch({
type: 'PLAYLIST_TRACKS_ADDED',
key: action.key,
@ -1288,7 +1285,7 @@ const MopidyMiddleware = (function () {
if (insert_before > range_start) insert_before -= range_length;
// collate our tracks to be moved
for (var i = 0; i < range_length; i++) {
for (let i = 0; i < range_length; i += 1) {
// add to FRONT: we work backwards to avoid screwing up our indexes
tracks_to_move.unshift(tracks[range_start + i]);
}
@ -1297,18 +1294,19 @@ const MopidyMiddleware = (function () {
tracks.splice(range_start, range_length);
// now plug them back in, in their new location
for (var i = 0; i < tracks_to_move.length; i++) {
for (let i = 0; i < tracks_to_move.length; i += 1) {
tracks.splice(insert_before, 0, tracks_to_move[i]);
}
// update playlist
playlist = { ...playlist, tracks };
request(store, 'playlists.save', { playlist })
.then((response) => {
.then(() => {
store.dispatch({
type: 'MOPIDY_RESOLVE_PLAYLIST_TRACKS',
tracks: playlist.tracks,
key: playlist.uri,
type: 'PLAYLIST_TRACKS_REORDERED',
key: action.key,
range_start,
range_length,
insert_before: action.insert_before, // We've adjusted this, so use original
});
});
});
@ -1321,7 +1319,9 @@ const MopidyMiddleware = (function () {
...formatPlaylist(response),
...action,
};
store.dispatch(uiActions.createNotification({ content: 'Created playlist' }));
store.dispatch(uiActions.createNotification({
content: i18n('actions.created', { name: i18n('playlist.title') }),
}));
store.dispatch(coreActions.addToLibrary('mopidy:library:playlists', playlist));
});
break;
@ -1329,7 +1329,9 @@ const MopidyMiddleware = (function () {
case 'MOPIDY_DELETE_PLAYLIST':
request(store, 'playlists.delete', { uri: action.uri })
.then(() => {
store.dispatch(uiActions.createNotification({ content: 'Deleted playlist' }));
store.dispatch(uiActions.createNotification({
content: i18n('actions.deleted', { name: i18n('playlist.title') }),
}));
store.dispatch(coreActions.removeFromLibrary('mopidy:library:playlists', action.uri));
});
break;

View File

@ -23,6 +23,7 @@ import {
formatAlbums,
formatImages,
formatTrack,
injectSortId,
} from '../../util/format';
import URILink from '../../components/URILink';
import { getItem } from '../../util/selectors';
@ -1328,7 +1329,7 @@ export function getPlaylist(uri, { full, forceRefetch, callbackAction } = {}) {
request(dispatch, getState, endpoint)
.then(
(response) => {
let tracks = formatTracks(response.tracks.items);
let tracks = injectSortId(formatTracks(response.tracks.items));
// convert links in description
let description = null;
@ -1358,7 +1359,7 @@ export function getPlaylist(uri, { full, forceRefetch, callbackAction } = {}) {
} else {
dispatch(coreActions.itemLoaded({
uri,
tracks,
tracks: injectSortId(tracks),
}));
if (callbackAction) {
@ -1452,10 +1453,14 @@ export function deleteTracksFromPlaylist(uri, snapshot_id, tracks_indexes) {
export function reorderPlaylistTracks(uri, range_start, range_length, insert_before, snapshot_id) {
return (dispatch, getState) => {
request(dispatch, getState, `playlists/${getFromUri('playlistid', uri)}/tracks`, 'PUT', {
uri, range_start, range_length, insert_before, snapshot_id,
})
.then(
request(
dispatch,
getState,
`playlists/${getFromUri('playlistid', uri)}/tracks`, 'PUT',
{
uri, range_start, range_length, insert_before, snapshot_id,
},
).then(
(response) => {
dispatch({
type: 'PLAYLIST_TRACKS_REORDERED',

View File

@ -929,6 +929,17 @@ const collate = function (obj, indexes = {}) {
return obj;
};
/**
* Add the array index as our sort_id.
* This allows us a source of truth for the original order of items when we have applied
* sort rules in the UI.
*
* @param {Array} array of objects to have sort_id added
*/
const injectSortId = (array) => {
return array.map((item, index) => ({ ...item, sort_id: index }));
};
export {
toJSON,
getTrackIcon,
@ -952,6 +963,7 @@ export {
formatCategories,
collate,
collateLibrary,
injectSortId,
};
export default {
@ -977,4 +989,5 @@ export default {
formatCategories,
collate,
collateLibrary,
injectSortId,
};

View File

@ -5,7 +5,6 @@ import { bindActionCreators } from 'redux';
import { Route, Switch } from 'react-router-dom';
import ErrorMessage from '../components/ErrorMessage';
import Link from '../components/Link';
import LazyLoadListener from '../components/LazyLoadListener';
import TrackList from '../components/TrackList';
import AlbumGrid from '../components/AlbumGrid';
import Thumbnail from '../components/Thumbnail';
@ -240,35 +239,37 @@ class Artist extends React.Component {
<div className="albums">
<h4>
<div><I18n path="artist.overview.albums" /></div>
<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.onChangeSort}
/>
<DropdownField
icon="filter_list"
name="Filter"
value={filter}
valueAsLabel
options={filter_options}
handleChange={this.onChangeFilter}
/>
{(sort || filter) && (
<Button
discrete
type="destructive"
size="small"
onClick={this.onResetFilters}
>
<Icon name="clear" />
<I18n path="actions.reset" />
</Button>
)}
<I18n path="artist.overview.albums" />
<div className="actions-wrapper">
<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.onChangeSort}
/>
<DropdownField
icon="filter_list"
name="Filter"
value={filter}
valueAsLabel
options={filter_options}
handleChange={this.onChangeFilter}
/>
{(sort || filter) && (
<Button
discrete
type="destructive"
size="small"
onClick={this.onResetFilters}
>
<Icon name="clear" />
<I18n path="actions.reset" />
</Button>
)}
</div>
</h4>
<section className="grid-wrapper no-top-padding">

View File

@ -99,7 +99,7 @@ class Debug extends React.Component {
<div className="view debugger-view">
<Header options={options} uiActions={uiActions}>
<Icon name="settings" type="material" />
<I18n path="debug.title" />
<I18n path="debug.title" />
</Header>
<div className="content-wrapper">

View File

@ -14,6 +14,7 @@ import Loader from '../components/Loader';
import ContextMenuTrigger from '../components/ContextMenuTrigger';
import URILink from '../components/URILink';
import Icon from '../components/Icon';
import DropdownField from '../components/Fields/DropdownField';
import * as coreActions from '../services/core/actions';
import * as uiActions from '../services/ui/actions';
import * as mopidyActions from '../services/mopidy/actions';
@ -24,6 +25,7 @@ import {
sourceIcon,
decodeMopidyUri,
} from '../util/helpers';
import { trackEvent } from '../components/Trackable';
import { i18n, I18n } from '../locale';
import { makeItemSelector, makeLoadingSelector } from '../util/selectors';
import { sortItems } from '../util/arrays';
@ -126,17 +128,61 @@ class Playlist extends React.Component {
deletePlaylist(uri);
}
onChangeSort = (value) => {
const {
sort,
sort_reverse,
uiActions: {
set,
hideContextMenu,
},
} = this.props;
let reverse = false;
if (value !== null && sort === value) {
reverse = !sort_reverse;
}
set({
playlist_tracks_sort_reverse: reverse,
playlist_tracks_sort: value,
});
hideContextMenu();
trackEvent({ category: 'Playlist', action: 'SortTracks', label: `${value} ${reverse ? 'DESC' : 'ASC'}` });
}
reorderTracks = (indexes, index) => {
const {
coreActions: {
reorderPlaylistTracks,
},
uiActions: {
createNotification,
},
playlist: {
uri,
snapshot_id,
tracks,
},
sort,
sort_reverse,
} = this.props;
if (sort !== 'sort_id') {
createNotification({
content: i18n('errors.cannot_reorder.title'),
description: i18n('errors.cannot_reorder.description'),
level: 'error',
});
return;
}
if (sort_reverse) {
const count = tracks.length - 1;
index = count - index + 1;
indexes = indexes.map((index) => count - index);
}
reorderPlaylistTracks(uri, indexes, index, snapshot_id);
}
@ -274,6 +320,8 @@ class Playlist extends React.Component {
playlist,
loading,
slim_mode,
sort,
sort_reverse,
} = this.props;
if (!playlist) {
@ -293,6 +341,42 @@ class Playlist extends React.Component {
if (playlist.can_edit) {
context = 'editable-playlist';
}
let {
playlist: {
tracks,
},
} = this.props;
if (sort && tracks) {
tracks = sortItems(tracks, sort, sort_reverse);
}
const sort_options = [
{
value: null,
label: i18n('playlist.tracks.sort.default'),
},
{
value: 'sort_id',
label: i18n('playlist.tracks.sort.sort_id'),
},
{
value: 'added_at',
label: i18n('playlist.tracks.sort.added_at'),
},
{
value: 'name',
label: i18n('playlist.tracks.sort.name'),
},
{
value: 'artists.first.name',
label: i18n('playlist.tracks.sort.artist'),
},
{
value: 'album.name',
label: i18n('playlist.tracks.sort.album'),
},
];
return (
<div className="view playlist-view content-wrapper preserve-3d">
@ -347,12 +431,27 @@ class Playlist extends React.Component {
{this.renderActions()}
<section className="list-wrapper">
<h4 className="no-bottom-margin">
<I18n path="playlist.tracks.title" />
<div className="actions-wrapper">
<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.onChangeSort}
/>
</div>
</h4>
<section className="list-wrapper no-top-padding">
<TrackList
uri={playlist.uri}
className="playlist-track-list"
track_context={context}
tracks={playlist.tracks}
tracks={tracks}
removeTracks={this.removeTracks}
reorderTracks={this.reorderTracks}
/>
@ -397,6 +496,8 @@ const mapStateToProps = (state, ownProps) => {
local_library_playlists,
spotify_authorized,
spotify_userid: (me && me.id) || null,
sort: (state.ui.playlist_tracks_sort ? state.ui.playlist_tracks_sort : null),
sort_reverse: (!!state.ui.playlist_tracks_sort_reverse),
};
};

View File

@ -72,7 +72,7 @@ class Settings extends React.Component {
}
resetAllSettings = () => {
localForage.clear(() => {
localForage.clear().then(() => {
console.debug('Cleared settings, reloading...');
window.location = '#';
window.location.reload(true);

View File

@ -472,7 +472,7 @@ class Discover extends React.Component {
<section className="col col--w70 tracks">
<h4>
<I18n path="discover.recommendations.tracks" />
<div className="pull-right">
<div className="actions-wrapper">
<ContextMenuTrigger onTrigger={this.handleContextMenu} />
<Button
type="primary"

View File

@ -279,10 +279,15 @@ h3 {
h4 {
@include feature_font();
display: flex;
font-size: 1.5rem;
margin-bottom: 16px;
font-weight: 700;
&.no-bottom-margin {
margin-bottom: 0;
}
&.underline {
padding-bottom: 4px;
border-bottom: 2px solid rgba(128, 128, 128, 0.35);
@ -292,8 +297,14 @@ h4 {
padding-top: 40px;
}
.dropdown-field {
margin-left: 10px;
.actions-wrapper {
margin-left: auto;
display: flex;
align-items: center;
.dropdown-field {
margin-left: 10px;
}
}
.button {