diff --git a/ui/package-lock.json b/ui/package-lock.json
index 79315b91a..ba4f8be5a 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -1693,6 +1693,18 @@
"@babel/runtime": "^7.4.4"
}
},
+ "@material-ui/lab": {
+ "version": "4.0.0-alpha.54",
+ "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.54.tgz",
+ "integrity": "sha512-BK/z+8xGPQoMtG6gWKyagCdYO1/2DzkBchvvXs2bbTVh3sbi/QQLIqWV6UA1KtMVydYVt22NwV3xltgPkaPKLg==",
+ "requires": {
+ "@babel/runtime": "^7.4.4",
+ "@material-ui/utils": "^4.9.6",
+ "clsx": "^1.0.4",
+ "prop-types": "^15.7.2",
+ "react-is": "^16.8.0"
+ }
+ },
"@material-ui/styles": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.10.0.tgz",
diff --git a/ui/package.json b/ui/package.json
index 48cfdf180..1caef17c6 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
+ "@material-ui/lab": "^4.0.0-alpha.54",
"deepmerge": "^4.2.2",
"jwt-decode": "^2.2.0",
"lodash.throttle": "^4.1.1",
diff --git a/ui/src/App.js b/ui/src/App.js
index 4b4de694d..68f725d8f 100644
--- a/ui/src/App.js
+++ b/ui/src/App.js
@@ -17,9 +17,10 @@ import { albumViewReducer } from './album/albumState'
import config from './config'
import customRoutes from './routes'
import themeReducer from './personal/themeReducer'
-import { newPlaylistDialogReducer } from './dialogs/dialogState'
+import { addToPlaylistDialogReducer } from './dialogs/dialogState'
import createAdminStore from './store/createAdminStore'
import { i18nProvider } from './i18n'
+import AddToPlaylistDialog from './dialogs/AddToPlaylistDialog'
const history = createHashHistory()
@@ -33,7 +34,7 @@ const App = () => (
queue: playQueueReducer,
albumView: albumViewReducer,
theme: themeReducer,
- newPlaylistDialog: newPlaylistDialogReducer,
+ addToPlaylistDialog: addToPlaylistDialogReducer,
},
})}
>
@@ -77,7 +78,10 @@ const App = () => (
,
,
,
+
+ // Detached components
,
+ ,
]}
diff --git a/ui/src/album/AlbumContextMenu.js b/ui/src/album/AlbumContextMenu.js
index 0047d5e3f..117f7f19b 100644
--- a/ui/src/album/AlbumContextMenu.js
+++ b/ui/src/album/AlbumContextMenu.js
@@ -1,14 +1,13 @@
import React, { useState } from 'react'
+import { useDispatch } from 'react-redux'
import IconButton from '@material-ui/core/IconButton'
import Menu from '@material-ui/core/Menu'
import MenuItem from '@material-ui/core/MenuItem'
import MoreVertIcon from '@material-ui/icons/MoreVert'
import { makeStyles } from '@material-ui/core/styles'
import { useDataProvider, useNotify, useTranslate } from 'react-admin'
-import { useDispatch } from 'react-redux'
import { addTracks, playTracks, shuffleTracks } from '../audioplayer'
-import NestedMenuItem from 'material-ui-nested-menu-item'
-import { AddToPlaylistMenu } from '../common'
+import { openAddToPlaylist } from '../dialogs/dialogState'
const useStyles = makeStyles({
icon: {
@@ -23,19 +22,23 @@ const AlbumContextMenu = ({ record, color }) => {
const translate = useTranslate()
const notify = useNotify()
const [anchorEl, setAnchorEl] = useState(null)
- const open = Boolean(anchorEl)
+
const options = {
play: {
label: translate('resources.album.actions.playAll'),
- action: (data) => playTracks(data),
+ action: playTracks,
},
addToQueue: {
label: translate('resources.album.actions.addToQueue'),
- action: (data) => addTracks(data),
+ action: addTracks,
},
shuffle: {
label: translate('resources.album.actions.shuffle'),
- action: (data) => shuffleTracks(data),
+ action: shuffleTracks,
+ },
+ addToPlaylist: {
+ label: translate('resources.song.actions.addToPlaylist'),
+ action: () => openAddToPlaylist({ albumId: record.id }),
},
}
@@ -74,6 +77,8 @@ const AlbumContextMenu = ({ record, color }) => {
e.stopPropagation()
}
+ const open = Boolean(anchorEl)
+
return (
{
{options[key].label}
))}
-
- setAnchorEl(null)}
- />
-
)
diff --git a/ui/src/common/AddToPlaylistMenu.js b/ui/src/common/AddToPlaylistMenu.js
deleted file mode 100644
index 4d993448b..000000000
--- a/ui/src/common/AddToPlaylistMenu.js
+++ /dev/null
@@ -1,117 +0,0 @@
-import React from 'react'
-import { useDispatch } from 'react-redux'
-import PropTypes from 'prop-types'
-import {
- useDataProvider,
- useGetList,
- useNotify,
- useTranslate,
-} from 'react-admin'
-import { MenuItem, Divider } from '@material-ui/core'
-import NewPlaylistIcon from '@material-ui/icons/Add'
-import { openNewPlaylist } from '../dialogs/dialogState'
-import NewPlaylistDialog from '../dialogs/NewPlaylist'
-
-export const addTracksToPlaylist = (dataProvider, selectedIds, playlistId) =>
- dataProvider
- .create('playlistTrack', {
- data: { ids: selectedIds },
- filter: { playlist_id: playlistId },
- })
- .then(() => selectedIds.length)
-
-export const addAlbumToPlaylist = (dataProvider, albumId, playlistId) =>
- dataProvider
- .getList('albumSong', {
- pagination: { page: 1, perPage: -1 },
- sort: { field: 'discNumber asc, trackNumber asc', order: 'ASC' },
- filter: { album_id: albumId },
- })
- .then((response) => response.data.map((song) => song.id))
- .then((ids) => addTracksToPlaylist(dataProvider, ids, playlistId))
-
-const AddToPlaylistMenu = React.forwardRef(
- ({ selectedIds, albumId, onClose, onItemAdded }, ref) => {
- const notify = useNotify()
- const dispatch = useDispatch()
- const translate = useTranslate()
- const dataProvider = useDataProvider()
- const { ids, data, loaded } = useGetList(
- 'playlist',
- { page: 1, perPage: -1 },
- { field: 'name', order: 'ASC' },
- {}
- )
-
- if (!loaded) {
- return
- }
-
- const addToPlaylist = (playlistId) => {
- const add = albumId
- ? addAlbumToPlaylist(dataProvider, albumId, playlistId)
- : addTracksToPlaylist(dataProvider, selectedIds, playlistId)
-
- add
- .then((len) => {
- notify('message.songsAddedToPlaylist', 'info', { smart_count: len })
- onItemAdded(playlistId)
- })
- .catch(() => {
- notify('ra.page.error', 'warning')
- })
- }
-
- const handleItemClick = (e) => {
- e.preventDefault()
- const playlistId = e.target.getAttribute('value')
- if (playlistId !== '') {
- addToPlaylist(playlistId)
- }
- e.stopPropagation()
- onClose(e)
- }
-
- const handleOpenDialog = (e) => {
- e.preventDefault()
- dispatch(openNewPlaylist(albumId, selectedIds))
- e.stopPropagation()
- onClose(e)
- }
-
- return (
- <>
-
- {ids.map((id) => (
-
- ))}
-
-
- >
- )
- }
-)
-
-AddToPlaylistMenu.propTypes = {
- selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired,
- albumId: PropTypes.string,
- onClose: PropTypes.func,
- onItemAdded: PropTypes.func,
-}
-
-AddToPlaylistMenu.defaultProps = {
- selectedIds: [],
- onClose: () => {},
- onItemAdded: () => {},
-}
-
-export default AddToPlaylistMenu
diff --git a/ui/src/common/SongContextMenu.js b/ui/src/common/SongContextMenu.js
index ad2788b53..35cf198ce 100644
--- a/ui/src/common/SongContextMenu.js
+++ b/ui/src/common/SongContextMenu.js
@@ -7,10 +7,9 @@ import { makeStyles } from '@material-ui/core/styles'
import MoreVertIcon from '@material-ui/icons/MoreVert'
import StarIcon from '@material-ui/icons/Star'
import StarBorderIcon from '@material-ui/icons/StarBorder'
-import NestedMenuItem from 'material-ui-nested-menu-item'
import { addTracks, setTrack } from '../audioplayer'
-import AddToPlaylistMenu from './AddToPlaylistMenu'
import config from '../config'
+import { openAddToPlaylist } from '../dialogs/dialogState'
const useStyles = makeStyles({
noWrap: {
@@ -41,6 +40,14 @@ const SongContextMenu = ({ record, showStar, onAddToPlaylist, visible }) => {
label: translate('resources.song.actions.addToQueue'),
action: (record) => addTracks({ [record.id]: record }),
},
+ addToPlaylist: {
+ label: translate('resources.song.actions.addToPlaylist'),
+ action: (record) =>
+ openAddToPlaylist({
+ selectedIds: [record.mediaFileId || record.id],
+ onSuccess: (id) => onAddToPlaylist(id),
+ }),
+ },
}
const handleClick = (e) => {
@@ -122,16 +129,6 @@ const SongContextMenu = ({ record, showStar, onAddToPlaylist, visible }) => {
{options[key].label}
))}
-
-
-
)
@@ -145,6 +142,7 @@ SongContextMenu.propTypes = {
}
SongContextMenu.defaultProps = {
+ onAddToPlaylist: () => {},
visible: true,
showStar: true,
addLabel: true,
diff --git a/ui/src/common/index.js b/ui/src/common/index.js
index 4c1eeca5d..458cf31b1 100644
--- a/ui/src/common/index.js
+++ b/ui/src/common/index.js
@@ -11,7 +11,6 @@ import SizeField from './SizeField'
import DocLink from './DocLink'
import List from './List'
import { SongDatagrid, SongDatagridRow } from './SongDatagrid'
-import AddToPlaylistMenu from './AddToPlaylistMenu'
import SongContextMenu from './SongContextMenu'
import QuickFilter from './QuickFilter'
@@ -32,7 +31,6 @@ export {
formatRange,
ArtistLinkField,
artistLink,
- AddToPlaylistMenu,
SongContextMenu,
QuickFilter,
}
diff --git a/ui/src/dialogs/AddToPlaylistDialog.js b/ui/src/dialogs/AddToPlaylistDialog.js
new file mode 100644
index 000000000..d4067e415
--- /dev/null
+++ b/ui/src/dialogs/AddToPlaylistDialog.js
@@ -0,0 +1,117 @@
+import React, { useState } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import {
+ useCreate,
+ useDataProvider,
+ useTranslate,
+ useNotify,
+} from 'react-admin'
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+} from '@material-ui/core'
+import { closeAddToPlaylist } from './dialogState'
+import SelectPlaylistInput from './SelectPlaylistInput'
+
+const AddToPlaylistDialog = () => {
+ const { open, albumId, selectedIds, onSuccess } = useSelector(
+ (state) => state.addToPlaylistDialog
+ )
+ const dispatch = useDispatch()
+ const translate = useTranslate()
+ const notify = useNotify()
+ const [value, setValue] = useState({})
+ const dataProvider = useDataProvider()
+ const [create] = useCreate(
+ 'playlist',
+ { name: value.name },
+ {
+ onSuccess: ({ data }) => {
+ setValue(data)
+ addToPlaylist(data.id)
+ },
+ onFailure: (error) => notify(`Error: ${error.message}`, 'warning'),
+ }
+ )
+
+ const addTracksToPlaylist = (selectedIds, playlistId) =>
+ dataProvider
+ .create('playlistTrack', {
+ data: { ids: selectedIds },
+ filter: { playlist_id: playlistId },
+ })
+ .then(() => selectedIds.length)
+
+ const addAlbumToPlaylist = (albumId, playlistId) =>
+ dataProvider
+ .getList('albumSong', {
+ pagination: { page: 1, perPage: -1 },
+ sort: { field: 'discNumber asc, trackNumber asc', order: 'ASC' },
+ filter: { album_id: albumId },
+ })
+ .then((response) => response.data.map((song) => song.id))
+ .then((ids) => addTracksToPlaylist(ids, playlistId))
+
+ const addToPlaylist = (playlistId) => {
+ const add = albumId
+ ? addAlbumToPlaylist(albumId, playlistId)
+ : addTracksToPlaylist(selectedIds, playlistId)
+
+ add
+ .then((len) => {
+ notify('message.songsAddedToPlaylist', 'info', { smart_count: len })
+ onSuccess && onSuccess(value, len)
+ })
+ .catch(() => {
+ notify('ra.page.error', 'warning')
+ })
+ }
+
+ const handleSubmit = (e) => {
+ if (value.id) {
+ addToPlaylist(value.id)
+ } else {
+ create()
+ }
+ dispatch(closeAddToPlaylist())
+ e.stopPropagation()
+ }
+
+ const handleClickClose = (e) => {
+ dispatch(closeAddToPlaylist())
+ e.stopPropagation()
+ }
+
+ const handleChange = (pls) => {
+ setValue(pls)
+ }
+
+ return (
+
+ )
+}
+
+export default AddToPlaylistDialog
diff --git a/ui/src/dialogs/NewPlaylist.js b/ui/src/dialogs/NewPlaylist.js
deleted file mode 100644
index 891c4bd08..000000000
--- a/ui/src/dialogs/NewPlaylist.js
+++ /dev/null
@@ -1,120 +0,0 @@
-import React from 'react'
-import { useDispatch, useSelector } from 'react-redux'
-import {
- useCreate,
- useDataProvider,
- useTranslate,
- useNotify,
-} from 'react-admin'
-import {
- Button,
- TextField,
- Dialog,
- DialogActions,
- DialogContent,
- DialogTitle,
-} from '@material-ui/core'
-import PropTypes from 'prop-types'
-import { closeNewPlaylist } from './dialogState'
-import {
- addAlbumToPlaylist,
- addTracksToPlaylist,
-} from '../common/AddToPlaylistMenu'
-
-const NewPlaylistDialog = ({ onCancel, onSubmit }) => {
- const { open, albumId, selectedIds } = useSelector(
- (state) => state.newPlaylistDialog
- )
- const dispatch = useDispatch()
- const translate = useTranslate()
- const notify = useNotify()
- const [value, setValue] = React.useState('')
- const dataProvider = useDataProvider()
- const [create] = useCreate(
- 'playlist',
- { name: value },
- {
- onSuccess: ({ data }) => {
- addToPlaylist(data.id)
- },
- onFailure: (error) => notify(`Error: ${error.message}`, 'warning'),
- }
- )
-
- const addToPlaylist = (playlistId) => {
- const add = albumId
- ? addAlbumToPlaylist(dataProvider, albumId, playlistId)
- : addTracksToPlaylist(dataProvider, selectedIds, playlistId)
-
- add
- .then((len) => {
- notify('message.songsAddedToPlaylist', 'info', { smart_count: len })
- onSubmit(value)
- })
- .catch(() => {
- notify('ra.page.error', 'warning')
- })
- }
-
- const handleSubmit = (e) => {
- create()
- dispatch(closeNewPlaylist())
- e.stopPropagation()
- }
-
- const handleChange = (e) => {
- setValue(e.target.value)
- }
-
- const handleClickClose = (e) => {
- onCancel(e)
- dispatch(closeNewPlaylist())
- e.stopPropagation()
- }
-
- return (
-
-
-
- )
-}
-
-NewPlaylistDialog.propTypes = {
- onCancel: PropTypes.func,
- onSubmit: PropTypes.func,
-}
-
-NewPlaylistDialog.defaultProps = {
- onCancel: () => {},
- onSubmit: () => {},
-}
-
-export default NewPlaylistDialog
diff --git a/ui/src/dialogs/SelectPlaylistInput.js b/ui/src/dialogs/SelectPlaylistInput.js
new file mode 100644
index 000000000..f7f4cb372
--- /dev/null
+++ b/ui/src/dialogs/SelectPlaylistInput.js
@@ -0,0 +1,96 @@
+/* eslint-disable no-use-before-define */
+import React from 'react'
+import TextField from '@material-ui/core/TextField'
+import Autocomplete, {
+ createFilterOptions,
+} from '@material-ui/lab/Autocomplete'
+import { useGetList, useTranslate } from 'react-admin'
+import PropTypes from 'prop-types'
+
+const filter = createFilterOptions()
+
+const SelectPlaylistInput = ({ onChange }) => {
+ const translate = useTranslate()
+ const { ids, data, loaded } = useGetList(
+ 'playlist',
+ { page: 1, perPage: -1 },
+ { field: 'name', order: 'ASC' },
+ {}
+ )
+
+ if (!loaded) {
+ return null
+ }
+
+ const options = ids.map((id) => data[id])
+
+ const handleOnChange = (event, newValue) => {
+ if (newValue == null) {
+ onChange({})
+ } else if (typeof newValue === 'string') {
+ onChange({
+ name: newValue,
+ })
+ } else if (newValue && newValue.inputValue) {
+ // Create a new value from the user input
+ onChange({
+ name: newValue.inputValue,
+ })
+ } else {
+ onChange(newValue)
+ }
+ }
+
+ return (
+ {
+ const filtered = filter(options, params)
+
+ // Suggest the creation of a new value
+ if (params.inputValue !== '') {
+ filtered.push({
+ inputValue: params.inputValue,
+ name: `Add "${params.inputValue}"`,
+ })
+ }
+
+ return filtered
+ }}
+ clearOnBlur
+ handleHomeEndKeys
+ openOnFocus
+ selectOnFocus
+ id="select-playlist-input"
+ options={options}
+ getOptionLabel={(option) => {
+ // Value selected with enter, right from the input
+ if (typeof option === 'string') {
+ return option
+ }
+ // Add "xxx" option created dynamically
+ if (option.inputValue) {
+ return option.inputValue
+ }
+ // Regular option
+ return option.name
+ }}
+ renderOption={(option) => option.name}
+ style={{ width: 300 }}
+ freeSolo
+ renderInput={(params) => (
+
+ )}
+ />
+ )
+}
+
+SelectPlaylistInput.propTypes = {
+ onChange: PropTypes.func.isRequired,
+}
+
+export default SelectPlaylistInput
diff --git a/ui/src/dialogs/dialogState.js b/ui/src/dialogs/dialogState.js
index 4d77a930b..da0e4f9f0 100644
--- a/ui/src/dialogs/dialogState.js
+++ b/ui/src/dialogs/dialogState.js
@@ -1,17 +1,18 @@
-const NEW_PLAYLIST_OPEN = 'NEW_PLAYLIST_OPEN'
-const NEW_PLAYLIST_CLOSE = 'NEW_PLAYLIST_CLOSE'
+const ADD_TO_PLAYLIST_OPEN = 'ADD_TO_PLAYLIST_OPEN'
+const ADD_TO_PLAYLIST_CLOSE = 'ADD_TO_PLAYLIST_CLOSE'
-const openNewPlaylist = (albumId, selectedIds) => ({
- type: NEW_PLAYLIST_OPEN,
+const openAddToPlaylist = ({ albumId, selectedIds, onSuccess }) => ({
+ type: ADD_TO_PLAYLIST_OPEN,
albumId,
selectedIds,
+ onSuccess,
})
-const closeNewPlaylist = () => ({
- type: NEW_PLAYLIST_CLOSE,
+const closeAddToPlaylist = () => ({
+ type: ADD_TO_PLAYLIST_CLOSE,
})
-const newPlaylistDialogReducer = (
+const addToPlaylistDialogReducer = (
previousState = {
open: false,
},
@@ -19,18 +20,19 @@ const newPlaylistDialogReducer = (
) => {
const { type } = payload
switch (type) {
- case NEW_PLAYLIST_OPEN:
+ case ADD_TO_PLAYLIST_OPEN:
return {
...previousState,
open: true,
albumId: payload.albumId,
selectedIds: payload.selectedIds,
+ onSuccess: payload.onSuccess,
}
- case NEW_PLAYLIST_CLOSE:
- return { ...previousState, open: false }
+ case ADD_TO_PLAYLIST_CLOSE:
+ return { ...previousState, open: false, onSuccess: undefined }
default:
return previousState
}
}
-export { openNewPlaylist, closeNewPlaylist, newPlaylistDialogReducer }
+export { openAddToPlaylist, closeAddToPlaylist, addToPlaylistDialogReducer }
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 274390ca5..6c7c5ed68 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -69,8 +69,8 @@
"createdAt": "Created at"
},
"actions": {
- "selectPlaylist": "Add songs to playlist:",
- "newPlaylist": "New playlist"
+ "selectPlaylist": "Select a playlist:",
+ "addToPlaylist": "New playlist"
}
},
"user": {
diff --git a/ui/src/playlist/PlaylistSongs.js b/ui/src/playlist/PlaylistSongs.js
index f85c1d763..0c4d42d7d 100644
--- a/ui/src/playlist/PlaylistSongs.js
+++ b/ui/src/playlist/PlaylistSongs.js
@@ -81,8 +81,8 @@ const PlaylistSongs = (props) => {
return
}
- const onAddToPlaylist = (playlistId) => {
- if (playlistId === props.id) {
+ const onAddToPlaylist = (pls) => {
+ if (pls.id === props.id) {
refresh()
}
}
diff --git a/ui/src/song/AddToPlaylistButton.js b/ui/src/song/AddToPlaylistButton.js
index a31117060..1bfe1c78d 100644
--- a/ui/src/song/AddToPlaylistButton.js
+++ b/ui/src/song/AddToPlaylistButton.js
@@ -1,49 +1,30 @@
import React from 'react'
+import { useDispatch } from 'react-redux'
import { Button, useTranslate, useUnselectAll } from 'react-admin'
-import { Menu } from '@material-ui/core'
import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd'
-import { AddToPlaylistMenu } from '../common'
+import { openAddToPlaylist } from '../dialogs/dialogState'
const AddToPlaylistButton = ({ resource, selectedIds, onAddToPlaylist }) => {
- const [anchorEl, setAnchorEl] = React.useState(null)
const translate = useTranslate()
+ const dispatch = useDispatch()
const unselectAll = useUnselectAll()
- const handleClick = (event) => {
- setAnchorEl(event.currentTarget)
- }
-
- const handleClose = () => {
- setAnchorEl(null)
- unselectAll(resource)
+ const handleClick = () => {
+ dispatch(
+ openAddToPlaylist({ selectedIds, onSuccess: () => unselectAll(resource) })
+ )
}
return (
- <>
-
-
- >
+
)
}