Add ability to add/remove songs from playlist (#17)

* Add api for add/remove playlist items

* Add playlistItemId property to normalized Song

- This is used for Navidrome to delete songs from playlists

* Add mutations for add/remove from playlist

* Add context modal for playlist add

* Add remove from playlist from context menu

* Set jellyfin to use playlistItemId

* Adjust font sizing

* Add playlist add from detail pages

* Bump mantine to v6-alpha.2
This commit is contained in:
Jeff 2023-01-29 18:40:26 -08:00 committed by GitHub
parent be39c2bc1f
commit 59f4f43e84
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1120 additions and 982 deletions

View file

@ -12,6 +12,7 @@ import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Box, Group, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { openContextModal } from '@mantine/modals';
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { generatePath, useParams } from 'react-router';
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
@ -182,6 +183,17 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
const { intersectRef, tableContainerRef } = useFixedTableHeader();
const handleAddToPlaylist = () => {
openContextModal({
innerProps: {
albumId: [albumId],
},
modal: 'addToPlaylist',
size: 'md',
title: 'Add to playlist',
});
};
return (
<ContentContainer>
<Box component="section">
@ -227,7 +239,7 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
</DropdownMenu.Item>
))}
<DropdownMenu.Divider />
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
<DropdownMenu.Item onClick={handleAddToPlaylist}>Add to playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>

View file

@ -10,6 +10,7 @@ import {
} from '/@/renderer/components';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import { Box, Group, Stack } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { RiArrowDownSLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { generatePath, useParams } from 'react-router';
import { useCurrentServer } from '/@/renderer/store';
@ -266,6 +267,17 @@ export const AlbumArtistDetailContent = () => {
}
};
const handleAddToPlaylist = () => {
openContextModal({
innerProps: {
artistId: [albumArtistId],
},
modal: 'addToPlaylist',
size: 'md',
title: 'Add to playlist',
});
};
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
const showBiography =
@ -318,7 +330,7 @@ export const AlbumArtistDetailContent = () => {
</DropdownMenu.Item>
))}
<DropdownMenu.Divider />
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
<DropdownMenu.Item onClick={handleAddToPlaylist}>Add to playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button

View file

@ -4,9 +4,20 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ disabled: true, id: 'addToPlaylist' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ id: 'removeFromFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' },
];
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'addToPlaylist' },
{ divider: true, id: 'removeFromPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' },
];
@ -14,7 +25,7 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ disabled: true, id: 'addToPlaylist' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' },
@ -24,9 +35,9 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ disabled: true, id: 'addToPlaylist' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ id: 'removeFromFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' },
];

View file

