YTMusic playlist support
- Can create and edit playlists - Can now load playlists natively
This commit is contained in:
@ -371,9 +371,7 @@ export function reorderPlaylistTracks(uri, indexes, insert_before, snapshot_id =
|
||||
insert_before,
|
||||
snapshot_id,
|
||||
};
|
||||
|
||||
case 'm3u':
|
||||
case 'gmusic':
|
||||
default:
|
||||
return {
|
||||
type: 'MOPIDY_REORDER_PLAYLIST_TRACKS',
|
||||
key: uri,
|
||||
@ -381,40 +379,22 @@ export function reorderPlaylistTracks(uri, indexes, insert_before, snapshot_id =
|
||||
range_length: range.length,
|
||||
insert_before,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'UNSUPPORTED_ACTION',
|
||||
name: 'reorderPlaylistTracks',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function savePlaylist(uri, name, description = '', is_public = false, is_collaborative = false, image = null) {
|
||||
export function savePlaylist(uri, data) {
|
||||
switch (uriSource(uri)) {
|
||||
case 'spotify':
|
||||
return {
|
||||
type: 'SPOTIFY_SAVE_PLAYLIST',
|
||||
key: uri,
|
||||
name,
|
||||
description: (description == '' ? null : description),
|
||||
image,
|
||||
is_public,
|
||||
is_collaborative,
|
||||
data,
|
||||
};
|
||||
|
||||
case 'm3u':
|
||||
case 'gmusic':
|
||||
default:
|
||||
return {
|
||||
type: 'MOPIDY_SAVE_PLAYLIST',
|
||||
key: uri,
|
||||
name,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'UNSUPPORTED_ACTION',
|
||||
name: 'savePlaylist',
|
||||
data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -432,7 +412,6 @@ export function deletePlaylist(uri) {
|
||||
switch (uriSource(uri)) {
|
||||
case 'spotify':
|
||||
return spotifyActions.following(uri, 'DELETE');
|
||||
|
||||
default:
|
||||
return mopidyActions.deletePlaylist(uri);
|
||||
}
|
||||
@ -446,20 +425,12 @@ export function removeTracksFromPlaylist(uri, tracks_indexes) {
|
||||
key: uri,
|
||||
tracks_indexes,
|
||||
};
|
||||
|
||||
case 'm3u':
|
||||
case 'gmusic':
|
||||
default:
|
||||
return {
|
||||
type: 'MOPIDY_REMOVE_PLAYLIST_TRACKS',
|
||||
key: uri,
|
||||
tracks_indexes,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'UNSUPPORTED_ACTION',
|
||||
name: 'removeTracksFromPlaylist',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -471,20 +442,12 @@ export function addTracksToPlaylist(uri, tracks_uris) {
|
||||
key: uri,
|
||||
tracks_uris,
|
||||
};
|
||||
|
||||
case 'm3u':
|
||||
case 'gmusic':
|
||||
default:
|
||||
return {
|
||||
type: 'MOPIDY_ADD_PLAYLIST_TRACKS',
|
||||
key: uri,
|
||||
tracks_uris,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'UNSUPPORTED_ACTION',
|
||||
name: 'addTracksToPlaylist',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -275,10 +275,8 @@ const CoreMiddleware = (function () {
|
||||
case 'spotify':
|
||||
store.dispatch(spotifyActions.getPlaylist(key, {}));
|
||||
break;
|
||||
case 'm3u':
|
||||
store.dispatch(mopidyActions.getPlaylist(key, {}));
|
||||
break;
|
||||
default:
|
||||
store.dispatch(mopidyActions.getPlaylist(key, {}));
|
||||
break;
|
||||
}
|
||||
next(action);
|
||||
|
||||
@ -1423,7 +1423,7 @@ const MopidyMiddleware = (function () {
|
||||
// requires a Mopidy playlist object (with updates)
|
||||
request(store, 'playlists.lookup', { uri: action.key })
|
||||
.then((response) => {
|
||||
const mopidy_playlist = { ...response, name: action.name };
|
||||
const mopidy_playlist = { ...response, ...action.data };
|
||||
|
||||
request(store, 'playlists.save', { playlist: mopidy_playlist })
|
||||
.then((response) => {
|
||||
@ -1432,9 +1432,9 @@ const MopidyMiddleware = (function () {
|
||||
// Overwrite our playlist with the response to our save
|
||||
// This is essential to get the updated URI from Mopidy
|
||||
const playlist = {
|
||||
...action.data,
|
||||
...store.getState().core.items[action.key],
|
||||
uri: response.uri,
|
||||
name: response.name,
|
||||
...response,
|
||||
};
|
||||
|
||||
// When we rename a playlist, the URI also changes to reflect the name change.
|
||||
@ -2154,134 +2154,57 @@ const MopidyMiddleware = (function () {
|
||||
case 'MOPIDY_GET_LIBRARY_PLAYLISTS': {
|
||||
store.dispatch(uiActions.startProcess(action.type, { notification: false }));
|
||||
|
||||
// Built-in playlist support works differently to other providers
|
||||
if (action.uri === 'm3u:playlists') {
|
||||
request(store, 'playlists.asList')
|
||||
.then((listResponse) => {
|
||||
const libraryPlaylists = [];
|
||||
const playlist_uris = arrayOf('uri', listResponse).filter(
|
||||
(pUri) => (pUri.indexOf('m3u') > -1),
|
||||
);
|
||||
store.dispatch(
|
||||
uiActions.updateProcess(
|
||||
action.type,
|
||||
{
|
||||
total: playlist_uris.length,
|
||||
remaining: playlist_uris.length,
|
||||
},
|
||||
),
|
||||
);
|
||||
request(store, 'playlists.asList').then((browseResponse) => {
|
||||
const allUris = arrayOf('uri', browseResponse);
|
||||
store.dispatch(
|
||||
uiActions.updateProcess(
|
||||
action.type,
|
||||
{
|
||||
total: allUris.length,
|
||||
remaining: allUris.length,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (playlist_uris.length) {
|
||||
playlist_uris.forEach((uri, index) => {
|
||||
request(store, 'playlists.lookup', { uri })
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
libraryPlaylists.push(
|
||||
formatPlaylist({
|
||||
name: response.name,
|
||||
uri: response.uri,
|
||||
can_edit: uriSource(response.uri) === 'm3u',
|
||||
last_modified: response.last_modified,
|
||||
// By not including actual tracks they will be fetched when needed. We don't
|
||||
// want these simple tracks because they don't contain duration, artist, etc.
|
||||
tracks_total: response.tracks ? response.tracks.length : null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const run = () => {
|
||||
if (allUris.length) {
|
||||
const uri = allUris.splice(0, 1)[0];
|
||||
const processor = store.getState().ui.processes[action.type];
|
||||
|
||||
store.dispatch(
|
||||
uiActions.updateProcess(
|
||||
action.type,
|
||||
{
|
||||
remaining: playlist_uris.length - index - 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (index === playlist_uris.length - 1) {
|
||||
store.dispatch(coreActions.itemsLoaded(libraryPlaylists));
|
||||
store.dispatch(coreActions.libraryLoaded({
|
||||
uri: action.uri,
|
||||
type: 'playlists',
|
||||
items_uris: arrayOf('uri', libraryPlaylists),
|
||||
}));
|
||||
store.dispatch(uiActions.processFinished(action.type));
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
store.dispatch(coreActions.libraryLoaded({
|
||||
uri: action.uri,
|
||||
type: 'playlists',
|
||||
items_uris: [],
|
||||
}));
|
||||
store.dispatch(uiActions.stopLoading('mopidy:library:playlists'));
|
||||
store.dispatch(uiActions.processFinished(action.type));
|
||||
if (processor && processor.status === 'cancelling') {
|
||||
store.dispatch(uiActions.processCancelled(action.type));
|
||||
store.dispatch(uiActions.stopLoading(action.uri));
|
||||
return;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
request(store, 'library.browse', { uri: action.uri })
|
||||
.then((browseResponse) => {
|
||||
const libraryPlaylists = [];
|
||||
store.dispatch(uiActions.updateProcess(action.type, { remaining: allUris.length }));
|
||||
|
||||
store.dispatch(
|
||||
uiActions.updateProcess(
|
||||
action.type,
|
||||
{
|
||||
total: browseResponse.length,
|
||||
remaining: browseResponse.length,
|
||||
},
|
||||
),
|
||||
);
|
||||
request(store, 'playlists.lookup', { uri }).then((lookupResponse) => {
|
||||
if (lookupResponse) {
|
||||
const playlist = formatPlaylist({
|
||||
name: lookupResponse.name,
|
||||
uri: lookupResponse.uri,
|
||||
can_edit: true, // TODO: Confirm whether some are uneditable??
|
||||
last_modified: lookupResponse.last_modified,
|
||||
// Don't include simple tracks; they don't contain duration, artist, etc.
|
||||
tracks_total: lookupResponse?.tracks?.length || null,
|
||||
});
|
||||
|
||||
if (browseResponse.length) {
|
||||
browseResponse.forEach((playlist, index) => {
|
||||
request(store, 'library.lookup', { uris: [playlist.uri] })
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
libraryPlaylists.push(
|
||||
formatPlaylist({
|
||||
name: playlist.name,
|
||||
uri: playlist.uri,
|
||||
can_edit: uriSource(playlist.uri) === 'm3u',
|
||||
last_modified: playlist.last_modified,
|
||||
tracks: formatTracks(response[playlist.uri]),
|
||||
}),
|
||||
);
|
||||
}
|
||||
store.dispatch(coreActions.itemLoaded(playlist));
|
||||
}
|
||||
run();
|
||||
});
|
||||
} else {
|
||||
store.dispatch(uiActions.processFinished(action.type));
|
||||
store.dispatch(coreActions.libraryLoaded({
|
||||
uri: action.uri,
|
||||
type: 'playlists',
|
||||
items_uris: arrayOf('uri', browseResponse),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
store.dispatch(
|
||||
uiActions.updateProcess(
|
||||
action.type,
|
||||
{
|
||||
remaining: browseResponse.length - index - 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (index === browseResponse.length - 1) {
|
||||
store.dispatch(coreActions.itemsLoaded(libraryPlaylists));
|
||||
store.dispatch(coreActions.libraryLoaded({
|
||||
uri: action.uri,
|
||||
type: 'playlists',
|
||||
items_uris: arrayOf('uri', libraryPlaylists),
|
||||
}));
|
||||
store.dispatch(uiActions.processFinished(action.type));
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
store.dispatch(coreActions.libraryLoaded({
|
||||
uri: action.uri,
|
||||
type: 'playlists',
|
||||
items_uris: [],
|
||||
}));
|
||||
store.dispatch(uiActions.stopLoading('mopidy:library:playlists'));
|
||||
store.dispatch(uiActions.processFinished(action.type));
|
||||
}
|
||||
});
|
||||
}
|
||||
run();
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@ -1237,14 +1237,8 @@ export function createPlaylist(playlist) {
|
||||
};
|
||||
}
|
||||
|
||||
export function savePlaylist(uri, name, description, is_public, is_collaborative, image) {
|
||||
export function savePlaylist(uri, { image, ...data }) {
|
||||
return (dispatch, getState) => {
|
||||
const data = {
|
||||
name,
|
||||
description,
|
||||
public: is_public,
|
||||
collaborative: is_collaborative,
|
||||
};
|
||||
const {
|
||||
spotify: {
|
||||
me: {
|
||||
|
||||
@ -121,6 +121,11 @@ const providers = {
|
||||
uri: 'jellyfin:playlists',
|
||||
title: i18n('services.jellyfin.title'),
|
||||
},
|
||||
{
|
||||
scheme: 'ytmusic:',
|
||||
uri: 'ytmusic:playlists',
|
||||
title: i18n('services.youtube.title'),
|
||||
},
|
||||
],
|
||||
albums: [
|
||||
{
|
||||
|
||||
@ -154,6 +154,18 @@ const CreatePlaylist = () => {
|
||||
<I18n path="services.mopidy.title" />
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="scheme"
|
||||
value="ytmusic"
|
||||
checked={playlist.scheme === 'ytmusic'}
|
||||
onChange={(e) => onChange({ scheme: e.target.value })}
|
||||
/>
|
||||
<span className="label">
|
||||
<I18n path="services.youtube.title" />
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
|
||||
@ -1,294 +1,192 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Modal from './Modal';
|
||||
import * as coreActions from '../../services/core/actions';
|
||||
import * as uiActions from '../../services/ui/actions';
|
||||
import * as mopidyActions from '../../services/mopidy/actions';
|
||||
import * as spotifyActions from '../../services/spotify/actions';
|
||||
import { loadPlaylist, savePlaylist } from '../../services/core/actions';
|
||||
import { closeModal, setWindowTitle } from '../../services/ui/actions';
|
||||
import { uriSource } from '../../util/helpers';
|
||||
import { i18n, I18n } from '../../locale';
|
||||
import Button from '../../components/Button';
|
||||
import { decodeUri } from '../../util/format';
|
||||
import { makeItemSelector, makeLoadingSelector } from '../../util/selectors';
|
||||
import { makeItemSelector } from '../../util/selectors';
|
||||
|
||||
class EditPlaylist extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loaded: false,
|
||||
error: null,
|
||||
name: '',
|
||||
description: '',
|
||||
image: null,
|
||||
public: false,
|
||||
collaborative: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
uri,
|
||||
playlist,
|
||||
coreActions: {
|
||||
loadPlaylist,
|
||||
},
|
||||
uiActions: {
|
||||
setWindowTitle,
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
setWindowTitle(i18n('modal.edit_playlist.title'));
|
||||
|
||||
if (playlist) {
|
||||
this.setState({
|
||||
loaded: true,
|
||||
name: playlist.name,
|
||||
description: playlist.description,
|
||||
public: (playlist.public === true),
|
||||
collaborative: (playlist.collaborative === true),
|
||||
});
|
||||
} else {
|
||||
loadPlaylist(uri);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate = ({
|
||||
playlist: prevPlaylist,
|
||||
}) => {
|
||||
const {
|
||||
uri,
|
||||
playlist,
|
||||
mopidyActions: {
|
||||
getPlaylist,
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
if (playlist !== prevPlaylist) {
|
||||
getPlaylist(uri);
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps({ playlist }, state) {
|
||||
if (playlist && !state.loaded) {
|
||||
return {
|
||||
loaded: true,
|
||||
name: playlist.name,
|
||||
description: playlist.description,
|
||||
public: (playlist.public === true),
|
||||
collaborative: (playlist.collaborative === true),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setImage = (e) => {
|
||||
const self = this;
|
||||
|
||||
// Create a file-reader to import the selected image as a base64 string
|
||||
const file_reader = new FileReader();
|
||||
|
||||
// Once the image is loaded, convert the result
|
||||
file_reader.addEventListener('load', (e) => {
|
||||
const image_base64 = e.target.result.replace('data:image/jpeg;base64,', '');
|
||||
self.setState({ image: image_base64 });
|
||||
});
|
||||
|
||||
// This calls the filereader to load the file
|
||||
file_reader.readAsDataURL(e.target.files[0]);
|
||||
}
|
||||
|
||||
savePlaylist = (e) => {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
public: isPublic,
|
||||
collaborative,
|
||||
image,
|
||||
} = this.state;
|
||||
const {
|
||||
uri,
|
||||
coreActions: {
|
||||
savePlaylist,
|
||||
},
|
||||
uiActions: {
|
||||
closeModal,
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (!name || name == '') {
|
||||
this.setState({ error: i18n('modal.edit_playlist.name_required') });
|
||||
return false;
|
||||
}
|
||||
savePlaylist(
|
||||
uri,
|
||||
name,
|
||||
description,
|
||||
isPublic,
|
||||
collaborative,
|
||||
image,
|
||||
);
|
||||
closeModal();
|
||||
return false;
|
||||
}
|
||||
|
||||
renderFields = () => {
|
||||
const {
|
||||
uri,
|
||||
} = this.props;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
public: isPublic,
|
||||
collaborative,
|
||||
image,
|
||||
} = this.state;
|
||||
|
||||
switch (uriSource(uri)) {
|
||||
case 'spotify':
|
||||
return (
|
||||
<div>
|
||||
<div className="field text">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.name" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => this.setState({ name: e.target.value })}
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field text">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.description" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => this.setState({ description: e.target.value })}
|
||||
value={description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field file">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.image.label" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="file"
|
||||
placeholder="Leave empty to keep existing image"
|
||||
onChange={(e) => this.setImage(e)}
|
||||
/>
|
||||
<div className="description">
|
||||
<I18n path="modal.edit_playlist.image.description" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field checkbox white">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.options.label" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="playlist_private"
|
||||
checked={isPublic}
|
||||
onChange={() => this.setState({ public: !isPublic })}
|
||||
/>
|
||||
<span className="label">
|
||||
<I18n path="modal.edit_playlist.options.public" />
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collaborative"
|
||||
checked={collaborative}
|
||||
onChange={() => this.setState({ collaborative: !collaborative })}
|
||||
/>
|
||||
<span className="label">
|
||||
<I18n path="modal.edit_playlist.options.collaborative" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div>
|
||||
<div className="field text">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.name" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => this.setState({ name: e.target.value })}
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render = () => {
|
||||
const {
|
||||
error,
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<Modal className="modal--edit-playlist">
|
||||
<h1>
|
||||
<I18n path="modal.edit_playlist.title" />
|
||||
</h1>
|
||||
{error ? <h3 className="red-text">{error}</h3> : null}
|
||||
<form onSubmit={(e) => this.savePlaylist(e)}>
|
||||
|
||||
{this.renderFields()}
|
||||
|
||||
<div className="actions centered-text">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
tracking={{ category: 'EditPlaylist', action: 'Submit' }}
|
||||
submit
|
||||
>
|
||||
<I18n path="actions.save" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const uri = decodeUri(ownProps.match.params.uri);
|
||||
const itemSelector = makeItemSelector(uri);
|
||||
const loadingSelector = makeLoadingSelector([`(.*)${uri}(.*)`, '^((?!contains).)*$', '^((?!tracks).)*$']);
|
||||
|
||||
return {
|
||||
uri,
|
||||
playlist: itemSelector(state),
|
||||
loading: loadingSelector(state),
|
||||
};
|
||||
const EMPTY_FORM = {
|
||||
loaded: false,
|
||||
error: null,
|
||||
name: '',
|
||||
description: '',
|
||||
image: null,
|
||||
public: false,
|
||||
collaborative: false,
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
coreActions: bindActionCreators(coreActions, dispatch),
|
||||
uiActions: bindActionCreators(uiActions, dispatch),
|
||||
mopidyActions: bindActionCreators(mopidyActions, dispatch),
|
||||
spotifyActions: bindActionCreators(spotifyActions, dispatch),
|
||||
});
|
||||
const Fields = ({
|
||||
uri,
|
||||
form,
|
||||
onChange,
|
||||
onChangeImage,
|
||||
}) => (
|
||||
<div>
|
||||
<div>
|
||||
<div className="field text">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.name" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
onChange={onChange}
|
||||
value={form.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{uriSource(uri) === 'spotify' && (
|
||||
<div>
|
||||
<div className="field text">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.description" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
onChange={onChange}
|
||||
value={form.description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field file">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.image.label" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<input
|
||||
type="file"
|
||||
placeholder="Leave empty to keep existing image"
|
||||
onChange={onChangeImage}
|
||||
/>
|
||||
<div className="description">
|
||||
<I18n path="modal.edit_playlist.image.description" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field checkbox white">
|
||||
<div className="name">
|
||||
<I18n path="modal.edit_playlist.options.label" />
|
||||
</div>
|
||||
<div className="input">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="public"
|
||||
checked={form.isPublic}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<span className="label">
|
||||
<I18n path="modal.edit_playlist.options.public" />
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collaborative"
|
||||
checked={form.collaborative}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<span className="label">
|
||||
<I18n path="modal.edit_playlist.options.collaborative" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditPlaylist);
|
||||
const EditPlaylist = () => {
|
||||
const { uri: encodedUri } = useParams();
|
||||
const uri = decodeUri(encodedUri);
|
||||
const itemSelector = makeItemSelector(uri);
|
||||
const playlist = useSelector(itemSelector);
|
||||
const dispatch = useDispatch();
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [error, setError] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setWindowTitle(i18n('modal.edit_playlist.title')));
|
||||
|
||||
if (uri && !playlist) dispatch(loadPlaylist(uri));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setForm({
|
||||
loaded: true,
|
||||
name: playlist?.name || '',
|
||||
description: playlist?.description || '',
|
||||
public: (playlist?.public === true),
|
||||
collaborative: (playlist?.collaborative === true),
|
||||
});
|
||||
}, [playlist]);
|
||||
|
||||
const onChange = ({ target: { name, type, value, checked }}) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
|
||||
const onChangeImage = (e) => {
|
||||
const file_reader = new FileReader();
|
||||
file_reader.addEventListener('load', (e) => {
|
||||
const image_base64 = e.target.result.replace('form:image/jpeg;base64,', '');
|
||||
setForm({ image: image_base64 });
|
||||
});
|
||||
file_reader.readAsDataURL(e.target.files[0]);
|
||||
}
|
||||
|
||||
const onSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (form.name == '') {
|
||||
setError(i18n('modal.edit_playlist.name_required'));
|
||||
return;
|
||||
}
|
||||
const data = uriSource(uri) === 'spotify'
|
||||
? form
|
||||
: { name: form.name };
|
||||
dispatch(savePlaylist(uri, data));
|
||||
dispatch(closeModal());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal className="modal--edit-playlist">
|
||||
<h1>
|
||||
<I18n path="modal.edit_playlist.title" />
|
||||
</h1>
|
||||
{error && <h3 className="red-text">{error}</h3>}
|
||||
<form onSubmit={onSubmit}>
|
||||
<Fields
|
||||
uri={uri}
|
||||
form={form}
|
||||
onChange={onChange}
|
||||
onChangeImage={onChangeImage}
|
||||
/>
|
||||
|
||||
<div className="actions centered-text">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
tracking={{ category: 'EditPlaylist', action: 'Submit' }}
|
||||
submit
|
||||
>
|
||||
<I18n path="actions.save" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditPlaylist;
|
||||
|
||||
@ -40,27 +40,6 @@ const Actions = ({
|
||||
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={`/modal/edit-playlist/${encodedUri}`}
|
||||
tracking={{ category: 'Playlist', action: 'Edit' }}
|
||||
>
|
||||
<I18n path="actions.edit" />
|
||||
</Button>
|
||||
<PinButton item={{ uri, name }} />
|
||||
<ContextMenuTrigger onTrigger={handleContextMenu} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'spotify':
|
||||
if (can_edit) {
|
||||
return (
|
||||
@ -111,6 +90,12 @@ const Actions = ({
|
||||
>
|
||||
<I18n path="actions.play" />
|
||||
</Button>
|
||||
<Button
|
||||
to={`/modal/edit-playlist/${encodedUri}`}
|
||||
tracking={{ category: 'Playlist', action: 'Edit' }}
|
||||
>
|
||||
<I18n path="actions.edit" />
|
||||
</Button>
|
||||
<PinButton item={{ uri, name }} />
|
||||
<ContextMenuTrigger onTrigger={handleContextMenu} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user