From e1773adb3a4602cc92d3e1fe4aa71dafa52f2dcc Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 29 Sep 2021 01:49:43 -0700 Subject: [PATCH] Update list-view for playlist, artist, album - Set proper single/double-click actions - Add context menu actions --- src/api/api.ts | 5 + src/components/library/AlbumList.tsx | 13 ++ src/components/library/AlbumView.tsx | 2 +- src/components/library/ArtistView.tsx | 2 +- src/components/player/NowPlayingMiniView.tsx | 1 + src/components/player/NowPlayingView.tsx | 1 + src/components/playlist/PlaylistList.tsx | 38 +++- src/components/playlist/PlaylistView.tsx | 1 + src/components/shared/ContextMenu.tsx | 198 +++++++++++++++---- src/components/starred/StarredView.tsx | 9 +- src/components/viewtypes/ListViewTable.tsx | 2 +- src/redux/miscSlice.ts | 1 + 12 files changed, 224 insertions(+), 49 deletions(-) diff --git a/src/api/api.ts b/src/api/api.ts index 5669975..1014cee 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -148,6 +148,7 @@ export const getPlaylists = async (sortBy: string) => { ...playlist, name: playlist.name, image: playlist.songCount > 0 ? getCoverArtUrl(playlist) : 'img/placeholder.jpg', + type: 'playlist', uniqueId: nanoid(), })); }; @@ -161,6 +162,7 @@ export const getPlaylist = async (id: string) => { ...entry, streamUrl: getStreamUrl(entry.id), image: getCoverArtUrl(entry), + type: 'music', index, uniqueId: nanoid(), })), @@ -323,6 +325,7 @@ export const getAlbum = async (id: string, coverArtSize = 150) => { ...entry, streamUrl: getStreamUrl(entry.id), image: getCoverArtUrl(entry, coverArtSize), + type: 'music', starred: entry.starred || undefined, index, uniqueId: nanoid(), @@ -375,9 +378,11 @@ export const getArtist = async (id: string, coverArtSize = 150) => { return { ...data.artist, image: getCoverArtUrl(data.artist, coverArtSize), + type: 'artist', album: (data.artist.album || []).map((entry: any, index: any) => ({ ...entry, albumId: entry.id, + type: 'album', image: getCoverArtUrl(entry, coverArtSize), starred: entry.starred || undefined, index, diff --git a/src/components/library/AlbumList.tsx b/src/components/library/AlbumList.tsx index 4c00595..82913f2 100644 --- a/src/components/library/AlbumList.tsx +++ b/src/components/library/AlbumList.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import settings from 'electron-settings'; import { ButtonToolbar } from 'rsuite'; import { useQuery, useQueryClient } from 'react-query'; +import { useHistory } from 'react-router'; import GridViewType from '../viewtypes/GridViewType'; import ListViewType from '../viewtypes/ListViewType'; import useSearchQuery from '../../hooks/useSearchQuery'; @@ -14,6 +15,7 @@ import { toggleSelected, setRangeSelected, toggleRangeSelected, + clearSelected, } from '../../redux/multiSelectSlice'; import { StyledInputPicker } from '../shared/styled'; import { RefreshButton } from '../shared/ToolbarButtons'; @@ -29,6 +31,7 @@ const ALBUM_SORT_TYPES = [ const AlbumList = () => { const dispatch = useAppDispatch(); + const history = useHistory(); const queryClient = useQueryClient(); const [isRefreshing, setIsRefreshing] = useState(false); const [sortBy, setSortBy] = useState('random'); @@ -65,6 +68,14 @@ const AlbumList = () => { } }; + const handleRowDoubleClick = (rowData: any) => { + window.clearTimeout(timeout); + timeout = null; + + dispatch(clearSelected()); + history.push(`/library/album/${rowData.id}`); + }; + const handleRefresh = async () => { setIsRefreshing(true); await queryClient.refetchQueries(['albumList'], { active: true }); @@ -116,6 +127,7 @@ const AlbumList = () => { rowHeight={Number(settings.getSync('albumListRowHeight'))} fontSize={settings.getSync('albumListFontSize')} handleRowClick={handleRowClick} + handleRowDoubleClick={handleRowDoubleClick} cacheImages={{ enabled: settings.getSync('cacheImages'), cacheType: 'album', @@ -123,6 +135,7 @@ const AlbumList = () => { }} listType="album" virtualized + disabledContextMenuOptions={['moveSelectedTo', 'removeFromCurrent', 'deletePlaylist']} /> )} diff --git a/src/components/library/AlbumView.tsx b/src/components/library/AlbumView.tsx index 2410356..f827d8e 100644 --- a/src/components/library/AlbumView.tsx +++ b/src/components/library/AlbumView.tsx @@ -197,7 +197,7 @@ const AlbumView = ({ ...rest }: any) => { }} listType="music" isModal={rest.isModal} - disabledContextMenuOptions={['removeFromCurrent', 'moveSelectedTo']} + disabledContextMenuOptions={['removeFromCurrent', 'moveSelectedTo', 'deletePlaylist']} /> ); diff --git a/src/components/library/ArtistView.tsx b/src/components/library/ArtistView.tsx index 3b3984d..a017389 100644 --- a/src/components/library/ArtistView.tsx +++ b/src/components/library/ArtistView.tsx @@ -190,7 +190,7 @@ const ArtistView = ({ ...rest }: any) => { }} listType="album" isModal={rest.isModal} - disabledContextMenuOptions={['removeFromCurrent', 'moveSelectedTo']} + disabledContextMenuOptions={['removeFromCurrent', 'moveSelectedTo', 'deletePlaylist']} /> )} diff --git a/src/components/player/NowPlayingMiniView.tsx b/src/components/player/NowPlayingMiniView.tsx index 2fe7c9d..55419bf 100644 --- a/src/components/player/NowPlayingMiniView.tsx +++ b/src/components/player/NowPlayingMiniView.tsx @@ -209,6 +209,7 @@ const NowPlayingMiniView = () => { miniView nowPlaying dnd + disabledContextMenuOptions={['deletePlaylist']} /> diff --git a/src/components/player/NowPlayingView.tsx b/src/components/player/NowPlayingView.tsx index 8223320..92603d1 100644 --- a/src/components/player/NowPlayingView.tsx +++ b/src/components/player/NowPlayingView.tsx @@ -184,6 +184,7 @@ const NowPlayingView = () => { listType="music" nowPlaying dnd + disabledContextMenuOptions={['deletePlaylist']} /> )} diff --git a/src/components/playlist/PlaylistList.tsx b/src/components/playlist/PlaylistList.tsx index 7fb212d..f954afc 100644 --- a/src/components/playlist/PlaylistList.tsx +++ b/src/components/playlist/PlaylistList.tsx @@ -14,9 +14,16 @@ import { StyledButton, StyledInputGroup } from '../shared/styled'; import { errorMessages, isFailedResponse } from '../../shared/utils'; import { notifyToast } from '../shared/toast'; import { AddPlaylistButton } from '../shared/ToolbarButtons'; -import { useAppSelector } from '../../redux/hooks'; +import { useAppDispatch, useAppSelector } from '../../redux/hooks'; +import { + clearSelected, + setRangeSelected, + toggleRangeSelected, + toggleSelected, +} from '../../redux/multiSelectSlice'; const PlaylistList = () => { + const dispatch = useAppDispatch(); const history = useHistory(); const queryClient = useQueryClient(); const multiSelect = useAppSelector((state) => state.multiSelect); @@ -49,7 +56,27 @@ const PlaylistList = () => { } }; - const handleRowClick = (_e: any, rowData: any) => { + let timeout: any = null; + const handleRowClick = (e: any, rowData: any) => { + if (timeout === null) { + timeout = window.setTimeout(() => { + timeout = null; + + if (e.ctrlKey) { + dispatch(toggleSelected(rowData)); + } else if (e.shiftKey) { + dispatch(setRangeSelected(rowData)); + dispatch(toggleRangeSelected(searchQuery !== '' ? filteredData : playlists)); + } + }, 100); + } + }; + + const handleRowDoubleClick = (rowData: any) => { + window.clearTimeout(timeout); + timeout = null; + + dispatch(clearSelected()); history.push(`playlist/${rowData.id}`); }; @@ -136,6 +163,7 @@ const PlaylistList = () => { }) } handleRowClick={handleRowClick} + handleRowDoubleClick={handleRowDoubleClick} tableColumns={settings.getSync('playlistListColumns')} rowHeight={Number(settings.getSync('playlistListRowHeight'))} fontSize={settings.getSync('playlistListFontSize')} @@ -146,6 +174,12 @@ const PlaylistList = () => { }} listType="playlist" virtualized + disabledContextMenuOptions={[ + 'moveSelectedTo', + 'addToFavorites', + 'removeFromFavorites', + 'removeFromCurrent', + ]} /> )} {viewType === 'grid' && ( diff --git a/src/components/playlist/PlaylistView.tsx b/src/components/playlist/PlaylistView.tsx index 2887481..3f85b52 100644 --- a/src/components/playlist/PlaylistView.tsx +++ b/src/components/playlist/PlaylistView.tsx @@ -406,6 +406,7 @@ const PlaylistView = ({ ...rest }) => { listType="music" dnd isModal={rest.isModal} + disabledContextMenuOptions={['deletePlaylist']} /> ); diff --git a/src/components/shared/ContextMenu.tsx b/src/components/shared/ContextMenu.tsx index 716f483..1af08c2 100644 --- a/src/components/shared/ContextMenu.tsx +++ b/src/components/shared/ContextMenu.tsx @@ -10,6 +10,9 @@ import { createPlaylist, batchStar, batchUnstar, + getAlbum, + getPlaylist, + deletePlaylist, } from '../../api/api'; import { useAppDispatch, useAppSelector } from '../../redux/hooks'; import { @@ -93,7 +96,8 @@ export const GlobalContextMenu = () => { const playQueue = useAppSelector((state) => state.playQueue); const misc = useAppSelector((state) => state.misc); const multiSelect = useAppSelector((state) => state.multiSelect); - const playlistTriggerRef = useRef(); + const addToPlaylistTriggerRef = useRef(); + const deletePlaylistTriggerRef = useRef(); const [selectedPlaylistId, setSelectedPlaylistId] = useState(''); const [shouldCreatePlaylist, setShouldCreatePlaylist] = useState(false); const [newPlaylistName, setNewPlaylistName] = useState(''); @@ -103,11 +107,33 @@ export const GlobalContextMenu = () => { refetchOnWindowFocus: false, }); - const handleAddToQueue = () => { - const entriesByRowIndexAsc = _.orderBy(multiSelect.selected, 'rowIndex', 'asc'); - notifyToast('info', `Added ${multiSelect.selected.length} song(s) to the queue`); - dispatch(appendPlayQueue({ entries: entriesByRowIndexAsc })); + const handleAddToQueue = async () => { dispatch(setContextMenu({ show: false })); + const promises = []; + + if (misc.contextMenu.type === 'music' || misc.contextMenu.type === 'nowPlaying') { + const entriesByRowIndexAsc = _.orderBy(multiSelect.selected, 'rowIndex', 'asc'); + dispatch(appendPlayQueue({ entries: entriesByRowIndexAsc })); + notifyToast('info', `Added ${multiSelect.selected.length} song(s) to the queue`); + } else if (misc.contextMenu.type === 'playlist') { + for (let i = 0; i < multiSelect.selected.length; i += 1) { + promises.push(getPlaylist(multiSelect.selected[i].id)); + } + + const res = await Promise.all(promises); + const songs = _.flatten(_.map(res, 'song')); + dispatch(appendPlayQueue({ entries: songs })); + notifyToast('info', `Added ${songs.length} song(s) to the queue`); + } else if (misc.contextMenu.type === 'album') { + for (let i = 0; i < multiSelect.selected.length; i += 1) { + promises.push(getAlbum(multiSelect.selected[i].id)); + } + + const res = await Promise.all(promises); + const songs = _.flatten(_.map(res, 'song')); + dispatch(appendPlayQueue({ entries: songs })); + notifyToast('info', `Added ${songs.length} song(s) to the queue`); + } }; const handleRemoveFromCurrent = async () => { @@ -123,48 +149,111 @@ export const GlobalContextMenu = () => { dispatch(setContextMenu({ show: false })); }; + const playlistSuccessToast = (songCount: number, playlistId: string) => { + notifyToast( + 'success', + <> +

+ Added {songCount} song(s) to playlist " + {playlists.find((pl: any) => pl.id === playlistId)?.name} + " +

+ { + history.push(`/playlist/${playlistId}`); + dispatch(setContextMenu({ show: false })); + }} + > + Go to playlist + + + ); + }; + const handleAddToPlaylist = async () => { // If the window is closed, the selectedPlaylistId will be deleted + const promises = []; + let res; + let songs; const localSelectedPlaylistId = selectedPlaylistId; dispatch(addProcessingPlaylist(selectedPlaylistId)); - const sortedEntries = [...multiSelect.selected].sort( - (a: any, b: any) => a.rowIndex - b.rowIndex - ); - try { - const res = await updatePlaylistSongsLg(localSelectedPlaylistId, sortedEntries); + if (misc.contextMenu.type === 'music' || misc.contextMenu.type === 'nowPlaying') { + songs = [...multiSelect.selected].sort((a: any, b: any) => a.rowIndex - b.rowIndex); - if (isFailedResponse(res)) { - notifyToast('error', errorMessages(res)[0]); - } else { - notifyToast( - 'success', - <> -

- Added {sortedEntries.length} song(s) to playlist " - {playlists.find((pl: any) => pl.id === localSelectedPlaylistId)?.name} - " -

- { - history.push(`/playlist/${localSelectedPlaylistId}`); - dispatch(setContextMenu({ show: false })); - }} - > - Go to playlist - - - ); + res = await updatePlaylistSongsLg(localSelectedPlaylistId, songs); + + if (isFailedResponse(res)) { + notifyToast('error', errorMessages(res)[0]); + } else { + playlistSuccessToast(songs.length, localSelectedPlaylistId); + } + } else if (misc.contextMenu.type === 'playlist') { + for (let i = 0; i < multiSelect.selected.length; i += 1) { + promises.push(getPlaylist(multiSelect.selected[i].id)); + } + + res = await Promise.all(promises); + songs = _.flatten(_.map(res, 'song')); + res = await updatePlaylistSongsLg(localSelectedPlaylistId, songs); + + if (isFailedResponse(res)) { + notifyToast('error', errorMessages(res)[0]); + } else { + playlistSuccessToast(songs.length, localSelectedPlaylistId); + } + } else if (misc.contextMenu.type === 'album') { + for (let i = 0; i < multiSelect.selected.length; i += 1) { + promises.push(getAlbum(multiSelect.selected[i].id)); + } + + res = await Promise.all(promises); + songs = _.flatten(_.map(res, 'song')); + res = await updatePlaylistSongsLg(localSelectedPlaylistId, songs); + + if (isFailedResponse(res)) { + notifyToast('error', errorMessages(res)[0]); + } else { + playlistSuccessToast(songs.length, localSelectedPlaylistId); + } } } catch (err) { notifyToast('error', err); } + await queryClient.refetchQueries(['playlists'], { + active: true, + }); + dispatch(removeProcessingPlaylist(localSelectedPlaylistId)); }; + const handleDeletePlaylist = async () => { + dispatch(setContextMenu({ show: false })); + + // Navidrome throws internal server error when using Promise.all() so we do it sequentially + const res = []; + for (let i = 0; i < multiSelect.selected.length; i += 1) { + try { + res.push(await deletePlaylist(multiSelect.selected[i].id)); + } catch (err) { + notifyToast('error', err); + } + } + + if (isFailedResponse(res)) { + notifyToast('error', errorMessages(res)[0]); + } else { + notifyToast('info', `Deleted ${multiSelect.selected.length} playlist(s)`); + } + + await queryClient.refetchQueries(['playlists'], { + active: true, + }); + }; + const handleCreatePlaylist = async () => { try { const res = await createPlaylist(newPlaylistName); @@ -315,10 +404,10 @@ export const GlobalContextMenu = () => { xPos={misc.contextMenu.xPos} yPos={misc.contextMenu.yPos} width={190} - numOfButtons={7} + numOfButtons={8} numOfDividers={4} > - + { { - playlistTriggerRef.current.state.isOverlayShown - ? playlistTriggerRef.current.close() - : playlistTriggerRef.current.open() + addToPlaylistTriggerRef.current.state.isOverlayShown + ? addToPlaylistTriggerRef.current.close() + : addToPlaylistTriggerRef.current.open() } disabled={misc.contextMenu.disabledOptions.includes('addToPlaylist')} /> + +

Are you sure you want to delete {multiSelect.selected?.length} playlist(s)?

+ + Yes + + + } + > + + deletePlaylistTriggerRef.current.state.isOverlayShown + ? deletePlaylistTriggerRef.current.close() + : deletePlaylistTriggerRef.current.open() + } + disabled={misc.contextMenu.disabledOptions.includes('deletePlaylist')} + /> +
+ { min={0} max={ misc.contextMenu.type === 'nowPlaying' - ? playQueue[getCurrentEntryList(playQueue)].length - : playlist.entry.length + ? playQueue[getCurrentEntryList(playQueue)]?.length + : playlist.entry?.length } value={indexToMoveTo} onChange={(e: number) => setIndexToMoveTo(e)} @@ -444,8 +558,8 @@ export const GlobalContextMenu = () => { onClick={handleMoveSelectedToIndex} disabled={ (misc.contextMenu.type === 'nowPlaying' - ? indexToMoveTo > playQueue[getCurrentEntryList(playQueue)].length - : indexToMoveTo > playlist.entry.length) || indexToMoveTo < 0 + ? indexToMoveTo > playQueue[getCurrentEntryList(playQueue)]?.length + : indexToMoveTo > playlist.entry?.length) || indexToMoveTo < 0 } > Go diff --git a/src/components/starred/StarredView.tsx b/src/components/starred/StarredView.tsx index 511cb27..1b9ca23 100644 --- a/src/components/starred/StarredView.tsx +++ b/src/components/starred/StarredView.tsx @@ -132,7 +132,7 @@ const StarredView = () => { }} listType="music" virtualized - disabledContextMenuOptions={['removeFromCurrent', 'moveSelectedTo']} + disabledContextMenuOptions={['removeFromCurrent', 'moveSelectedTo', 'deletePlaylist']} /> )} {currentPage === 'Albums' && ( @@ -152,7 +152,11 @@ const StarredView = () => { }} listType="album" virtualized - disabledContextMenuOptions={['removeFromCurrent', 'moveSelectedTo']} + disabledContextMenuOptions={[ + 'removeFromCurrent', + 'moveSelectedTo', + 'deletePlaylist', + ]} /> )} {viewType === 'grid' && ( @@ -197,6 +201,7 @@ const StarredView = () => { 'removeFromCurrent', 'moveSelectedTo', 'addToPlaylist', + 'deletePlaylist', ]} /> )} diff --git a/src/components/viewtypes/ListViewTable.tsx b/src/components/viewtypes/ListViewTable.tsx index bea0ea3..211778e 100644 --- a/src/components/viewtypes/ListViewTable.tsx +++ b/src/components/viewtypes/ListViewTable.tsx @@ -261,7 +261,7 @@ const ListViewTable = ({ xPos: e.pageX, yPos: e.pageY, rowId: rowData.uniqueId, - type: nowPlaying ? 'nowPlaying' : 'other', + type: nowPlaying ? 'nowPlaying' : multiSelect.selected[0].type, disabledOptions: disabledContextMenuOptions || [], }) ); diff --git a/src/redux/miscSlice.ts b/src/redux/miscSlice.ts index a17a447..6c0b4e1 100644 --- a/src/redux/miscSlice.ts +++ b/src/redux/miscSlice.ts @@ -18,6 +18,7 @@ type ContextMenuOptions = | 'addToQueue' | 'removeFromCurrent' | 'addToPlaylist' + | 'deletePlaylist' | 'addToFavorites' | 'removeFromFavorites' | 'moveSelectedTo';