From 22fec8f9d3f594a0bd56e24d1ad6cc317a022d92 Mon Sep 17 00:00:00 2001 From: Jeff <42182408+jeffvli@users.noreply.github.com> Date: Sun, 5 Feb 2023 05:19:01 -0800 Subject: [PATCH] Add ratings support (#21) * Update rating types for multiserver support * Add rating mutation * Add rating support to table views * Add rating support on playerbar * Add hovercard component * Handle rating from context menu - Improve context menu components - Allow left / right icons - Allow nested menus * Add selected item count * Fix context menu auto direction * Add transition and move portal for context menu * Re-use context menu for all item dropdowns * Add ratings to detail pages / double click to clear * Bump react-query package --- package-lock.json | 46 +- package.json | 4 +- src/renderer/api/subsonic.api.ts | 10 +- src/renderer/api/types.ts | 17 +- .../components/card/card-controls.tsx | 85 +-- .../components/context-menu/index.tsx | 94 ++- src/renderer/components/hover-card/index.tsx | 23 + src/renderer/components/index.ts | 1 + src/renderer/components/rating/index.tsx | 23 +- .../grid-card/grid-card-controls.tsx | 87 +-- .../virtual-table/cells/rating-cell.tsx | 38 +- .../virtual-table/hooks/use-rating.ts | 131 ++++ .../components/virtual-table/index.tsx | 2 +- .../components/album-detail-content.tsx | 63 +- .../albums/components/album-detail-header.tsx | 45 +- .../album-artist-detail-content.tsx | 81 +-- .../components/album-artist-detail-header.tsx | 52 +- .../context-menu/context-menu-items.tsx | 8 +- .../context-menu/context-menu-provider.tsx | 586 +++++++++++++----- src/renderer/features/context-menu/events.ts | 5 +- .../hooks/use-handle-context-menu.ts | 29 +- .../player/components/left-controls.tsx | 74 +-- .../player/components/right-controls.tsx | 33 +- src/renderer/features/shared/index.ts | 1 + .../mutations/update-rating-mutation.ts | 133 ++++ src/renderer/store/player.store.ts | 9 + src/renderer/themes/default.scss | 2 +- 27 files changed, 1184 insertions(+), 498 deletions(-) create mode 100644 src/renderer/components/hover-card/index.tsx create mode 100644 src/renderer/components/virtual-table/hooks/use-rating.ts create mode 100644 src/renderer/features/shared/mutations/update-rating-mutation.ts diff --git a/package-lock.json b/package-lock.json index 1751af020..67d9f66eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,8 @@ "@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-query": "^4.24.4", + "@tanstack/react-query-devtools": "^4.24.4", "@tanstack/react-virtual": "^3.0.0-beta.39", "dayjs": "^1.11.6", "electron-debug": "^3.2.0", @@ -2230,20 +2230,20 @@ } }, "node_modules/@tanstack/query-core": { - "version": "4.20.4", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.20.4.tgz", - "integrity": "sha512-lhLtGVNJDsJ/DyZXrLzekDEywQqRVykgBqTmkv0La32a/RleILXy6JMLBb7UmS3QCatg/F/0N9/5b0i5j6IKcA==", + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.4.tgz", + "integrity": "sha512-9dqjv9eeB6VHN7lD3cLo16ZAjfjCsdXetSAD5+VyKqLUvcKTL0CklGQRJu+bWzdrS69R6Ea4UZo8obHYZnG6aA==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "4.20.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.20.4.tgz", - "integrity": "sha512-SJRxx13k/csb9lXAJfycgVA1N/yU/h3bvRNWP0+aHMfMjmbyX82FdoAcckDBbOdEyAupvb0byelNHNeypCFSyA==", + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.24.4.tgz", + "integrity": "sha512-RpaS/3T/a3pHuZJbIAzAYRu+1nkp+/enr9hfRXDS/mojwx567UiMksoqW4wUFWlwIvWTXyhot2nbIipTKEg55Q==", "dependencies": { - "@tanstack/query-core": "4.20.4", + "@tanstack/query-core": "4.24.4", "use-sync-external-store": "^1.2.0" }, "funding": { @@ -2265,9 +2265,9 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "4.20.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.20.4.tgz", - "integrity": "sha512-4e4wOmqAYjLS1RQ7gRBLCk3koCjbOfCMvbxS3CPCAN5+FLBemLAvoYvFJ/i/7DPsIsltGwsnd7YAFFGMzdSx7A==", + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.24.4.tgz", + "integrity": "sha512-4mldcR99QDX8k94I+STM9gPsYF+FDAD2EQJvHtxR2HrDNegbfmY474xuW0QUZaNW/vJi09Gak6b6Vy2INWhL6w==", "dependencies": { "@tanstack/match-sorter-utils": "^8.7.0", "superjson": "^1.10.0", @@ -2278,7 +2278,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "4.20.4", + "@tanstack/react-query": "4.24.4", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } @@ -25208,23 +25208,23 @@ } }, "@tanstack/query-core": { - "version": "4.20.4", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.20.4.tgz", - "integrity": "sha512-lhLtGVNJDsJ/DyZXrLzekDEywQqRVykgBqTmkv0La32a/RleILXy6JMLBb7UmS3QCatg/F/0N9/5b0i5j6IKcA==" + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.4.tgz", + "integrity": "sha512-9dqjv9eeB6VHN7lD3cLo16ZAjfjCsdXetSAD5+VyKqLUvcKTL0CklGQRJu+bWzdrS69R6Ea4UZo8obHYZnG6aA==" }, "@tanstack/react-query": { - "version": "4.20.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.20.4.tgz", - "integrity": "sha512-SJRxx13k/csb9lXAJfycgVA1N/yU/h3bvRNWP0+aHMfMjmbyX82FdoAcckDBbOdEyAupvb0byelNHNeypCFSyA==", + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.24.4.tgz", + "integrity": "sha512-RpaS/3T/a3pHuZJbIAzAYRu+1nkp+/enr9hfRXDS/mojwx567UiMksoqW4wUFWlwIvWTXyhot2nbIipTKEg55Q==", "requires": { - "@tanstack/query-core": "4.20.4", + "@tanstack/query-core": "4.24.4", "use-sync-external-store": "^1.2.0" } }, "@tanstack/react-query-devtools": { - "version": "4.20.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.20.4.tgz", - "integrity": "sha512-4e4wOmqAYjLS1RQ7gRBLCk3koCjbOfCMvbxS3CPCAN5+FLBemLAvoYvFJ/i/7DPsIsltGwsnd7YAFFGMzdSx7A==", + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.24.4.tgz", + "integrity": "sha512-4mldcR99QDX8k94I+STM9gPsYF+FDAD2EQJvHtxR2HrDNegbfmY474xuW0QUZaNW/vJi09Gak6b6Vy2INWhL6w==", "requires": { "@tanstack/match-sorter-utils": "^8.7.0", "superjson": "^1.10.0", diff --git a/package.json b/package.json index 134917559..6fa0f910d 100644 --- a/package.json +++ b/package.json @@ -261,8 +261,8 @@ "@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-query": "^4.24.4", + "@tanstack/react-query-devtools": "^4.24.4", "@tanstack/react-virtual": "^3.0.0-beta.39", "dayjs": "^1.11.6", "electron-debug": "^3.2.0", diff --git a/src/renderer/api/subsonic.api.ts b/src/renderer/api/subsonic.api.ts index eb9b3ac6a..8c441bf6c 100644 --- a/src/renderer/api/subsonic.api.ts +++ b/src/renderer/api/subsonic.api.ts @@ -322,7 +322,9 @@ const updateRating = async (args: RatingArgs): Promise => { const { server, query, signal } = args; const defaultParams = getDefaultParams(server); - for (const id of query.id) { + const itemIds = query.item.map((item) => item.id); + + for (const id of itemIds) { const searchParams: SSRatingParams = { id, rating: query.rating, @@ -334,13 +336,9 @@ const updateRating = async (args: RatingArgs): Promise => { searchParams: parseSearchParams(searchParams), signal, }); - // .json(); } - return { - id: query.id, - rating: query.rating, - }; + return null; }; const getTopSongList = async (args: TopSongListArgs): Promise => { diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index d1169a55c..7a0b5fc79 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -54,6 +54,16 @@ export enum LibraryItem { SONG = 'song', } +export type AnyLibraryItem = Album | AlbumArtist | Artist | Playlist | Song | QueueSong; + +export type AnyLibraryItems = + | Album[] + | AlbumArtist[] + | Artist[] + | Playlist[] + | Song[] + | QueueSong[]; + export enum SortOrder { ASC = 'ASC', DESC = 'DESC', @@ -773,9 +783,12 @@ export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs; // Rating export type RawRatingResponse = RatingResponse | undefined; -export type RatingResponse = { id: string[]; rating: number }; +export type RatingResponse = null; -export type RatingQuery = { id: string[]; rating: number }; +export type RatingQuery = { + item: AnyLibraryItems; + rating: number; +}; export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs; diff --git a/src/renderer/components/card/card-controls.tsx b/src/renderer/components/card/card-controls.tsx index 48e69ae44..f320bc76d 100644 --- a/src/renderer/components/card/card-controls.tsx +++ b/src/renderer/components/card/card-controls.tsx @@ -2,15 +2,18 @@ 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'; -import { DropdownMenu } from '/@/renderer/components/dropdown-menu'; import type { PlayQueueAddOptions } from '/@/renderer/types'; import { Play } from '/@/renderer/types'; import { useSettingsStore } from '/@/renderer/store/settings.store'; import { LibraryItem } from '/@/renderer/api/types'; +import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; +import { + ALBUM_CONTEXT_MENU_ITEMS, + ARTIST_CONTEXT_MENU_ITEMS, +} from '/@/renderer/features/context-menu/context-menu-items'; type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>; @@ -100,21 +103,6 @@ const FavoriteWrapper = styled.span<{ isFavorite: boolean }>` } `; -const PLAY_TYPES = [ - { - label: 'Play', - play: Play.NOW, - }, - { - label: 'Add to queue', - play: Play.LAST, - }, - { - label: 'Add to queue next', - play: Play.NEXT, - }, -]; - export const CardControls = ({ itemData, itemType, @@ -138,18 +126,10 @@ export const CardControls = ({ }); }; - const openAddToPlaylistModal = (e: MouseEvent) => { - 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', - }); - }; + const handleContextMenu = useHandleGeneralContextMenu( + itemType, + itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS, + ); return ( @@ -175,40 +155,21 @@ export const CardControls = ({ )} - { + e.preventDefault(); + e.stopPropagation(); + handleContextMenu(e, [itemData]); + }} > - - { - e.preventDefault(); - e.stopPropagation(); - }} - > - - - - - {PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => ( - ) => handlePlay(e, type.play)} - > - {type.label} - - ))} - - Add to playlist - - - + + diff --git a/src/renderer/components/context-menu/index.tsx b/src/renderer/components/context-menu/index.tsx index ae0bc5e99..5b7b357c0 100644 --- a/src/renderer/components/context-menu/index.tsx +++ b/src/renderer/components/context-menu/index.tsx @@ -1,6 +1,6 @@ import { forwardRef, ReactNode, Ref } from 'react'; -import { Portal, UnstyledButton } from '@mantine/core'; -import { motion } from 'framer-motion'; +import { Grid, Group, UnstyledButton, UnstyledButtonProps } from '@mantine/core'; +import { motion, Variants } from 'framer-motion'; import styled from 'styled-components'; interface ContextMenuProps { @@ -22,8 +22,8 @@ const ContextMenuContainer = styled(motion.div) & { + leftIcon?: ReactNode; + rightIcon?: ReactNode; + }, + ref: any, + ) => { + return ( + + + + {leftIcon} + + {children} + + + {rightIcon} + + + + + ); + }, +); + +const variants: Variants = { + closed: { + opacity: 0, + transition: { + duration: 0.1, + }, + }, + open: { + opacity: 1, + transition: { + duration: 0.1, + }, + }, +}; + export const ContextMenu = forwardRef( ({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref) => { return ( - - - {children} - - + + {children} + ); }, ); diff --git a/src/renderer/components/hover-card/index.tsx b/src/renderer/components/hover-card/index.tsx new file mode 100644 index 000000000..a1a367a50 --- /dev/null +++ b/src/renderer/components/hover-card/index.tsx @@ -0,0 +1,23 @@ +import { HoverCard as MantineHoverCard, HoverCardProps } from '@mantine/core'; + +export const HoverCard = ({ children, ...props }: HoverCardProps) => { + return ( + + {children} + + ); +}; + +HoverCard.Target = MantineHoverCard.Target; +HoverCard.Dropdown = MantineHoverCard.Dropdown; diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index a28e0cff3..3b4660def 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -33,3 +33,4 @@ export * from './motion'; export * from './context-menu'; export * from './query-builder'; export * from './rating'; +export * from './hover-card'; diff --git a/src/renderer/components/rating/index.tsx b/src/renderer/components/rating/index.tsx index 8cb1a9b95..21a303d03 100644 --- a/src/renderer/components/rating/index.tsx +++ b/src/renderer/components/rating/index.tsx @@ -1,7 +1,12 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import { MouseEvent } from 'react'; import { Rating as MantineRating, RatingProps as MantineRatingProps } from '@mantine/core'; import styled from 'styled-components'; +import { Tooltip } from '/@/renderer/components/tooltip'; -type RatingProps = MantineRatingProps; +interface RatingProps extends Omit { + onClick: (e: MouseEvent, value: number | undefined) => void; +} const StyledRating = styled(MantineRating)` & .mantine-Rating-symbolBody { @@ -11,6 +16,18 @@ const StyledRating = styled(MantineRating)` } `; -export const Rating = ({ ...props }: RatingProps) => { - return ; +export const Rating = ({ onClick, ...props }: RatingProps) => { + // const debouncedOnClick = debounce(onClick, 100); + + return ( + + onClick(e, props.value)} + /> + + ); }; diff --git a/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx b/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx index 24a65aff1..d9ef82cca 100644 --- a/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx +++ b/src/renderer/components/virtual-grid/grid-card/grid-card-controls.tsx @@ -2,15 +2,18 @@ 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'; -import { DropdownMenu } from '/@/renderer/components/dropdown-menu'; import type { PlayQueueAddOptions } from '/@/renderer/types'; import { Play } from '/@/renderer/types'; import { useSettingsStore } from '/@/renderer/store/settings.store'; import { LibraryItem } from '/@/renderer/api/types'; +import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; +import { + ALBUM_CONTEXT_MENU_ITEMS, + ARTIST_CONTEXT_MENU_ITEMS, +} from '/@/renderer/features/context-menu/context-menu-items'; type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>; @@ -100,21 +103,6 @@ const FavoriteWrapper = styled.span<{ isFavorite: boolean }>` } `; -const PLAY_TYPES = [ - { - label: 'Play', - play: Play.NOW, - }, - { - label: 'Add to queue', - play: Play.LAST, - }, - { - label: 'Add to queue next', - play: Play.NEXT, - }, -]; - export const GridCardControls = ({ itemData, itemType, @@ -152,23 +140,13 @@ export const GridCardControls = ({ }); }; - const openAddToPlaylistModal = (e: MouseEvent) => { - 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', - }); - }; + const handleContextMenu = useHandleGeneralContextMenu( + itemType, + itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS, + ); return ( - {/* */} - {/* */} @@ -191,40 +169,21 @@ export const GridCardControls = ({ )} - { + e.preventDefault(); + e.stopPropagation(); + handleContextMenu(e, [itemData]); + }} > - - { - e.preventDefault(); - e.stopPropagation(); - }} - > - - - - - {PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => ( - ) => handlePlay(e, type.play)} - > - {type.label} - - ))} - - Add to playlist - - - + + diff --git a/src/renderer/components/virtual-table/cells/rating-cell.tsx b/src/renderer/components/virtual-table/cells/rating-cell.tsx index 69ef22b30..011dc3209 100644 --- a/src/renderer/components/virtual-table/cells/rating-cell.tsx +++ b/src/renderer/components/virtual-table/cells/rating-cell.tsx @@ -1,13 +1,49 @@ +import { MouseEvent, useState } from 'react'; import type { ICellRendererParams } from '@ag-grid-community/core'; import { Rating } from '/@/renderer/components/rating'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; +import { useUpdateRating } from '/@/renderer/components/virtual-table/hooks/use-rating'; export const RatingCell = ({ value }: ICellRendererParams) => { + const updateRatingMutation = useUpdateRating(); + const [ratingValue, setRatingValue] = useState(value?.userRating); + + const handleUpdateRating = (rating: number) => { + if (!value) return; + + updateRatingMutation.mutate({ + _serverId: value?.serverId, + query: { + item: [value], + rating, + }, + }); + + setRatingValue(rating); + }; + + const handleClearRating = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + updateRatingMutation.mutate({ + _serverId: value?.serverId, + query: { + item: [value], + rating: 0, + }, + }); + + setRatingValue(0); + }; + return ( ); diff --git a/src/renderer/components/virtual-table/hooks/use-rating.ts b/src/renderer/components/virtual-table/hooks/use-rating.ts new file mode 100644 index 000000000..b34558aee --- /dev/null +++ b/src/renderer/components/virtual-table/hooks/use-rating.ts @@ -0,0 +1,131 @@ +import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { HTTPError } from 'ky'; +import { api } from '/@/renderer/api'; +import { NDAlbumDetail, NDAlbumArtistDetail } from '/@/renderer/api/navidrome.types'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { SSAlbumDetail, SSAlbumArtistDetail } from '/@/renderer/api/subsonic.types'; +import { + RawRatingResponse, + RatingArgs, + Album, + Song, + AlbumArtist, + Artist, + LibraryItem, +} from '/@/renderer/api/types'; +import { + useCurrentServer, + useSetAlbumListItemDataById, + useSetQueueRating, + useAuthStore, +} from '/@/renderer/store'; +import { ServerType } from '/@/renderer/types'; + +export const useUpdateRating = () => { + const queryClient = useQueryClient(); + const currentServer = useCurrentServer(); + const setAlbumListData = useSetAlbumListItemDataById(); + const setQueueRating = useSetQueueRating(); + + return useMutation< + RawRatingResponse, + HTTPError, + Omit, + { previous: { items: Album[] | Song[] | AlbumArtist[] | Artist[] } | undefined } + >({ + mutationFn: (args) => { + const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer; + return api.controller.updateRating({ ...args, server }); + }, + onError: (_error, _variables, context) => { + for (const item of context?.previous?.items || []) { + switch (item.itemType) { + case LibraryItem.ALBUM: + setAlbumListData(item.id, { userRating: item.userRating }); + break; + case LibraryItem.SONG: + setQueueRating([item.id], item.userRating); + break; + } + } + }, + onMutate: (variables) => { + for (const item of variables.query.item) { + switch (item.itemType) { + case LibraryItem.ALBUM: + setAlbumListData(item.id, { userRating: variables.query.rating }); + break; + case LibraryItem.SONG: + setQueueRating([item.id], variables.query.rating); + break; + } + } + + return { previous: { items: variables.query.item } }; + }, + onSuccess: (_data, variables) => { + // We only need to set if we're already on the album detail page + const isAlbumDetailPage = + variables.query.item.length === 1 && variables.query.item[0].itemType === LibraryItem.ALBUM; + + if (isAlbumDetailPage) { + const { serverType, id: albumId, serverId } = variables.query.item[0] as Album; + + const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId }); + const previous = queryClient.getQueryData(queryKey); + if (previous) { + switch (serverType) { + case ServerType.NAVIDROME: + queryClient.setQueryData(queryKey, { + ...previous, + userRating: variables.query.rating, + }); + break; + case ServerType.SUBSONIC: + queryClient.setQueryData(queryKey, { + ...previous, + userRating: variables.query.rating, + }); + break; + case ServerType.JELLYFIN: + // Jellyfin does not support ratings + break; + } + } + } + + // We only need to set if we're already on the album detail page + const isAlbumArtistDetailPage = + variables.query.item.length === 1 && + variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST; + + if (isAlbumArtistDetailPage) { + const { serverType, id: albumArtistId, serverId } = variables.query.item[0] as AlbumArtist; + + const queryKey = queryKeys.albumArtists.detail(serverId || '', { + id: albumArtistId, + }); + const previous = queryClient.getQueryData(queryKey); + if (previous) { + switch (serverType) { + case ServerType.NAVIDROME: + queryClient.setQueryData(queryKey, { + ...previous, + userRating: variables.query.rating, + }); + break; + case ServerType.SUBSONIC: + queryClient.setQueryData(queryKey, { + ...previous, + userRating: variables.query.rating, + }); + break; + case ServerType.JELLYFIN: + // Jellyfin does not support ratings + break; + } + } + } + }, + }); +}; diff --git a/src/renderer/components/virtual-table/index.tsx b/src/renderer/components/virtual-table/index.tsx index a425f7382..5ab03df7f 100644 --- a/src/renderer/components/virtual-table/index.tsx +++ b/src/renderer/components/virtual-table/index.tsx @@ -300,7 +300,7 @@ const tableColumns: { [key: string]: ColDef } = { GenericTableHeader(params, { position: 'center', preset: 'userRating' }), headerName: 'Rating', suppressSizeToFit: true, - valueGetter: (params: ValueGetterParams) => (params.data ? params.data.userRating : undefined), + valueGetter: (params: ValueGetterParams) => (params.data ? params.data : undefined), width: 95, }, }; diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index 9e55afcdd..faaf55de2 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -1,7 +1,6 @@ import { MutableRefObject, useCallback, useMemo } from 'react'; import { Button, - DropdownMenu, getColumnDefs, GridCarousel, TextTitle, @@ -12,7 +11,6 @@ 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'; @@ -22,15 +20,16 @@ import styled from 'styled-components'; import { AppRoute } from '/@/renderer/router/routes'; import { useContainerQuery } from '/@/renderer/hooks'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; -import { useHandleTableContextMenu } from '/@/renderer/features/context-menu'; +import { + useHandleGeneralContextMenu, + useHandleTableContextMenu, +} from '/@/renderer/features/context-menu'; import { Play } from '/@/renderer/types'; -import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { - PlayButton, - PLAY_TYPES, - useCreateFavorite, - useDeleteFavorite, -} from '/@/renderer/features/shared'; + ALBUM_CONTEXT_MENU_ITEMS, + SONG_CONTEXT_MENU_ITEMS, +} from '/@/renderer/features/context-menu/context-menu-items'; +import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared'; import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query'; import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types'; import { usePlayQueueAdd } from '/@/renderer/features/player'; @@ -183,16 +182,10 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => { const { intersectRef, tableContainerRef } = useFixedTableHeader(); - const handleAddToPlaylist = () => { - openContextModal({ - innerProps: { - albumId: [albumId], - }, - modal: 'addToPlaylist', - size: 'md', - title: 'Add to playlist', - }); - }; + const handleGeneralContextMenu = useHandleGeneralContextMenu( + LibraryItem.ALBUM, + ALBUM_CONTEXT_MENU_ITEMS, + ); return ( @@ -220,28 +213,16 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => { )} - - - - - - {PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => ( - handlePlay(type.play)} - > - {type.label} - - ))} - - Add to playlist - - + diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx index ff9df11ed..7bb8d8d7b 100644 --- a/src/renderer/features/albums/components/album-detail-header.tsx +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -2,10 +2,10 @@ import { Group, Stack } from '@mantine/core'; import { forwardRef, Fragment, Ref } from 'react'; import { generatePath, useParams } from 'react-router'; import { Link } from 'react-router-dom'; -import { LibraryItem } from '/@/renderer/api/types'; -import { Text } from '/@/renderer/components'; +import { LibraryItem, ServerType } from '/@/renderer/api/types'; +import { Rating, Text } from '/@/renderer/components'; import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query'; -import { LibraryHeader } from '/@/renderer/features/shared'; +import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared'; import { useContainerQuery } from '/@/renderer/hooks'; import { AppRoute } from '/@/renderer/router/routes'; import { formatDurationString } from '/@/renderer/utils'; @@ -38,6 +38,34 @@ export const AlbumDetailHeader = forwardRef( }, ]; + const updateRatingMutation = useUpdateRating(); + + const handleUpdateRating = (rating: number) => { + if (!detailQuery?.data) return; + + updateRatingMutation.mutate({ + _serverId: detailQuery?.data.serverId, + query: { + item: [detailQuery.data], + rating, + }, + }); + }; + + const handleClearRating = () => { + if (!detailQuery?.data || !detailQuery?.data.userRating) return; + + updateRatingMutation.mutate({ + _serverId: detailQuery.data.serverId, + query: { + item: [detailQuery.data], + rating: 0, + }, + }); + }; + + const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME; + return ( {item.value} ))} + {showRating && ( + <> + + + + )} { } }; - const handleAddToPlaylist = () => { - openContextModal({ - innerProps: { - artistId: [albumArtistId], - }, - modal: 'addToPlaylist', - size: 'md', - title: 'Add to playlist', - }); - }; + const handleGeneralContextMenu = useHandleGeneralContextMenu( + LibraryItem.ALBUM_ARTIST, + ARTIST_CONTEXT_MENU_ITEMS, + ); const topSongs = topSongsQuery?.data?.items?.slice(0, 10); @@ -311,28 +304,16 @@ export const AlbumArtistDetailContent = () => { )} - - - - - - {PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => ( - handlePlay(type.play)} - > - {type.label} - - ))} - - Add to playlist - - + - - - - - - Community - User - - { + if (!detailQuery?.data) return; + + updateRatingMutation.mutate({ + _serverId: detailQuery?.data.serverId, + query: { + item: [detailQuery.data], + rating, + }, + }); + }; + + const handleClearRating = (_e: MouseEvent, rating?: number) => { + if (!detailQuery?.data || !detailQuery?.data.userRating) return; + + console.log(rating, detailQuery.data.userRating); + + const isSameRatingAsPrevious = rating === detailQuery.data.userRating; + if (!isSameRatingAsPrevious) return; + + updateRatingMutation.mutate({ + _serverId: detailQuery.data.serverId, + query: { + item: [detailQuery.data], + rating: 0, + }, + }); + }; + + const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME; + return ( {item.value} ))} + {showRating && ( + <> + + + + )} void; }; +type ContextMenuItem = { + children?: ContextMenuItem[]; + disabled?: boolean; + id: string; + label: string | ReactNode; + leftIcon?: ReactNode; + onClick?: (...args: any) => any; + rightIcon?: ReactNode; +}; + const ContextMenuContext = createContext({ closeContextMenu: () => {}, openContextMenu: (args: OpenContextMenuProps) => { @@ -34,6 +72,7 @@ export interface ContextMenuProviderProps { 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; @@ -53,11 +92,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { const openContextMenu = (args: OpenContextMenuProps) => { const { xPos, yPos, menuItems, data, type, tableRef, dataNodes, context } = args; - const shouldReverseY = yPos + menuRect.height > viewport.height; - const shouldReverseX = xPos + menuRect.width > viewport.width; + // If the context menu dimension can't be automatically calculated, calculate it manually + // This is a hacky way since resize observer may not automatically recalculate when not rendered + const menuHeight = menuRect.height || (menuItems.length + 1) * 50; + const menuWidth = menuRect.width || 220; - const calculatedXPos = shouldReverseX ? xPos - menuRect.width : xPos; - const calculatedYPos = shouldReverseY ? yPos - menuRect.height : yPos; + const shouldReverseY = yPos + menuHeight > viewport.height; + const shouldReverseX = xPos + menuWidth > viewport.width; + + const calculatedXPos = shouldReverseX ? xPos - menuWidth : xPos; + const calculatedYPos = shouldReverseY ? yPos - menuHeight : yPos; setCtx({ context, @@ -90,44 +134,47 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { openContextMenu, }); - const handlePlay = (play: Play) => { - switch (ctx.type) { - case LibraryItem.ALBUM: - handlePlayQueueAdd?.({ - byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, - play, - }); - break; - case LibraryItem.ARTIST: - handlePlayQueueAdd?.({ - byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, - play, - }); - break; - case LibraryItem.ALBUM_ARTIST: - handlePlayQueueAdd?.({ - byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, - play, - }); - break; - case LibraryItem.SONG: - handlePlayQueueAdd?.({ byData: ctx.data, play }); - break; - case LibraryItem.PLAYLIST: - for (const item of ctx.data) { + const handlePlay = useCallback( + (play: Play) => { + switch (ctx.type) { + case LibraryItem.ALBUM: handlePlayQueueAdd?.({ - byItemType: { id: [item.id], type: ctx.type }, + byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, play, }); - } + break; + case LibraryItem.ARTIST: + handlePlayQueueAdd?.({ + byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, + play, + }); + break; + case LibraryItem.ALBUM_ARTIST: + handlePlayQueueAdd?.({ + byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, + play, + }); + break; + case LibraryItem.SONG: + handlePlayQueueAdd?.({ byData: ctx.data, play }); + break; + case LibraryItem.PLAYLIST: + for (const item of ctx.data) { + handlePlayQueueAdd?.({ + byItemType: { id: [item.id], type: ctx.type }, + play, + }); + } - break; - } - }; + break; + } + }, + [ctx.data, ctx.type, handlePlayQueueAdd], + ); const deletePlaylistMutation = useDeletePlaylist(); - const handleDeletePlaylist = () => { + const handleDeletePlaylist = useCallback(() => { for (const item of ctx.data) { deletePlaylistMutation?.mutate( { query: { id: item.id } }, @@ -148,9 +195,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { ); } closeAllModals(); - }; + }, [ctx.data, deletePlaylistMutation]); - const openDeletePlaylistModal = () => { + const openDeletePlaylistModal = useCallback(() => { openModal({ children: ( @@ -170,17 +217,30 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { ), title: 'Delete playlist(s)', }); - }; + }, [ctx.data, handleDeletePlaylist]); const createFavoriteMutation = useCreateFavorite(); const deleteFavoriteMutation = useDeleteFavorite(); - const handleAddToFavorites = () => { - if (!ctx.dataNodes) return; - const nodesToFavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite); + const handleAddToFavorites = useCallback(() => { + if (!ctx.dataNodes && !ctx.data) return; + + let itemsToFavorite: AnyLibraryItems = []; + let nodesToFavorite: RowNode[] = []; + + if (ctx.dataNodes) { + nodesToFavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite); + } else { + itemsToFavorite = ctx.data.filter((item) => !item.userFavorite); + } + + const idsToFavorite = nodesToFavorite + ? nodesToFavorite.map((node) => node.data.id) + : itemsToFavorite.map((item) => item.id); + createFavoriteMutation.mutate( { query: { - id: nodesToFavorite.map((item) => item.data.id), + id: idsToFavorite, type: ctx.type, }, }, @@ -192,22 +252,36 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { }); }, onSuccess: () => { - for (const node of nodesToFavorite) { - node.setData({ ...node.data, userFavorite: true }); + if (ctx.dataNodes) { + for (const node of nodesToFavorite) { + node.setData({ ...node.data, userFavorite: true }); + } } }, }, ); - }; + }, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type]); + + const handleRemoveFromFavorites = useCallback(() => { + if (!ctx.dataNodes && !ctx.data) return; - const handleRemoveFromFavorites = () => { - if (!ctx.dataNodes) return; - const nodesToUnfavorite = ctx.dataNodes.filter((item) => item.data.userFavorite); + let itemsToUnfavorite: AnyLibraryItems = []; + let nodesToUnfavorite: RowNode[] = []; + + if (ctx.dataNodes) { + nodesToUnfavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite); + } else { + itemsToUnfavorite = ctx.data.filter((item) => !item.userFavorite); + } + + const idsToUnfavorite = nodesToUnfavorite + ? nodesToUnfavorite.map((node) => node.data.id) + : itemsToUnfavorite.map((item) => item.id); deleteFavoriteMutation.mutate( { query: { - id: nodesToUnfavorite.map((item) => item.data.id), + id: idsToUnfavorite, type: ctx.type, }, }, @@ -219,28 +293,60 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { }, }, ); - }; + }, [ctx.data, ctx.dataNodes, ctx.type, deleteFavoriteMutation]); + + const handleAddToPlaylist = useCallback(() => { + if (!ctx.dataNodes && !ctx.data) return; + + const albumId: string[] = []; + const artistId: string[] = []; + const songId: string[] = []; + + if (ctx.dataNodes) { + for (const node of ctx.dataNodes) { + switch (node.data.type) { + case LibraryItem.ALBUM: + albumId.push(node.data.id); + break; + case LibraryItem.ARTIST: + artistId.push(node.data.id); + break; + case LibraryItem.SONG: + songId.push(node.data.id); + break; + } + } + } else { + for (const item of ctx.data) { + switch (item.type) { + case LibraryItem.ALBUM: + albumId.push(item.id); + break; + case LibraryItem.ARTIST: + artistId.push(item.id); + break; + case LibraryItem.SONG: + songId.push(item.id); + break; + } + } + } - 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, + albumId: albumId.length > 0 ? albumId : undefined, + artistId: artistId.length > 0 ? artistId : undefined, + songId: songId.length > 0 ? songId : undefined, }, modal: 'addToPlaylist', size: 'md', title: 'Add to playlist', }); - }; + }, [ctx.data, ctx.dataNodes]); const removeFromPlaylistMutation = useRemoveFromPlaylist(); - const handleRemoveFromPlaylist = () => { + const handleRemoveFromPlaylist = useCallback(() => { const songId = (serverType === ServerType.NAVIDROME || ServerType.JELLYFIN ? ctx.dataNodes?.map((node) => node.data.playlistItemId) @@ -284,48 +390,198 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { ), title: 'Remove song(s) from playlist', }); - }; + }, [ + ctx.context?.playlistId, + ctx.context?.tableRef, + ctx.dataNodes, + removeFromPlaylistMutation, + serverType, + ]); - const contextMenuItems = { - addToFavorites: { - id: 'addToFavorites', - label: 'Add to favorites', - onClick: handleAddToFavorites, - }, - addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: handleAddToPlaylist }, - createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} }, - deletePlaylist: { - id: 'deletePlaylist', - label: 'Delete playlist', - onClick: openDeletePlaylistModal, - }, - play: { - id: 'play', - label: 'Play', - onClick: () => handlePlay(Play.NOW), - }, - playLast: { - id: 'playLast', - label: 'Add to queue', - onClick: () => handlePlay(Play.LAST), - }, - playNext: { - id: 'playNext', - label: 'Add to queue next', - onClick: () => handlePlay(Play.NEXT), - }, - removeFromFavorites: { - id: 'removeFromFavorites', - label: 'Remove from favorites', - onClick: handleRemoveFromFavorites, - }, - removeFromPlaylist: { - id: 'removeFromPlaylist', - label: 'Remove from playlist', - onClick: handleRemoveFromPlaylist, + const updateRatingMutation = useUpdateRating(); + + const handleUpdateRating = useCallback( + (rating: number) => { + if (!ctx.dataNodes || !ctx.data) return; + + let uniqueServerIds: string[] = []; + let items: AnyLibraryItems = []; + + if (ctx.dataNodes) { + uniqueServerIds = ctx.dataNodes.reduce((acc, node) => { + if (!acc.includes(node.data.serverId)) { + acc.push(node.data.serverId); + } + return acc; + }, [] as string[]); + } else { + uniqueServerIds = ctx.data.reduce((acc, item) => { + if (!acc.includes(item.serverId)) { + acc.push(item.serverId); + } + return acc; + }, [] as string[]); + } + + for (const serverId of uniqueServerIds) { + if (ctx.dataNodes) { + items = ctx.dataNodes + .filter((node) => node.data.serverId === serverId) + .map((node) => node.data); + } else { + items = ctx.data.filter((item) => item.serverId === serverId); + } + + updateRatingMutation.mutate({ + _serverId: serverId, + query: { + item: items, + rating, + }, + }); + } }, - setRating: { id: 'setRating', label: 'Set rating', onClick: () => {} }, - }; + [ctx.data, ctx.dataNodes, updateRatingMutation], + ); + + const contextMenuItems: Record = useMemo(() => { + return { + addToFavorites: { + id: 'addToFavorites', + label: 'Add to favorites', + leftIcon: , + onClick: handleAddToFavorites, + }, + addToPlaylist: { + id: 'addToPlaylist', + label: 'Add to playlist', + leftIcon: , + onClick: handleAddToPlaylist, + }, + createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} }, + deletePlaylist: { + id: 'deletePlaylist', + label: 'Delete playlist', + leftIcon: , + onClick: openDeletePlaylistModal, + }, + play: { + id: 'play', + label: 'Play', + leftIcon: , + onClick: () => handlePlay(Play.NOW), + }, + playLast: { + id: 'playLast', + label: 'Add to queue', + leftIcon: , + onClick: () => handlePlay(Play.LAST), + }, + playNext: { + id: 'playNext', + label: 'Add to queue next', + leftIcon: , + onClick: () => handlePlay(Play.NEXT), + }, + removeFromFavorites: { + id: 'removeFromFavorites', + label: 'Remove from favorites', + leftIcon: , + onClick: handleRemoveFromFavorites, + }, + removeFromPlaylist: { + id: 'removeFromPlaylist', + label: 'Remove from playlist', + leftIcon: , + onClick: handleRemoveFromPlaylist, + }, + setRating: { + children: [ + { + id: 'zeroStar', + label: ( + {}} + /> + ), + onClick: () => handleUpdateRating(0), + }, + { + id: 'oneStar', + label: ( + {}} + /> + ), + onClick: () => handleUpdateRating(1), + }, + { + id: 'twoStar', + label: ( + {}} + /> + ), + onClick: () => handleUpdateRating(2), + }, + { + id: 'threeStar', + label: ( + {}} + /> + ), + onClick: () => handleUpdateRating(3), + }, + { + id: 'fourStar', + label: ( + {}} + /> + ), + onClick: () => handleUpdateRating(4), + }, + { + id: 'fiveStar', + label: ( + {}} + /> + ), + onClick: () => handleUpdateRating(5), + }, + ], + id: 'setRating', + label: 'Set rating', + leftIcon: , + onClick: () => {}, + rightIcon: , + }, + }; + }, [ + handleAddToFavorites, + handleAddToPlaylist, + handlePlay, + handleRemoveFromFavorites, + handleRemoveFromPlaylist, + handleUpdateRating, + openDeletePlaylistModal, + ]); + + const mergedRef = useMergedRef(ref, clickOutsideRef); return ( { openContextMenu, }} > - {opened && ( - - - {ctx.menuItems?.map((item) => { - return ( - - - {contextMenuItems[item.id as keyof typeof contextMenuItems].label} - - {item.divider && ( - - )} - - ); - })} - - - )} + + + {opened && ( + + + + {ctx.menuItems?.map((item) => { + return ( + + {item.children ? ( + + + + {contextMenuItems[item.id].label} + + + + + {contextMenuItems[item.id].children?.map((child) => ( + <> + + {child.label} + + + ))} + + + + ) : ( + + {contextMenuItems[item.id].label} + + )} - {children} + {item.divider && ( + + )} + + ); + })} + + + {ctx.data?.length} selected + + + )} + + {children} + ); }; diff --git a/src/renderer/features/context-menu/events.ts b/src/renderer/features/context-menu/events.ts index 778810ffa..08306139e 100644 --- a/src/renderer/features/context-menu/events.ts +++ b/src/renderer/features/context-menu/events.ts @@ -20,7 +20,7 @@ export type ContextMenuEvents = { openContextMenu: (args: OpenContextMenuProps) => void; }; -export type ContextMenuItem = +export type ContextMenuItemType = | 'play' | 'playLast' | 'playNext' @@ -33,9 +33,10 @@ export type ContextMenuItem = | 'createPlaylist'; export type SetContextMenuItems = { + children?: boolean; disabled?: boolean; divider?: boolean; - id: ContextMenuItem; + id: ContextMenuItemType; onClick?: () => void; }[]; diff --git a/src/renderer/features/context-menu/hooks/use-handle-context-menu.ts b/src/renderer/features/context-menu/hooks/use-handle-context-menu.ts index 1f0a04437..f844eb0fd 100644 --- a/src/renderer/features/context-menu/hooks/use-handle-context-menu.ts +++ b/src/renderer/features/context-menu/hooks/use-handle-context-menu.ts @@ -1,6 +1,6 @@ import { CellContextMenuEvent } from '@ag-grid-community/core'; import sortBy from 'lodash/sortBy'; -import { LibraryItem } from '/@/renderer/api/types'; +import { Album, AlbumArtist, Artist, LibraryItem, QueueSong, Song } from '/@/renderer/api/types'; import { openContextMenu, SetContextMenuItems } from '/@/renderer/features/context-menu/events'; export const useHandleTableContextMenu = ( @@ -38,3 +38,30 @@ export const useHandleTableContextMenu = ( return handleContextMenu; }; + +export const useHandleGeneralContextMenu = ( + itemType: LibraryItem, + contextMenuItems: SetContextMenuItems, + context?: any, +) => { + const handleContextMenu = ( + e: any, + data: Song[] | QueueSong[] | AlbumArtist[] | Artist[] | Album[], + ) => { + if (!e) return; + const clickEvent = e as MouseEvent; + clickEvent.preventDefault(); + + openContextMenu({ + context, + data, + dataNodes: undefined, + menuItems: contextMenuItems, + type: itemType, + xPos: clickEvent.clientX + 15, + yPos: clickEvent.clientY + 5, + }); + }; + + return handleContextMenu; +}; diff --git a/src/renderer/features/player/components/left-controls.tsx b/src/renderer/features/player/components/left-controls.tsx index 6155ede0c..ca7bebf17 100644 --- a/src/renderer/features/player/components/left-controls.tsx +++ b/src/renderer/features/player/components/left-controls.tsx @@ -1,16 +1,16 @@ import React from 'react'; import { Center, Group } from '@mantine/core'; -import { openContextModal } from '@mantine/modals'; import { motion, AnimatePresence, LayoutGroup } from 'framer-motion'; import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri'; import { generatePath, Link } from 'react-router-dom'; import styled from 'styled-components'; -import { Button, DropdownMenu, Text } from '/@/renderer/components'; +import { Button, Text } from '/@/renderer/components'; import { AppRoute } from '/@/renderer/router/routes'; import { useAppStoreActions, useAppStore, useCurrentSong } from '/@/renderer/store'; import { fadeIn } from '/@/renderer/styles'; -import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared'; import { LibraryItem } from '/@/renderer/api/types'; +import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; +import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu'; const LeftControlsContainer = styled.div` display: flex; @@ -82,41 +82,10 @@ export const LeftControls = () => { const isSongDefined = Boolean(currentSong?.id); - const openAddToPlaylistModal = () => { - openContextModal({ - innerProps: { - songId: [currentSong?.id], - }, - modal: 'addToPlaylist', - size: 'md', - title: 'Add to playlist', - }); - }; - - const addToFavoritesMutation = useCreateFavorite(); - const removeFromFavoritesMutation = useDeleteFavorite(); - - const handleAddToFavorites = () => { - if (!isSongDefined || !currentSong) return; - - addToFavoritesMutation.mutate({ - query: { - id: [currentSong.id], - type: LibraryItem.SONG, - }, - }); - }; - - const handleRemoveFromFavorites = () => { - if (!isSongDefined || !currentSong) return; - - removeFromFavoritesMutation.mutate({ - query: { - id: [currentSong.id], - type: LibraryItem.SONG, - }, - }); - }; + const handleGeneralContextMenu = useHandleGeneralContextMenu( + LibraryItem.SONG, + SONG_CONTEXT_MENU_ITEMS, + ); return ( @@ -196,28 +165,13 @@ export const LeftControls = () => { {title || '—'} {isSongDefined && ( - - - - - - - Add to playlist - - - - Add to favorites - - - Remove from favorites - - - + )} diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx index e10472562..33fd92500 100644 --- a/src/renderer/features/player/components/right-controls.tsx +++ b/src/renderer/features/player/components/right-controls.tsx @@ -1,3 +1,4 @@ +import { MouseEvent } from 'react'; import { Flex, Group } from '@mantine/core'; import { HiOutlineQueueList } from 'react-icons/hi2'; import { @@ -19,7 +20,7 @@ import { import { useRightControls } from '../hooks/use-right-controls'; import { PlayerButton } from './player-button'; import { LibraryItem, ServerType } from '/@/renderer/api/types'; -import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared'; +import { useCreateFavorite, useDeleteFavorite, useUpdateRating } from '/@/renderer/features/shared'; import { Rating } from '/@/renderer/components'; import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider'; @@ -32,6 +33,7 @@ export const RightControls = () => { const { rightExpanded: isQueueExpanded } = useSidebarStore(); const { handleVolumeSlider, handleVolumeWheel, handleMute } = useRightControls(); + const updateRatingMutation = useUpdateRating(); const addToFavoritesMutation = useCreateFavorite(); const removeFromFavoritesMutation = useDeleteFavorite(); const setFavorite = useSetQueueFavorite(); @@ -54,6 +56,30 @@ export const RightControls = () => { ); }; + const handleUpdateRating = (rating: number) => { + if (!currentSong) return; + + updateRatingMutation.mutate({ + _serverId: currentSong?.serverId, + query: { + item: [currentSong], + rating, + }, + }); + }; + + const handleClearRating = (_e: MouseEvent, rating?: number) => { + if (!currentSong || !rating) return; + + updateRatingMutation.mutate({ + _serverId: currentSong?.serverId, + query: { + item: [currentSong], + rating: 0, + }, + }); + }; + const handleRemoveFromFavorites = () => { if (!currentSong) return; @@ -96,9 +122,10 @@ export const RightControls = () => { {showRating && ( )} diff --git a/src/renderer/features/shared/index.ts b/src/renderer/features/shared/index.ts index 8417727f6..371f5c8d6 100644 --- a/src/renderer/features/shared/index.ts +++ b/src/renderer/features/shared/index.ts @@ -6,3 +6,4 @@ export * from './components/library-header'; export * from './components/library-header-bar'; export * from './mutations/create-favorite-mutation'; export * from './mutations/delete-favorite-mutation'; +export * from './mutations/update-rating-mutation'; diff --git a/src/renderer/features/shared/mutations/update-rating-mutation.ts b/src/renderer/features/shared/mutations/update-rating-mutation.ts new file mode 100644 index 000000000..d2ef05659 --- /dev/null +++ b/src/renderer/features/shared/mutations/update-rating-mutation.ts @@ -0,0 +1,133 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { HTTPError } from 'ky'; +import { api } from '/@/renderer/api'; +import { NDAlbumArtistDetail, NDAlbumDetail } from '/@/renderer/api/navidrome.types'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { SSAlbumArtistDetail, SSAlbumDetail } from '/@/renderer/api/subsonic.types'; +import { + Album, + AlbumArtist, + AnyLibraryItems, + LibraryItem, + RatingArgs, + RawRatingResponse, + ServerType, +} from '/@/renderer/api/types'; +import { MutationOptions } from '/@/renderer/lib/react-query'; +import { + useAuthStore, + useCurrentServer, + useSetAlbumListItemDataById, + useSetQueueRating, +} from '/@/renderer/store'; + +export const useUpdateRating = (options?: MutationOptions) => { + const queryClient = useQueryClient(); + const currentServer = useCurrentServer(); + const setAlbumListData = useSetAlbumListItemDataById(); + const setQueueRating = useSetQueueRating(); + + return useMutation< + RawRatingResponse, + HTTPError, + Omit, + { previous: { items: AnyLibraryItems } | undefined } + >({ + mutationFn: (args) => { + const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer; + return api.controller.updateRating({ ...args, server }); + }, + onError: (_error, _variables, context) => { + for (const item of context?.previous?.items || []) { + switch (item.itemType) { + case LibraryItem.ALBUM: + setAlbumListData(item.id, { userRating: item.userRating }); + break; + case LibraryItem.SONG: + setQueueRating([item.id], item.userRating); + break; + } + } + }, + onMutate: (variables) => { + for (const item of variables.query.item) { + switch (item.itemType) { + case LibraryItem.ALBUM: + setAlbumListData(item.id, { userRating: variables.query.rating }); + break; + case LibraryItem.SONG: + setQueueRating([item.id], variables.query.rating); + break; + } + } + + return { previous: { items: variables.query.item } }; + }, + onSuccess: (_data, variables) => { + // We only need to set if we're already on the album detail page + const isAlbumDetailPage = + variables.query.item.length === 1 && variables.query.item[0].itemType === LibraryItem.ALBUM; + + if (isAlbumDetailPage) { + const { serverType, id: albumId, serverId } = variables.query.item[0] as Album; + + const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId }); + const previous = queryClient.getQueryData(queryKey); + if (previous) { + switch (serverType) { + case ServerType.NAVIDROME: + queryClient.setQueryData(queryKey, { + ...previous, + rating: variables.query.rating, + }); + break; + case ServerType.SUBSONIC: + queryClient.setQueryData(queryKey, { + ...previous, + userRating: variables.query.rating, + }); + break; + case ServerType.JELLYFIN: + // Jellyfin does not support ratings + break; + } + } + } + + // We only need to set if we're already on the album detail page + const isAlbumArtistDetailPage = + variables.query.item.length === 1 && + variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST; + + if (isAlbumArtistDetailPage) { + const { serverType, id: albumArtistId, serverId } = variables.query.item[0] as AlbumArtist; + + const queryKey = queryKeys.albumArtists.detail(serverId || '', { + id: albumArtistId, + }); + const previous = queryClient.getQueryData(queryKey); + if (previous) { + switch (serverType) { + case ServerType.NAVIDROME: + queryClient.setQueryData(queryKey, { + ...previous, + rating: variables.query.rating, + }); + break; + case ServerType.SUBSONIC: + queryClient.setQueryData(queryKey, { + ...previous, + userRating: variables.query.rating, + }); + break; + case ServerType.JELLYFIN: + // Jellyfin does not support ratings + break; + } + } + } + }, + + ...options, + }); +}; diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index 64835efa4..e289c3056 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -718,6 +718,15 @@ export const usePlayerStore = create()( } } + const currentSongId = get().current.song?.id; + if (currentSongId && ids.includes(currentSongId)) { + set((state) => { + if (state.current.song) { + state.current.song.userRating = rating; + } + }); + } + return foundUniqueIds; }, setRepeat: (type: PlayerRepeat) => { diff --git a/src/renderer/themes/default.scss b/src/renderer/themes/default.scss index 8712863d4..18f76bfb4 100644 --- a/src/renderer/themes/default.scss +++ b/src/renderer/themes/default.scss @@ -65,7 +65,7 @@ --dropdown-menu-bg: rgb(40, 40, 40); --dropdown-menu-fg: rgb(235, 235, 235); - --dropdown-menu-item-padding: 1rem 0.5rem; + --dropdown-menu-item-padding: 1rem; --dropdown-menu-item-font-size: 1rem; --dropdown-menu-bg-hover: rgb(62, 62, 62); --dropdown-menu-border: none;