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

1424
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -254,14 +254,14 @@
"@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1",
"@emotion/react": "^11.10.4",
"@mantine/core": "^6.0.0-alpha.0",
"@mantine/dates": "^6.0.0-alpha.0",
"@mantine/dropzone": "^6.0.0-alpha.0",
"@mantine/form": "^6.0.0-alpha.0",
"@mantine/hooks": "^6.0.0-alpha.0",
"@mantine/modals": "^6.0.0-alpha.0",
"@mantine/notifications": "^6.0.0-alpha.0",
"@mantine/utils": "^6.0.0-alpha.0",
"@mantine/core": "^6.0.0-alpha.2",
"@mantine/dates": "^6.0.0-alpha.2",
"@mantine/dropzone": "^6.0.0-alpha.2",
"@mantine/form": "^6.0.0-alpha.2",
"@mantine/hooks": "^6.0.0-alpha.2",
"@mantine/modals": "^6.0.0-alpha.2",
"@mantine/notifications": "^6.0.0-alpha.2",
"@mantine/utils": "^6.0.0-alpha.2",
"@tanstack/react-query": "^4.16.1",
"@tanstack/react-query-devtools": "^4.16.1",
"@tanstack/react-virtual": "^3.0.0-beta.39",

View file

@ -39,11 +39,16 @@ import type {
FavoriteArgs,
TopSongListArgs,
RawTopSongListResponse,
AddToPlaylistArgs,
RawAddToPlaylistResponse,
RemoveFromPlaylistArgs,
RawRemoveFromPlaylistResponse,
} from '/@/renderer/api/types';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<RawAddToPlaylistResponse>;
clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<RawCreatePlaylistResponse>;
@ -69,6 +74,7 @@ export type ControllerEndpoint = Partial<{
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RawRemoveFromPlaylistResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
}>;
@ -81,6 +87,7 @@ type ApiController = {
const endpoints: ApiController = {
jellyfin: {
addToPlaylist: jellyfinApi.addToPlaylist,
clearPlaylist: undefined,
createFavorite: jellyfinApi.createFavorite,
createPlaylist: jellyfinApi.createPlaylist,
@ -106,10 +113,12 @@ const endpoints: ApiController = {
getSongList: jellyfinApi.getSongList,
getTopSongs: undefined,
getUserList: undefined,
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
updatePlaylist: jellyfinApi.updatePlaylist,
updateRating: undefined,
},
navidrome: {
addToPlaylist: navidromeApi.addToPlaylist,
clearPlaylist: undefined,
createFavorite: subsonicApi.createFavorite,
createPlaylist: navidromeApi.createPlaylist,
@ -135,6 +144,7 @@ const endpoints: ApiController = {
getSongList: navidromeApi.getSongList,
getTopSongs: subsonicApi.getTopSongList,
getUserList: navidromeApi.getUserList,
removeFromPlaylist: navidromeApi.removeFromPlaylist,
updatePlaylist: navidromeApi.updatePlaylist,
updateRating: subsonicApi.updateRating,
},
@ -239,6 +249,14 @@ const deletePlaylist = async (args: DeletePlaylistArgs) => {
return (apiController('deletePlaylist') as ControllerEndpoint['deletePlaylist'])?.(args);
};
const addToPlaylist = async (args: AddToPlaylistArgs) => {
return (apiController('addToPlaylist') as ControllerEndpoint['addToPlaylist'])?.(args);
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
return (apiController('removeFromPlaylist') as ControllerEndpoint['removeFromPlaylist'])?.(args);
};
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
return (apiController('getPlaylistDetail') as ControllerEndpoint['getPlaylistDetail'])?.(args);
};
@ -270,6 +288,7 @@ const getTopSongList = async (args: TopSongListArgs) => {
};
export const controller = {
addToPlaylist,
createFavorite,
createPlaylist,
deleteFavorite,
@ -287,6 +306,7 @@ export const controller = {
getSongList,
getTopSongList,
getUserList,
removeFromPlaylist,
updatePlaylist,
updateRating,
};

View file

@ -1,6 +1,8 @@
import ky from 'ky';
import { nanoid } from 'nanoid/non-secure';
import type {
JFAddToPlaylist,
JFAddToPlaylistParams,
JFAlbum,
JFAlbumArtist,
JFAlbumArtistDetail,
@ -27,6 +29,8 @@ import type {
JFPlaylistDetailResponse,
JFPlaylistList,
JFPlaylistListResponse,
JFRemoveFromPlaylist,
JFRemoveFromPlaylistParams,
JFSong,
JFSongList,
JFSongListParams,
@ -64,6 +68,8 @@ import {
UpdatePlaylistArgs,
UpdatePlaylistResponse,
LibraryItem,
RemoveFromPlaylistArgs,
AddToPlaylistArgs,
} from '/@/renderer/api/types';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
@ -362,6 +368,45 @@ const getSongList = async (args: SongListArgs): Promise<JFSongList> => {
};
};
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<JFAddToPlaylist> => {
const { query, body, server, signal } = args;
const searchParams: JFAddToPlaylistParams = {
ids: body.songId,
userId: server?.userId || '',
};
await api
.post(`playlists/${query.id}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFPlaylistDetailResponse>();
return null;
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<JFRemoveFromPlaylist> => {
const { query, server, signal } = args;
const searchParams: JFRemoveFromPlaylistParams = {
entryIds: query.songId,
};
await api
.delete(`playlists/${query.id}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFPlaylistDetailResponse>();
return null;
};
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<JFPlaylistDetail> => {
const { query, server, signal } = args;
@ -677,6 +722,7 @@ const normalizeSong = (
name: item.Name,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
releaseDate: null,
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
@ -863,6 +909,7 @@ const normalizePlaylist = (
// };
export const jellyfinApi = {
addToPlaylist,
authenticate,
createFavorite,
createPlaylist,
@ -879,6 +926,7 @@ export const jellyfinApi = {
getPlaylistList,
getPlaylistSongList,
getSongList,
removeFromPlaylist,
updatePlaylist,
};

View file

@ -59,6 +59,25 @@ export type JFSongList = {
totalRecordCount: number;
};
export type JFAddToPlaylistResponse = {
added: number;
};
export type JFAddToPlaylistParams = {
ids: string[];
userId: string;
};
export type JFAddToPlaylist = null;
export type JFRemoveFromPlaylistResponse = null;
export type JFRemoveFromPlaylistParams = {
entryIds: string[];
};
export type JFRemoveFromPlaylist = null;
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
Items: JFPlaylist[];
}
@ -252,6 +271,7 @@ export type JFSong = {
MediaType: string;
Name: string;
ParentIndexNumber: number;
PlaylistItemId?: string;
PremiereDate?: string;
ProductionYear: number;
RunTimeTicks: number;

View file

@ -41,6 +41,12 @@ import type {
NDUserListResponse,
NDUserListParams,
NDUser,
NDAddToPlaylist,
NDAddToPlaylistBody,
NDAddToPlaylistResponse,
NDRemoveFromPlaylistParams,
NDRemoveFromPlaylistResponse,
NDRemoveFromPlaylist,
} from '/@/renderer/api/navidrome.types';
import { NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
import {
@ -73,6 +79,8 @@ import {
sortOrderMap,
User,
LibraryItem,
AddToPlaylistArgs,
RemoveFromPlaylistArgs,
} from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
@ -472,6 +480,44 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDPlayli
};
};
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<NDAddToPlaylist> => {
const { query, body, server, signal } = args;
const json: NDAddToPlaylistBody = {
ids: body.songId,
};
await api
.post(`api/playlist/${query.id}/tracks`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
json,
prefixUrl: server?.url,
signal,
})
.json<NDAddToPlaylistResponse>();
return null;
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<NDRemoveFromPlaylist> => {
const { query, server, signal } = args;
const searchParams: NDRemoveFromPlaylistParams = {
id: query.songId,
};
await api
.delete(`api/playlist/${query.id}/tracks`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<NDRemoveFromPlaylistResponse>();
return null;
};
const getCoverArtUrl = (args: {
baseUrl: string;
coverArtId: string;
@ -501,10 +547,12 @@ const normalizeSong = (
imageSize?: number,
): Song => {
let id;
let playlistItemId;
// Dynamically determine the id field based on whether or not the item is a playlist song
if ('mediaFileId' in item) {
id = item.mediaFileId;
playlistItemId = item.id;
} else {
id = item.id;
}
@ -542,6 +590,7 @@ const normalizeSong = (
name: item.title,
path: item.path,
playCount: item.playCount,
playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(),
releaseYear: String(item.year),
serverId: server.id,
@ -675,6 +724,7 @@ const normalizeUser = (item: NDUser): User => {
};
export const navidromeApi = {
addToPlaylist,
authenticate,
createPlaylist,
deletePlaylist,
@ -689,6 +739,7 @@ export const navidromeApi = {
getSongDetail,
getSongList,
getUserList,
removeFromPlaylist,
updatePlaylist,
};

View file

@ -274,6 +274,26 @@ export type NDAlbumArtistListParams = {
} & NDPagination &
NDOrder;
export type NDAddToPlaylistResponse = {
added: number;
};
export type NDAddToPlaylistBody = {
ids: string[];
};
export type NDAddToPlaylist = null;
export type NDRemoveFromPlaylistResponse = {
ids: string[];
};
export type NDRemoveFromPlaylistParams = {
id: string[];
};
export type NDRemoveFromPlaylist = null;
export type NDCreatePlaylistParams = {
comment?: string;
name: string;

View file

@ -205,6 +205,7 @@ export type Song = {
name: string;
path: string | null;
playCount: number;
playlistItemId?: string;
releaseDate: string | null;
releaseYear: string | null;
serverId: string;
@ -777,6 +778,32 @@ export type RatingQuery = { id: string[]; rating: number };
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
// Add to playlist
export type RawAddToPlaylistResponse = null | undefined;
export type AddToPlaylistQuery = {
id: string;
};
export type AddToPlaylistBody = {
songId: string[];
};
export type AddToPlaylistArgs = {
body: AddToPlaylistBody;
query: AddToPlaylistQuery;
} & BaseEndpointArgs;
// Remove from playlist
export type RawRemoveFromPlaylistResponse = null | undefined;
export type RemoveFromPlaylistQuery = {
id: string;
songId: string[];
};
export type RemoveFromPlaylistArgs = { query: RemoveFromPlaylistQuery } & BaseEndpointArgs;
// Create Playlist
export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
@ -850,6 +877,7 @@ export type PlaylistListQuery = {
limit?: number;
ndParams?: {
owner_id?: string;
smart?: boolean;
};
searchTerm?: string;
sortBy: PlaylistListSort;

View file

@ -15,6 +15,7 @@ import '@ag-grid-community/styles/ag-grid.css';
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
@ -53,10 +54,10 @@ export const App = () => {
},
fontFamily: 'var(--content-font-family)',
fontSizes: {
lg: '1.5rem',
md: '1.1rem',
lg: '1.1rem',
md: '1rem',
sm: '0.9rem',
xl: '2rem',
xl: '1.5rem',
xs: '0.8rem',
},
headings: {
@ -90,7 +91,7 @@ export const App = () => {
transition: 'slide-down',
},
}}
modals={{ base: BaseContextModal }}
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
>
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
<ContextMenuProvider>

View file

@ -2,6 +2,7 @@ import type { MouseEvent } from 'react';
import React from 'react';
import type { UnstyledButtonProps } from '@mantine/core';
import { Group } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button';
@ -137,6 +138,19 @@ export const CardControls = ({
});
};
const openAddToPlaylistModal = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
openContextModal({
innerProps: {
albumId: itemType === LibraryItem.ALBUM ? [itemData.id] : undefined,
artistId: itemType === LibraryItem.ALBUM_ARTIST ? [itemData.id] : undefined,
},
modal: 'addToPlaylist',
size: 'md',
title: 'Add to playlist',
});
};
return (
<GridCardControlsContainer>
<BottomControls>
@ -190,8 +204,9 @@ export const CardControls = ({
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
<DropdownMenu.Item onClick={openAddToPlaylistModal}>
Add to playlist
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>

View file

@ -2,6 +2,7 @@ import type { MouseEvent } from 'react';
import React from 'react';
import type { UnstyledButtonProps } from '@mantine/core';
import { Group } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button';
@ -151,6 +152,19 @@ export const GridCardControls = ({
});
};
const openAddToPlaylistModal = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
openContextModal({
innerProps: {
albumId: itemType === LibraryItem.ALBUM ? [itemData.id] : undefined,
artistId: itemType === LibraryItem.ALBUM_ARTIST ? [itemData.id] : undefined,
},
modal: 'addToPlaylist',
size: 'md',
title: 'Add to playlist',
});
};
return (
<GridCardControlsContainer>
{/* <TopControls /> */}
@ -206,8 +220,9 @@ export const GridCardControls = ({
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
<DropdownMenu.Item onClick={openAddToPlaylistModal}>
Add to playlist
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>

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,
});
};