@ -1,8 +1,8 @@
import { Divider, Group, Stack } from '@mantine/core';
import { useClickOutside, useResizeObserver, useSetState, useViewportSize } from '@mantine/hooks';
import { closeAllModals, openModal } from '@mantine/modals';
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
import { createContext, Fragment, useState } from 'react';
import { LibraryItem } from '/@/renderer/api/types';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { ConfirmModal, ContextMenu, ContextMenuButton, Text, toast } from '/@/renderer/components';
import {
OpenContextMenuProps,
@ -10,7 +10,9 @@ import {
} from '/@/renderer/features/context-menu/events';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useDeletePlaylist } from '/@/renderer/features/playlists';
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { useCurrentServer } from '/@/renderer/store';
import { Play } from '/@/renderer/types';
type ContextMenuContextProps = {
@ -33,6 +35,8 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const [opened, setOpened] = useState(false);
const clickOutsideRef = useClickOutside(() => setOpened(false));
const viewport = useViewportSize();
const server = useCurrentServer();
const serverType = server?.type;
const [ref, menuRect] = useResizeObserver();
const [ctx, setCtx] = useSetState<OpenContextMenuProps>({
data: [],
@ -47,7 +51,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const handlePlayQueueAdd = usePlayQueueAdd();
const openContextMenu = (args: OpenContextMenuProps) => {
const { xPos, yPos, menuItems, data, type, tableRef, dataNodes } = args;
const { xPos, yPos, menuItems, data, type, tableRef, dataNodes, context } = args;
const shouldReverseY = yPos + menuRect.height > viewport.height;
const shouldReverseX = xPos + menuRect.width > viewport.width;
@ -56,6 +60,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const calculatedYPos = shouldReverseY ? yPos - menuRect.height : yPos;
setCtx({
context,
data,
dataNodes,
menuItems,
@ -216,13 +221,78 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
);
};
const handleAddToPlaylist = () => {
if (!ctx.dataNodes) return;
openContextModal({
innerProps: {
albumId:
ctx.type === LibraryItem.ALBUM ? ctx.dataNodes.map((node) => node.data.id) : undefined,
artistId:
ctx.type === LibraryItem.ARTIST ? ctx.dataNodes.map((node) => node.data.id) : undefined,
songId:
ctx.type === LibraryItem.SONG ? ctx.dataNodes.map((node) => node.data.id) : undefined,
},
modal: 'addToPlaylist',
size: 'md',
title: 'Add to playlist',
});
};
const removeFromPlaylistMutation = useRemoveFromPlaylist();
const handleRemoveFromPlaylist = () => {
const songId =
(serverType === ServerType.NAVIDROME || ServerType.JELLYFIN
? ctx.dataNodes?.map((node) => node.data.playlistItemId)
: ctx.dataNodes?.map((node) => node.data.id)) || [];
const confirm = () => {
removeFromPlaylistMutation.mutate(
{
query: {
id: ctx.context.playlistId,
songId,
},
},
{
onError: (err) => {
toast.error({
message: err.message,
title: 'Error removing song(s) from playlist',
});
},
onSuccess: () => {
toast.success({
message: `${songId.length} song(s) were removed from the playlist`,
title: 'Song(s) removed from playlist',
});
ctx.context?.tableRef?.current?.api?.refreshInfiniteCache();
closeAllModals();
},
},
);
};
openModal({
children: (
<ConfirmModal
loading={removeFromPlaylistMutation.isLoading}
onConfirm={confirm}
>
Are you sure you want to remove the following song(s) from the playlist?
</ConfirmModal>
),
title: 'Remove song(s) from playlist',
});
};
const contextMenuItems = {
addToFavorites: {
id: 'addToFavorites',
label: 'Add to favorites',
onClick: handleAddToFavorites,
},
addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: () => {} },
addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: handleAddToPlaylist },
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
deletePlaylist: {
id: 'deletePlaylist',
@ -249,6 +319,11 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
label: 'Remove from favorites',
onClick: handleRemoveFromFavorites,
},
removeFromPlaylist: {
id: 'removeFromPlaylist',
label: 'Remove from playlist',
onClick: handleRemoveFromPlaylist,
},
setRating: { id: 'setRating', label: 'Set rating', onClick: () => {} },
};

View file

@ -5,6 +5,7 @@ import { MutableRefObject } from 'react';
import { LibraryItem } from '/@/renderer/api/types';
export type OpenContextMenuProps = {
context?: any;
data: any[];
dataNodes?: RowNode[];
menuItems: SetContextMenuItems;
@ -24,6 +25,7 @@ export type ContextMenuItem =
| 'playLast'
| 'playNext'
| 'addToPlaylist'
| 'removeFromPlaylist'
| 'addToFavorites'
| 'removeFromFavorites'
| 'setRating'

View file

@ -6,6 +6,7 @@ import { openContextMenu, SetContextMenuItems } from '/@/renderer/features/conte
export const useHandleTableContextMenu = (
itemType: LibraryItem,
contextMenuItems: SetContextMenuItems,
context?: any,
) => {
const handleContextMenu = (e: CellContextMenuEvent) => {
if (!e.event) return;
@ -25,6 +26,7 @@ export const useHandleTableContextMenu = (
}
openContextMenu({
context,
data: selectedRows,
dataNodes: selectedNodes,
menuItems: contextMenuItems,

View file

@ -0,0 +1,212 @@
import { Box, Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { closeModal, ContextModalProps } from '@mantine/modals';
import { useMemo, useState } from 'react';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { PlaylistListSort, SongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
import { Button, MultiSelect, Switch, toast } from '/@/renderer/components';
import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
export const AddToPlaylistContextModal = ({
id,
innerProps,
}: ContextModalProps<{
albumId?: string[];
artistId?: string[];
songId?: string[];
}>) => {
const { albumId, artistId, songId } = innerProps;
const server = useCurrentServer();
const [isLoading, setIsLoading] = useState(false);
const addToPlaylistMutation = useAddToPlaylist();
const playlistList = usePlaylistList({
ndParams: {
smart: false,
},
sortBy: PlaylistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
});
const playlistSelect = useMemo(() => {
return (
playlistList.data?.items?.map((playlist) => ({
label: playlist.name,
value: playlist.id,
})) || []
);
}, [playlistList.data]);
const form = useForm({
initialValues: {
playlistId: [],
skipDuplicates: true,
},
});
const getSongsByAlbum = async (albumId: string) => {
const query: SongListQuery = {
albumIds: [albumId],
sortBy: SongListSort.ALBUM,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const queryKey = queryKeys.songs.list(server?.id || '', query);
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) =>
api.controller.getSongList({ query, server, signal }),
);
return api.normalize.songList(songsRes, server);
};
const getSongsByArtist = async (artistId: string) => {
const query: SongListQuery = {
artistIds: [artistId],
sortBy: SongListSort.ARTIST,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const queryKey = queryKeys.songs.list(server?.id || '', query);
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) =>
api.controller.getSongList({ query, server, signal }),
);
return api.normalize.songList(songsRes, server);
};
const isSubmitDisabled = form.values.playlistId.length === 0 || addToPlaylistMutation.isLoading;
const handleSubmit = form.onSubmit(async (values) => {
setIsLoading(true);
const allSongIds: string[] = [];
const uniqueSongIds: string[] = [];
if (albumId && albumId.length > 0) {
for (const id of albumId) {
const songs = await getSongsByAlbum(id);
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
}
if (artistId && artistId.length > 0) {
for (const id of artistId) {
const songs = await getSongsByArtist(id);
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
}
if (songId && songId.length > 0) {
allSongIds.push(...songId);
}
for (const playlistId of values.playlistId) {
if (values.skipDuplicates) {
const query = {
id: playlistId,
startIndex: 0,
};
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) =>
api.controller.getPlaylistSongList({
query: { id: playlistId, startIndex: 0 },
server,
signal,
}),
);
const playlistSongIds = api.normalize
.songList(playlistSongsRes, server)
.items?.map((song) => song.id);
for (const songId of allSongIds) {
if (!playlistSongIds?.includes(songId)) {
uniqueSongIds.push(songId);
}
}
}
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
addToPlaylistMutation.mutate(
{
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
query: { id: playlistId },
},
{
onError: (err) => {
toast.error({
message: `[${
playlistSelect.find((playlist) => playlist.value === playlistId)?.label
}] ${err.message}`,
title: 'Failed to add songs to playlist',
});
},
},
);
}
}
setIsLoading(false);
toast.success({
message: `Added ${
values.skipDuplicates ? uniqueSongIds.length : allSongIds.length
} songs to ${values.playlistId.length} playlist(s)`,
});
closeModal(id);
return null;
});
return (
<Box p="1rem">
<form onSubmit={handleSubmit}>
<Stack>
<MultiSelect
clearable
searchable
data={playlistSelect}
disabled={playlistList.isLoading}
label="Playlists"
size="md"
{...form.getInputProps('playlistId')}
/>
<Switch
label="Skip duplicates"
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
/>
<Group position="right">
<Group>
<Button
disabled={addToPlaylistMutation.isLoading}
size="md"
variant="subtle"
onClick={() => closeModal(id)}
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
loading={isLoading}
size="md"
type="submit"
variant="filled"
>
Add
</Button>
</Group>
</Group>
</Stack>
</form>
</Box>
);
};

View file

@ -28,7 +28,7 @@ import {
VirtualTable,
} from '/@/renderer/components';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { PLAYLIST_SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { UpdatePlaylistForm } from '/@/renderer/features/playlists/components/update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
@ -90,7 +90,11 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
[page.table.columns],
);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleContextMenu = useHandleTableContextMenu(
LibraryItem.SONG,
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
{ playlistId },
);
const playlistSongData = useMemo(
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p.items),

View file

@ -26,7 +26,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { AnimatePresence } from 'framer-motion';
import debounce from 'lodash/debounce';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { PLAYLIST_SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import {
LibraryItem,
@ -185,7 +185,11 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
setPagination(playlistId, { scrollOffset });
};
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleContextMenu = useHandleTableContextMenu(
LibraryItem.SONG,
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
{ playlistId, tableRef },
);
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;

View file

@ -1,5 +1,8 @@
export * from './queries/playlist-list-query';
export * from './components/add-to-playlist-context-modal';
export * from './components/create-playlist-form';
export * from './mutations/delete-playlist-mutation';
export * from './mutations/create-playlist-mutation';
export * from './mutations/update-playlist-mutation';
export * from './mutations/add-to-playlist-mutation';
export * from './mutations/remove-from-playlist-mutation';

View file

@ -0,0 +1,28 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HTTPError } from 'ky';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AddToPlaylistArgs, RawAddToPlaylistResponse } from '/@/renderer/api/types';
import { MutationOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
export const useAddToPlaylist = (options?: MutationOptions) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
return useMutation<RawAddToPlaylistResponse, HTTPError, Omit<AddToPlaylistArgs, 'server'>, null>({
mutationFn: (args) => api.controller.addToPlaylist({ ...args, server }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''), { exact: false });
queryClient.invalidateQueries(
queryKeys.playlists.detail(server?.id || '', variables.query.id),
);
queryClient.invalidateQueries(
queryKeys.playlists.detailSongList(server?.id || '', variables.query.id),
);
},
...options,
});
};

View file

@ -0,0 +1,33 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HTTPError } from 'ky';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { RawRemoveFromPlaylistResponse, RemoveFromPlaylistArgs } from '/@/renderer/api/types';
import { MutationOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
export const useRemoveFromPlaylist = (options?: MutationOptions) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
return useMutation<
RawRemoveFromPlaylistResponse,
HTTPError,
Omit<RemoveFromPlaylistArgs, 'server'>,
null
>({
mutationFn: (args) => api.controller.removeFromPlaylist({ ...args, server }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''), { exact: false });
queryClient.invalidateQueries(
queryKeys.playlists.detail(server?.id || '', variables.query.id),
);
queryClient.invalidateQueries(
queryKeys.playlists.detailSongList(server?.id || '', variables.query.id),
);
},
...options,
});
};