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
This commit is contained in:
Jeff 2023-02-05 05:19:01 -08:00 committed by GitHub
parent f50ec5cf31
commit 22fec8f9d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1189 additions and 503 deletions

46
package-lock.json generated
View file

@ -24,8 +24,8 @@
"@mantine/modals": "^6.0.0-alpha.2", "@mantine/modals": "^6.0.0-alpha.2",
"@mantine/notifications": "^6.0.0-alpha.2", "@mantine/notifications": "^6.0.0-alpha.2",
"@mantine/utils": "^6.0.0-alpha.2", "@mantine/utils": "^6.0.0-alpha.2",
"@tanstack/react-query": "^4.16.1", "@tanstack/react-query": "^4.24.4",
"@tanstack/react-query-devtools": "^4.16.1", "@tanstack/react-query-devtools": "^4.24.4",
"@tanstack/react-virtual": "^3.0.0-beta.39", "@tanstack/react-virtual": "^3.0.0-beta.39",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"electron-debug": "^3.2.0", "electron-debug": "^3.2.0",
@ -2230,20 +2230,20 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "4.20.4", "version": "4.24.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.20.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.4.tgz",
"integrity": "sha512-lhLtGVNJDsJ/DyZXrLzekDEywQqRVykgBqTmkv0La32a/RleILXy6JMLBb7UmS3QCatg/F/0N9/5b0i5j6IKcA==", "integrity": "sha512-9dqjv9eeB6VHN7lD3cLo16ZAjfjCsdXetSAD5+VyKqLUvcKTL0CklGQRJu+bWzdrS69R6Ea4UZo8obHYZnG6aA==",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "4.20.4", "version": "4.24.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.20.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.24.4.tgz",
"integrity": "sha512-SJRxx13k/csb9lXAJfycgVA1N/yU/h3bvRNWP0+aHMfMjmbyX82FdoAcckDBbOdEyAupvb0byelNHNeypCFSyA==", "integrity": "sha512-RpaS/3T/a3pHuZJbIAzAYRu+1nkp+/enr9hfRXDS/mojwx567UiMksoqW4wUFWlwIvWTXyhot2nbIipTKEg55Q==",
"dependencies": { "dependencies": {
"@tanstack/query-core": "4.20.4", "@tanstack/query-core": "4.24.4",
"use-sync-external-store": "^1.2.0" "use-sync-external-store": "^1.2.0"
}, },
"funding": { "funding": {
@ -2265,9 +2265,9 @@
} }
}, },
"node_modules/@tanstack/react-query-devtools": { "node_modules/@tanstack/react-query-devtools": {
"version": "4.20.4", "version": "4.24.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.20.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.24.4.tgz",
"integrity": "sha512-4e4wOmqAYjLS1RQ7gRBLCk3koCjbOfCMvbxS3CPCAN5+FLBemLAvoYvFJ/i/7DPsIsltGwsnd7YAFFGMzdSx7A==", "integrity": "sha512-4mldcR99QDX8k94I+STM9gPsYF+FDAD2EQJvHtxR2HrDNegbfmY474xuW0QUZaNW/vJi09Gak6b6Vy2INWhL6w==",
"dependencies": { "dependencies": {
"@tanstack/match-sorter-utils": "^8.7.0", "@tanstack/match-sorter-utils": "^8.7.0",
"superjson": "^1.10.0", "superjson": "^1.10.0",
@ -2278,7 +2278,7 @@
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
}, },
"peerDependencies": { "peerDependencies": {
"@tanstack/react-query": "4.20.4", "@tanstack/react-query": "4.24.4",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^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": { "@tanstack/query-core": {
"version": "4.20.4", "version": "4.24.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.20.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.4.tgz",
"integrity": "sha512-lhLtGVNJDsJ/DyZXrLzekDEywQqRVykgBqTmkv0La32a/RleILXy6JMLBb7UmS3QCatg/F/0N9/5b0i5j6IKcA==" "integrity": "sha512-9dqjv9eeB6VHN7lD3cLo16ZAjfjCsdXetSAD5+VyKqLUvcKTL0CklGQRJu+bWzdrS69R6Ea4UZo8obHYZnG6aA=="
}, },
"@tanstack/react-query": { "@tanstack/react-query": {
"version": "4.20.4", "version": "4.24.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.20.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.24.4.tgz",
"integrity": "sha512-SJRxx13k/csb9lXAJfycgVA1N/yU/h3bvRNWP0+aHMfMjmbyX82FdoAcckDBbOdEyAupvb0byelNHNeypCFSyA==", "integrity": "sha512-RpaS/3T/a3pHuZJbIAzAYRu+1nkp+/enr9hfRXDS/mojwx567UiMksoqW4wUFWlwIvWTXyhot2nbIipTKEg55Q==",
"requires": { "requires": {
"@tanstack/query-core": "4.20.4", "@tanstack/query-core": "4.24.4",
"use-sync-external-store": "^1.2.0" "use-sync-external-store": "^1.2.0"
} }
}, },
"@tanstack/react-query-devtools": { "@tanstack/react-query-devtools": {
"version": "4.20.4", "version": "4.24.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.20.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.24.4.tgz",
"integrity": "sha512-4e4wOmqAYjLS1RQ7gRBLCk3koCjbOfCMvbxS3CPCAN5+FLBemLAvoYvFJ/i/7DPsIsltGwsnd7YAFFGMzdSx7A==", "integrity": "sha512-4mldcR99QDX8k94I+STM9gPsYF+FDAD2EQJvHtxR2HrDNegbfmY474xuW0QUZaNW/vJi09Gak6b6Vy2INWhL6w==",
"requires": { "requires": {
"@tanstack/match-sorter-utils": "^8.7.0", "@tanstack/match-sorter-utils": "^8.7.0",
"superjson": "^1.10.0", "superjson": "^1.10.0",

View file

@ -261,8 +261,8 @@
"@mantine/modals": "^6.0.0-alpha.2", "@mantine/modals": "^6.0.0-alpha.2",
"@mantine/notifications": "^6.0.0-alpha.2", "@mantine/notifications": "^6.0.0-alpha.2",
"@mantine/utils": "^6.0.0-alpha.2", "@mantine/utils": "^6.0.0-alpha.2",
"@tanstack/react-query": "^4.16.1", "@tanstack/react-query": "^4.24.4",
"@tanstack/react-query-devtools": "^4.16.1", "@tanstack/react-query-devtools": "^4.24.4",
"@tanstack/react-virtual": "^3.0.0-beta.39", "@tanstack/react-virtual": "^3.0.0-beta.39",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"electron-debug": "^3.2.0", "electron-debug": "^3.2.0",

View file

@ -322,7 +322,9 @@ const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
const { server, query, signal } = args; const { server, query, signal } = args;
const defaultParams = getDefaultParams(server); 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 = { const searchParams: SSRatingParams = {
id, id,
rating: query.rating, rating: query.rating,
@ -334,13 +336,9 @@ const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
searchParams: parseSearchParams(searchParams), searchParams: parseSearchParams(searchParams),
signal, signal,
}); });
// .json<SSRatingResponse>();
} }
return { return null;
id: query.id,
rating: query.rating,
};
}; };
const getTopSongList = async (args: TopSongListArgs): Promise<SSTopSongList> => { const getTopSongList = async (args: TopSongListArgs): Promise<SSTopSongList> => {

View file

@ -54,6 +54,16 @@ export enum LibraryItem {
SONG = 'song', SONG = 'song',
} }
export type AnyLibraryItem = Album | AlbumArtist | Artist | Playlist | Song | QueueSong;
export type AnyLibraryItems =
| Album[]
| AlbumArtist[]
| Artist[]
| Playlist[]
| Song[]
| QueueSong[];
export enum SortOrder { export enum SortOrder {
ASC = 'ASC', ASC = 'ASC',
DESC = 'DESC', DESC = 'DESC',
@ -773,9 +783,12 @@ export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs;
// Rating // Rating
export type RawRatingResponse = RatingResponse | undefined; 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; export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;

View file

@ -2,15 +2,18 @@ import type { MouseEvent } from 'react';
import React from 'react'; import React from 'react';
import type { UnstyledButtonProps } from '@mantine/core'; import type { UnstyledButtonProps } from '@mantine/core';
import { Group } from '@mantine/core'; import { Group } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri'; import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
import styled from 'styled-components'; import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button'; import { _Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
import type { PlayQueueAddOptions } from '/@/renderer/types'; import type { PlayQueueAddOptions } from '/@/renderer/types';
import { Play } from '/@/renderer/types'; import { Play } from '/@/renderer/types';
import { useSettingsStore } from '/@/renderer/store/settings.store'; import { useSettingsStore } from '/@/renderer/store/settings.store';
import { LibraryItem } from '/@/renderer/api/types'; 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'>; 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 = ({ export const CardControls = ({
itemData, itemData,
itemType, itemType,
@ -138,18 +126,10 @@ export const CardControls = ({
}); });
}; };
const openAddToPlaylistModal = (e: MouseEvent<HTMLButtonElement>) => { const handleContextMenu = useHandleGeneralContextMenu(
e.stopPropagation(); itemType,
openContextModal({ itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
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 ( return (
<GridCardControlsContainer> <GridCardControlsContainer>
@ -175,40 +155,21 @@ export const CardControls = ({
)} )}
</FavoriteWrapper> </FavoriteWrapper>
</SecondaryButton> </SecondaryButton>
<DropdownMenu <SecondaryButton
withinPortal p={5}
position="bottom-start" sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
> >
<DropdownMenu.Target> <RiMore2Fill
<SecondaryButton color="white"
p={5} size={20}
sx={{ svg: { fill: 'white !important' } }} />
variant="subtle" </SecondaryButton>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<RiMore2Fill
color="white"
size={20}
/>
</SecondaryButton>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={(e: MouseEvent<HTMLButtonElement>) => handlePlay(e, type.play)}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Item onClick={openAddToPlaylistModal}>
Add to playlist
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group> </Group>
</BottomControls> </BottomControls>
</GridCardControlsContainer> </GridCardControlsContainer>

View file

@ -1,6 +1,6 @@
import { forwardRef, ReactNode, Ref } from 'react'; import { forwardRef, ReactNode, Ref } from 'react';
import { Portal, UnstyledButton } from '@mantine/core'; import { Grid, Group, UnstyledButton, UnstyledButtonProps } from '@mantine/core';
import { motion } from 'framer-motion'; import { motion, Variants } from 'framer-motion';
import styled from 'styled-components'; import styled from 'styled-components';
interface ContextMenuProps { interface ContextMenuProps {
@ -22,8 +22,8 @@ const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%); box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
`; `;
export const ContextMenuButton = styled(UnstyledButton)` export const StyledContextMenuButton = styled(UnstyledButton)`
padding: 1rem 1.5rem; padding: var(--dropdown-menu-item-padding);
color: var(--dropdown-menu-fg); color: var(--dropdown-menu-fg);
font-weight: 500; font-weight: 500;
font-family: var(--content-font-family); font-family: var(--content-font-family);
@ -46,20 +46,84 @@ export const ContextMenuButton = styled(UnstyledButton)`
} }
`; `;
export const ContextMenu = forwardRef( export const ContextMenuButton = forwardRef(
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => { (
{
children,
rightIcon,
leftIcon,
...props
}: UnstyledButtonProps &
React.ComponentPropsWithoutRef<'button'> & {
leftIcon?: ReactNode;
rightIcon?: ReactNode;
},
ref: any,
) => {
return ( return (
<Portal> <StyledContextMenuButton
<ContextMenuContainer {...props}
ref={ref} key={props.key}
maxWidth={maxWidth} ref={ref}
minWidth={minWidth} as="button"
xPos={xPos} disabled={props.disabled}
yPos={yPos} onClick={props.onClick}
> >
{children} <Grid>
</ContextMenuContainer> <Grid.Col
</Portal> span={2}
sx={{ alignSelf: 'center' }}
>
{leftIcon}
</Grid.Col>
<Grid.Col span={8}>{children} </Grid.Col>
<Grid.Col
span={2}
sx={{ alignSelf: 'center' }}
>
<Group
align="flex-end"
position="right"
>
{rightIcon}
</Group>
</Grid.Col>
</Grid>
</StyledContextMenuButton>
);
},
);
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<HTMLDivElement>) => {
return (
<ContextMenuContainer
ref={ref}
animate="open"
initial="closed"
maxWidth={maxWidth}
minWidth={minWidth}
variants={variants}
xPos={xPos}
yPos={yPos}
>
{children}
</ContextMenuContainer>
); );
}, },
); );

View file

@ -0,0 +1,23 @@
import { HoverCard as MantineHoverCard, HoverCardProps } from '@mantine/core';
export const HoverCard = ({ children, ...props }: HoverCardProps) => {
return (
<MantineHoverCard
styles={{
dropdown: {
background: 'var(--dropdown-menu-bg)',
border: 'none',
boxShadow: '2px 2px 10px 2px rgba(0, 0, 0, 40%)',
margin: 0,
padding: 0,
},
}}
{...props}
>
{children}
</MantineHoverCard>
);
};
HoverCard.Target = MantineHoverCard.Target;
HoverCard.Dropdown = MantineHoverCard.Dropdown;

View file

@ -33,3 +33,4 @@ export * from './motion';
export * from './context-menu'; export * from './context-menu';
export * from './query-builder'; export * from './query-builder';
export * from './rating'; export * from './rating';
export * from './hover-card';

View file

@ -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 { Rating as MantineRating, RatingProps as MantineRatingProps } from '@mantine/core';
import styled from 'styled-components'; import styled from 'styled-components';
import { Tooltip } from '/@/renderer/components/tooltip';
type RatingProps = MantineRatingProps; interface RatingProps extends Omit<MantineRatingProps, 'onClick'> {
onClick: (e: MouseEvent<HTMLDivElement>, value: number | undefined) => void;
}
const StyledRating = styled(MantineRating)` const StyledRating = styled(MantineRating)`
& .mantine-Rating-symbolBody { & .mantine-Rating-symbolBody {
@ -11,6 +16,18 @@ const StyledRating = styled(MantineRating)`
} }
`; `;
export const Rating = ({ ...props }: RatingProps) => { export const Rating = ({ onClick, ...props }: RatingProps) => {
return <StyledRating {...props} />; // const debouncedOnClick = debounce(onClick, 100);
return (
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<StyledRating
{...props}
onDoubleClick={(e) => onClick(e, props.value)}
/>
</Tooltip>
);
}; };

View file

@ -2,15 +2,18 @@ import type { MouseEvent } from 'react';
import React from 'react'; import React from 'react';
import type { UnstyledButtonProps } from '@mantine/core'; import type { UnstyledButtonProps } from '@mantine/core';
import { Group } from '@mantine/core'; import { Group } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri'; import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
import styled from 'styled-components'; import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button'; import { _Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
import type { PlayQueueAddOptions } from '/@/renderer/types'; import type { PlayQueueAddOptions } from '/@/renderer/types';
import { Play } from '/@/renderer/types'; import { Play } from '/@/renderer/types';
import { useSettingsStore } from '/@/renderer/store/settings.store'; import { useSettingsStore } from '/@/renderer/store/settings.store';
import { LibraryItem } from '/@/renderer/api/types'; 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'>; 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 = ({ export const GridCardControls = ({
itemData, itemData,
itemType, itemType,
@ -152,23 +140,13 @@ export const GridCardControls = ({
}); });
}; };
const openAddToPlaylistModal = (e: MouseEvent<HTMLButtonElement>) => { const handleContextMenu = useHandleGeneralContextMenu(
e.stopPropagation(); itemType,
openContextModal({ itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
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 ( return (
<GridCardControlsContainer> <GridCardControlsContainer>
{/* <TopControls /> */}
{/* <CenterControls /> */}
<BottomControls> <BottomControls>
<PlayButton onClick={handlePlay}> <PlayButton onClick={handlePlay}>
<RiPlayFill size={25} /> <RiPlayFill size={25} />
@ -191,40 +169,21 @@ export const GridCardControls = ({
)} )}
</FavoriteWrapper> </FavoriteWrapper>
</SecondaryButton> </SecondaryButton>
<DropdownMenu <SecondaryButton
withinPortal p={5}
position="bottom-start" sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
> >
<DropdownMenu.Target> <RiMore2Fill
<SecondaryButton color="white"
p={5} size={20}
sx={{ svg: { fill: 'white !important' } }} />
variant="subtle" </SecondaryButton>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<RiMore2Fill
color="white"
size={20}
/>
</SecondaryButton>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={(e: MouseEvent<HTMLButtonElement>) => handlePlay(e, type.play)}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Item onClick={openAddToPlaylistModal}>
Add to playlist
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group> </Group>
</BottomControls> </BottomControls>
</GridCardControlsContainer> </GridCardControlsContainer>

View file

@ -1,13 +1,49 @@
import { MouseEvent, useState } from 'react';
import type { ICellRendererParams } from '@ag-grid-community/core'; import type { ICellRendererParams } from '@ag-grid-community/core';
import { Rating } from '/@/renderer/components/rating'; import { Rating } from '/@/renderer/components/rating';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; 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) => { 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<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
updateRatingMutation.mutate({
_serverId: value?.serverId,
query: {
item: [value],
rating: 0,
},
});
setRatingValue(0);
};
return ( return (
<CellContainer position="center"> <CellContainer position="center">
<Rating <Rating
defaultValue={value?.userRating || 0}
size="xs" size="xs"
value={value} value={ratingValue}
onChange={handleUpdateRating}
onClick={handleClearRating}
/> />
</CellContainer> </CellContainer>
); );

View file

@ -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<RatingArgs, 'server'>,
{ 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<any>(queryKey);
if (previous) {
switch (serverType) {
case ServerType.NAVIDROME:
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.SUBSONIC:
queryClient.setQueryData<SSAlbumDetail>(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<any>(queryKey);
if (previous) {
switch (serverType) {
case ServerType.NAVIDROME:
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.SUBSONIC:
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.JELLYFIN:
// Jellyfin does not support ratings
break;
}
}
}
},
});
};

View file

@ -300,7 +300,7 @@ const tableColumns: { [key: string]: ColDef } = {
GenericTableHeader(params, { position: 'center', preset: 'userRating' }), GenericTableHeader(params, { position: 'center', preset: 'userRating' }),
headerName: 'Rating', headerName: 'Rating',
suppressSizeToFit: true, suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.userRating : undefined), valueGetter: (params: ValueGetterParams) => (params.data ? params.data : undefined),
width: 95, width: 95,
}, },
}; };

View file

@ -1,7 +1,6 @@
import { MutableRefObject, useCallback, useMemo } from 'react'; import { MutableRefObject, useCallback, useMemo } from 'react';
import { import {
Button, Button,
DropdownMenu,
getColumnDefs, getColumnDefs,
GridCarousel, GridCarousel,
TextTitle, 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 type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Box, Group, Stack } from '@mantine/core'; import { Box, Group, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks'; import { useSetState } from '@mantine/hooks';
import { openContextModal } from '@mantine/modals';
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri'; import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { generatePath, useParams } from 'react-router'; import { generatePath, useParams } from 'react-router';
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query'; 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 { AppRoute } from '/@/renderer/router/routes';
import { useContainerQuery } from '/@/renderer/hooks'; import { useContainerQuery } from '/@/renderer/hooks';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { 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 { import {
PlayButton, useHandleGeneralContextMenu,
PLAY_TYPES, useHandleTableContextMenu,
useCreateFavorite, } from '/@/renderer/features/context-menu';
useDeleteFavorite, import { Play } from '/@/renderer/types';
} from '/@/renderer/features/shared'; import {
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 { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types'; import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
@ -183,16 +182,10 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
const { intersectRef, tableContainerRef } = useFixedTableHeader(); const { intersectRef, tableContainerRef } = useFixedTableHeader();
const handleAddToPlaylist = () => { const handleGeneralContextMenu = useHandleGeneralContextMenu(
openContextModal({ LibraryItem.ALBUM,
innerProps: { ALBUM_CONTEXT_MENU_ITEMS,
albumId: [albumId], );
},
modal: 'addToPlaylist',
size: 'md',
title: 'Add to playlist',
});
};
return ( return (
<ContentContainer> <ContentContainer>
@ -220,28 +213,16 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
<RiHeartLine size={20} /> <RiHeartLine size={20} />
)} )}
</Button> </Button>
<DropdownMenu position="bottom-start"> <Button
<DropdownMenu.Target> compact
<Button variant="subtle"
compact onClick={(e) => {
variant="subtle" if (!detailQuery?.data) return;
> handleGeneralContextMenu(e, [detailQuery.data!]);
<RiMoreFill size={20} /> }}
</Button> >
</DropdownMenu.Target> <RiMoreFill size={20} />
<DropdownMenu.Dropdown> </Button>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={() => handlePlay(type.play)}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Divider />
<DropdownMenu.Item onClick={handleAddToPlaylist}>Add to playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group> </Group>
</Group> </Group>
</Box> </Box>

View file

@ -2,10 +2,10 @@ import { Group, Stack } from '@mantine/core';
import { forwardRef, Fragment, Ref } from 'react'; import { forwardRef, Fragment, Ref } from 'react';
import { generatePath, useParams } from 'react-router'; import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { LibraryItem } from '/@/renderer/api/types'; import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components'; import { Rating, Text } from '/@/renderer/components';
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query'; 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 { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { formatDurationString } from '/@/renderer/utils'; 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 ( return (
<Stack ref={cq.ref}> <Stack ref={cq.ref}>
<LibraryHeader <LibraryHeader
@ -55,6 +83,17 @@ export const AlbumDetailHeader = forwardRef(
<Text $secondary={item.secondary}>{item.value}</Text> <Text $secondary={item.secondary}>{item.value}</Text>
</Fragment> </Fragment>
))} ))}
{showRating && (
<>
<Text $noSelect></Text>
<Rating
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
value={detailQuery?.data?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</>
)}
</Group> </Group>
<Group <Group
spacing="sm" spacing="sm"

View file

@ -1,7 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import {
Button, Button,
DropdownMenu,
getColumnDefs, getColumnDefs,
GridCarousel, GridCarousel,
Text, Text,
@ -10,8 +9,7 @@ import {
} from '/@/renderer/components'; } from '/@/renderer/components';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core'; import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import { Box, Group, Stack } from '@mantine/core'; import { Box, Group, Stack } from '@mantine/core';
import { openContextModal } from '@mantine/modals'; import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { RiArrowDownSLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { generatePath, useParams } from 'react-router'; import { generatePath, useParams } from 'react-router';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { createSearchParams, Link } from 'react-router-dom'; import { createSearchParams, Link } from 'react-router-dom';
@ -19,15 +17,16 @@ import styled from 'styled-components';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useContainerQuery } from '/@/renderer/hooks'; import { useContainerQuery } from '/@/renderer/hooks';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { Play, TableColumn } from '/@/renderer/types';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { import {
PlayButton, useHandleGeneralContextMenu,
PLAY_TYPES, useHandleTableContextMenu,
useCreateFavorite, } from '/@/renderer/features/context-menu';
useDeleteFavorite, import { Play, TableColumn } from '/@/renderer/types';
} from '/@/renderer/features/shared'; import {
ARTIST_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 { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import { import {
AlbumListSort, AlbumListSort,
@ -267,16 +266,10 @@ export const AlbumArtistDetailContent = () => {
} }
}; };
const handleAddToPlaylist = () => { const handleGeneralContextMenu = useHandleGeneralContextMenu(
openContextModal({ LibraryItem.ALBUM_ARTIST,
innerProps: { ARTIST_CONTEXT_MENU_ITEMS,
artistId: [albumArtistId], );
},
modal: 'addToPlaylist',
size: 'md',
title: 'Add to playlist',
});
};
const topSongs = topSongsQuery?.data?.items?.slice(0, 10); const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
@ -311,28 +304,16 @@ export const AlbumArtistDetailContent = () => {
<RiHeartLine size={20} /> <RiHeartLine size={20} />
)} )}
</Button> </Button>
<DropdownMenu position="bottom-start"> <Button
<DropdownMenu.Target> compact
<Button variant="subtle"
compact onClick={(e) => {
variant="subtle" if (!detailQuery?.data) return;
> handleGeneralContextMenu(e, [detailQuery.data!]);
<RiMoreFill size={20} /> }}
</Button> >
</DropdownMenu.Target> <RiMoreFill size={20} />
<DropdownMenu.Dropdown> </Button>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={() => handlePlay(type.play)}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Divider />
<DropdownMenu.Item onClick={handleAddToPlaylist}>Add to playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button <Button
compact compact
uppercase uppercase
@ -422,22 +403,6 @@ export const AlbumArtistDetailContent = () => {
View all View all
</Button> </Button>
</Group> </Group>
<DropdownMenu>
<DropdownMenu.Target>
<Button
compact
uppercase
rightIcon={<RiArrowDownSLine size={20} />}
variant="subtle"
>
Community
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item>Community</DropdownMenu.Item>
<DropdownMenu.Item>User</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group> </Group>
<VirtualTable <VirtualTable
autoFitColumns autoFitColumns

View file

@ -1,10 +1,10 @@
import { Group, Stack } from '@mantine/core'; import { Group, Rating, Stack } from '@mantine/core';
import { forwardRef, Fragment, Ref } from 'react'; import { forwardRef, Fragment, Ref, MouseEvent } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { LibraryItem } from '/@/renderer/api/types'; import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components'; import { Text } from '/@/renderer/components';
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query'; import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
import { LibraryHeader } from '/@/renderer/features/shared'; import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks'; import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { formatDurationString } from '/@/renderer/utils'; import { formatDurationString } from '/@/renderer/utils';
@ -37,6 +37,39 @@ export const AlbumArtistDetailHeader = 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 = (_e: MouseEvent<HTMLDivElement>, 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 ( return (
<Stack ref={cq.ref}> <Stack ref={cq.ref}>
<LibraryHeader <LibraryHeader
@ -56,6 +89,17 @@ export const AlbumArtistDetailHeader = forwardRef(
<Text $secondary={item.secondary}>{item.value}</Text> <Text $secondary={item.secondary}>{item.value}</Text>
</Fragment> </Fragment>
))} ))}
{showRating && (
<>
<Text $noSelect></Text>
<Rating
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
value={detailQuery?.data?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</>
)}
</Group> </Group>
<Group <Group
sx={{ sx={{

View file

@ -7,7 +7,7 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' }, { divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
]; ];
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@ -18,7 +18,7 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'removeFromPlaylist' }, { divider: true, id: 'removeFromPlaylist' },
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
]; ];
export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@ -28,7 +28,7 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' }, { divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ id: 'removeFromFavorites' }, { id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
]; ];
export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@ -38,7 +38,7 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' }, { divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
]; ];
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [

View file

@ -1,17 +1,45 @@
import { Divider, Group, Stack } from '@mantine/core'; import { RowNode } from '@ag-grid-community/core';
import { useClickOutside, useResizeObserver, useSetState, useViewportSize } from '@mantine/hooks'; import { Divider, Group, Portal, Stack } from '@mantine/core';
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
import { createContext, Fragment, useState } from 'react';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { ConfirmModal, ContextMenu, ContextMenuButton, Text, toast } from '/@/renderer/components';
import { import {
useClickOutside,
useMergedRef,
useResizeObserver,
useSetState,
useViewportSize,
} from '@mantine/hooks';
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
import { AnimatePresence } from 'framer-motion';
import { createContext, Fragment, ReactNode, useState, useMemo, useCallback } from 'react';
import {
RiAddBoxFill,
RiAddCircleFill,
RiArrowRightSFill,
RiDeleteBinFill,
RiDislikeFill,
RiHeartFill,
RiPlayFill,
RiPlayListAddFill,
RiStarFill,
} from 'react-icons/ri';
import { AnyLibraryItems, LibraryItem, ServerType } from '/@/renderer/api/types';
import {
ConfirmModal,
ContextMenu,
ContextMenuButton,
HoverCard,
Rating,
Text,
toast,
} from '/@/renderer/components';
import {
ContextMenuItemType,
OpenContextMenuProps, OpenContextMenuProps,
useContextMenuEvents, useContextMenuEvents,
} from '/@/renderer/features/context-menu/events'; } from '/@/renderer/features/context-menu/events';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useDeletePlaylist } from '/@/renderer/features/playlists'; import { useDeletePlaylist } from '/@/renderer/features/playlists';
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation'; import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared'; import { useCreateFavorite, useDeleteFavorite, useUpdateRating } from '/@/renderer/features/shared';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { Play } from '/@/renderer/types'; import { Play } from '/@/renderer/types';
@ -20,6 +48,16 @@ type ContextMenuContextProps = {
openContextMenu: (args: OpenContextMenuProps) => void; openContextMenu: (args: OpenContextMenuProps) => void;
}; };
type ContextMenuItem = {
children?: ContextMenuItem[];
disabled?: boolean;
id: string;
label: string | ReactNode;
leftIcon?: ReactNode;
onClick?: (...args: any) => any;
rightIcon?: ReactNode;
};
const ContextMenuContext = createContext<ContextMenuContextProps>({ const ContextMenuContext = createContext<ContextMenuContextProps>({
closeContextMenu: () => {}, closeContextMenu: () => {},
openContextMenu: (args: OpenContextMenuProps) => { openContextMenu: (args: OpenContextMenuProps) => {
@ -34,6 +72,7 @@ export interface ContextMenuProviderProps {
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const clickOutsideRef = useClickOutside(() => setOpened(false)); const clickOutsideRef = useClickOutside(() => setOpened(false));
const viewport = useViewportSize(); const viewport = useViewportSize();
const server = useCurrentServer(); const server = useCurrentServer();
const serverType = server?.type; const serverType = server?.type;
@ -53,11 +92,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const openContextMenu = (args: OpenContextMenuProps) => { const openContextMenu = (args: OpenContextMenuProps) => {
const { xPos, yPos, menuItems, data, type, tableRef, dataNodes, context } = args; const { xPos, yPos, menuItems, data, type, tableRef, dataNodes, context } = args;
const shouldReverseY = yPos + menuRect.height > viewport.height; // If the context menu dimension can't be automatically calculated, calculate it manually
const shouldReverseX = xPos + menuRect.width > viewport.width; // 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 shouldReverseY = yPos + menuHeight > viewport.height;
const calculatedYPos = shouldReverseY ? yPos - menuRect.height : yPos; const shouldReverseX = xPos + menuWidth > viewport.width;
const calculatedXPos = shouldReverseX ? xPos - menuWidth : xPos;
const calculatedYPos = shouldReverseY ? yPos - menuHeight : yPos;
setCtx({ setCtx({
context, context,
@ -90,44 +134,47 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
openContextMenu, openContextMenu,
}); });
const handlePlay = (play: Play) => { const handlePlay = useCallback(
switch (ctx.type) { (play: Play) => {
case LibraryItem.ALBUM: switch (ctx.type) {
handlePlayQueueAdd?.({ case LibraryItem.ALBUM:
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?.({ handlePlayQueueAdd?.({
byItemType: { id: [item.id], type: ctx.type }, byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
play, 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 deletePlaylistMutation = useDeletePlaylist();
const handleDeletePlaylist = () => { const handleDeletePlaylist = useCallback(() => {
for (const item of ctx.data) { for (const item of ctx.data) {
deletePlaylistMutation?.mutate( deletePlaylistMutation?.mutate(
{ query: { id: item.id } }, { query: { id: item.id } },
@ -148,9 +195,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
); );
} }
closeAllModals(); closeAllModals();
}; }, [ctx.data, deletePlaylistMutation]);
const openDeletePlaylistModal = () => { const openDeletePlaylistModal = useCallback(() => {
openModal({ openModal({
children: ( children: (
<ConfirmModal onConfirm={handleDeletePlaylist}> <ConfirmModal onConfirm={handleDeletePlaylist}>
@ -170,17 +217,30 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
), ),
title: 'Delete playlist(s)', title: 'Delete playlist(s)',
}); });
}; }, [ctx.data, handleDeletePlaylist]);
const createFavoriteMutation = useCreateFavorite(); const createFavoriteMutation = useCreateFavorite();
const deleteFavoriteMutation = useDeleteFavorite(); const deleteFavoriteMutation = useDeleteFavorite();
const handleAddToFavorites = () => { const handleAddToFavorites = useCallback(() => {
if (!ctx.dataNodes) return; if (!ctx.dataNodes && !ctx.data) return;
const nodesToFavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite);
let itemsToFavorite: AnyLibraryItems = [];
let nodesToFavorite: RowNode<any>[] = [];
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( createFavoriteMutation.mutate(
{ {
query: { query: {
id: nodesToFavorite.map((item) => item.data.id), id: idsToFavorite,
type: ctx.type, type: ctx.type,
}, },
}, },
@ -192,22 +252,36 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
}); });
}, },
onSuccess: () => { onSuccess: () => {
for (const node of nodesToFavorite) { if (ctx.dataNodes) {
node.setData({ ...node.data, userFavorite: true }); for (const node of nodesToFavorite) {
node.setData({ ...node.data, userFavorite: true });
}
} }
}, },
}, },
); );
}; }, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type]);
const handleRemoveFromFavorites = () => { const handleRemoveFromFavorites = useCallback(() => {
if (!ctx.dataNodes) return; if (!ctx.dataNodes && !ctx.data) return;
const nodesToUnfavorite = ctx.dataNodes.filter((item) => item.data.userFavorite);
let itemsToUnfavorite: AnyLibraryItems = [];
let nodesToUnfavorite: RowNode<any>[] = [];
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( deleteFavoriteMutation.mutate(
{ {
query: { query: {
id: nodesToUnfavorite.map((item) => item.data.id), id: idsToUnfavorite,
type: ctx.type, 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({ openContextModal({
innerProps: { innerProps: {
albumId: albumId: albumId.length > 0 ? albumId : undefined,
ctx.type === LibraryItem.ALBUM ? ctx.dataNodes.map((node) => node.data.id) : undefined, artistId: artistId.length > 0 ? artistId : undefined,
artistId: songId: songId.length > 0 ? songId : undefined,
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', modal: 'addToPlaylist',
size: 'md', size: 'md',
title: 'Add to playlist', title: 'Add to playlist',
}); });
}; }, [ctx.data, ctx.dataNodes]);
const removeFromPlaylistMutation = useRemoveFromPlaylist(); const removeFromPlaylistMutation = useRemoveFromPlaylist();
const handleRemoveFromPlaylist = () => { const handleRemoveFromPlaylist = useCallback(() => {
const songId = const songId =
(serverType === ServerType.NAVIDROME || ServerType.JELLYFIN (serverType === ServerType.NAVIDROME || ServerType.JELLYFIN
? ctx.dataNodes?.map((node) => node.data.playlistItemId) ? ctx.dataNodes?.map((node) => node.data.playlistItemId)
@ -284,48 +390,198 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
), ),
title: 'Remove song(s) from playlist', title: 'Remove song(s) from playlist',
}); });
}; }, [
ctx.context?.playlistId,
ctx.context?.tableRef,
ctx.dataNodes,
removeFromPlaylistMutation,
serverType,
]);
const contextMenuItems = { const updateRatingMutation = useUpdateRating();
addToFavorites: {
id: 'addToFavorites', const handleUpdateRating = useCallback(
label: 'Add to favorites', (rating: number) => {
onClick: handleAddToFavorites, 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,
},
});
}
}, },
addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: handleAddToPlaylist }, [ctx.data, ctx.dataNodes, updateRatingMutation],
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} }, );
deletePlaylist: {
id: 'deletePlaylist', const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
label: 'Delete playlist', return {
onClick: openDeletePlaylistModal, addToFavorites: {
}, id: 'addToFavorites',
play: { label: 'Add to favorites',
id: 'play', leftIcon: <RiHeartFill size="1.1rem" />,
label: 'Play', onClick: handleAddToFavorites,
onClick: () => handlePlay(Play.NOW), },
}, addToPlaylist: {
playLast: { id: 'addToPlaylist',
id: 'playLast', label: 'Add to playlist',
label: 'Add to queue', leftIcon: <RiPlayListAddFill size="1.1rem" />,
onClick: () => handlePlay(Play.LAST), onClick: handleAddToPlaylist,
}, },
playNext: { createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
id: 'playNext', deletePlaylist: {
label: 'Add to queue next', id: 'deletePlaylist',
onClick: () => handlePlay(Play.NEXT), label: 'Delete playlist',
}, leftIcon: <RiDeleteBinFill size="1.1rem" />,
removeFromFavorites: { onClick: openDeletePlaylistModal,
id: 'removeFromFavorites', },
label: 'Remove from favorites', play: {
onClick: handleRemoveFromFavorites, id: 'play',
}, label: 'Play',
removeFromPlaylist: { leftIcon: <RiPlayFill size="1.1rem" />,
id: 'removeFromPlaylist', onClick: () => handlePlay(Play.NOW),
label: 'Remove from playlist', },
onClick: handleRemoveFromPlaylist, playLast: {
}, id: 'playLast',
setRating: { id: 'setRating', label: 'Set rating', onClick: () => {} }, label: 'Add to queue',
}; leftIcon: <RiAddBoxFill size="1.1rem" />,
onClick: () => handlePlay(Play.LAST),
},
playNext: {
id: 'playNext',
label: 'Add to queue next',
leftIcon: <RiAddCircleFill size="1.1rem" />,
onClick: () => handlePlay(Play.NEXT),
},
removeFromFavorites: {
id: 'removeFromFavorites',
label: 'Remove from favorites',
leftIcon: <RiDislikeFill size="1.1rem" />,
onClick: handleRemoveFromFavorites,
},
removeFromPlaylist: {
id: 'removeFromPlaylist',
label: 'Remove from playlist',
leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: handleRemoveFromPlaylist,
},
setRating: {
children: [
{
id: 'zeroStar',
label: (
<Rating
readOnly
value={0}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(0),
},
{
id: 'oneStar',
label: (
<Rating
readOnly
value={1}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(1),
},
{
id: 'twoStar',
label: (
<Rating
readOnly
value={2}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(2),
},
{
id: 'threeStar',
label: (
<Rating
readOnly
value={3}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(3),
},
{
id: 'fourStar',
label: (
<Rating
readOnly
value={4}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(4),
},
{
id: 'fiveStar',
label: (
<Rating
readOnly
value={5}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(5),
},
],
id: 'setRating',
label: 'Set rating',
leftIcon: <RiStarFill size="1.1rem" />,
onClick: () => {},
rightIcon: <RiArrowRightSFill size="1.2rem" />,
},
};
}, [
handleAddToFavorites,
handleAddToPlaylist,
handlePlay,
handleRemoveFromFavorites,
handleRemoveFromPlaylist,
handleUpdateRating,
openDeletePlaylistModal,
]);
const mergedRef = useMergedRef(ref, clickOutsideRef);
return ( return (
<ContextMenuContext.Provider <ContextMenuContext.Provider
@ -334,43 +590,89 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
openContextMenu, openContextMenu,
}} }}
> >
{opened && ( <Portal>
<ContextMenu <AnimatePresence>
ref={ref} {opened && (
minWidth={125} <ContextMenu
xPos={ctx.xPos} ref={mergedRef}
yPos={ctx.yPos} minWidth={125}
> xPos={ctx.xPos}
<Stack yPos={ctx.yPos}
ref={clickOutsideRef} >
spacing={0} <Stack spacing={0}>
onClick={closeContextMenu} <Stack
> spacing={0}
{ctx.menuItems?.map((item) => { onClick={closeContextMenu}
return ( >
<Fragment key={`context-menu-${item.id}`}> {ctx.menuItems?.map((item) => {
<ContextMenuButton return (
as="button" <Fragment key={`context-menu-${item.id}`}>
disabled={item.disabled} {item.children ? (
onClick={contextMenuItems[item.id as keyof typeof contextMenuItems].onClick} <HoverCard
> offset={5}
{contextMenuItems[item.id as keyof typeof contextMenuItems].label} position="right"
</ContextMenuButton> >
{item.divider && ( <HoverCard.Target>
<Divider <ContextMenuButton
key={`context-menu-divider-${item.id}`} disabled={item.disabled}
color="rgb(62, 62, 62)" leftIcon={contextMenuItems[item.id].leftIcon}
size="sm" rightIcon={contextMenuItems[item.id].rightIcon}
/> onClick={contextMenuItems[item.id].onClick}
)} >
</Fragment> {contextMenuItems[item.id].label}
); </ContextMenuButton>
})} </HoverCard.Target>
</Stack> <HoverCard.Dropdown>
</ContextMenu> <Stack spacing={0}>
)} {contextMenuItems[item.id].children?.map((child) => (
<>
<ContextMenuButton
key={`sub-${child.id}`}
disabled={child.disabled}
leftIcon={child.leftIcon}
rightIcon={child.rightIcon}
onClick={child.onClick}
>
{child.label}
</ContextMenuButton>
</>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
) : (
<ContextMenuButton
disabled={item.disabled}
leftIcon={contextMenuItems[item.id].leftIcon}
rightIcon={contextMenuItems[item.id].rightIcon}
onClick={contextMenuItems[item.id].onClick}
>
{contextMenuItems[item.id].label}
</ContextMenuButton>
)}
{children} {item.divider && (
<Divider
key={`context-menu-divider-${item.id}`}
color="rgb(62, 62, 62)"
size="sm"
/>
)}
</Fragment>
);
})}
</Stack>
<Divider
color="rgb(62, 62, 62)"
size="sm"
/>
<ContextMenuButton disabled>{ctx.data?.length} selected</ContextMenuButton>
</Stack>
</ContextMenu>
)}
</AnimatePresence>
{children}
</Portal>
</ContextMenuContext.Provider> </ContextMenuContext.Provider>
); );
}; };

View file

@ -20,7 +20,7 @@ export type ContextMenuEvents = {
openContextMenu: (args: OpenContextMenuProps) => void; openContextMenu: (args: OpenContextMenuProps) => void;
}; };
export type ContextMenuItem = export type ContextMenuItemType =
| 'play' | 'play'
| 'playLast' | 'playLast'
| 'playNext' | 'playNext'
@ -33,9 +33,10 @@ export type ContextMenuItem =
| 'createPlaylist'; | 'createPlaylist';
export type SetContextMenuItems = { export type SetContextMenuItems = {
children?: boolean;
disabled?: boolean; disabled?: boolean;
divider?: boolean; divider?: boolean;
id: ContextMenuItem; id: ContextMenuItemType;
onClick?: () => void; onClick?: () => void;
}[]; }[];

View file

@ -1,6 +1,6 @@
import { CellContextMenuEvent } from '@ag-grid-community/core'; import { CellContextMenuEvent } from '@ag-grid-community/core';
import sortBy from 'lodash/sortBy'; 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'; import { openContextMenu, SetContextMenuItems } from '/@/renderer/features/context-menu/events';
export const useHandleTableContextMenu = ( export const useHandleTableContextMenu = (
@ -38,3 +38,30 @@ export const useHandleTableContextMenu = (
return handleContextMenu; 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;
};

View file

@ -1,16 +1,16 @@
import React from 'react'; import React from 'react';
import { Center, Group } from '@mantine/core'; import { Center, Group } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion'; import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri'; import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri';
import { generatePath, Link } from 'react-router-dom'; import { generatePath, Link } from 'react-router-dom';
import styled from 'styled-components'; 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 { AppRoute } from '/@/renderer/router/routes';
import { useAppStoreActions, useAppStore, useCurrentSong } from '/@/renderer/store'; import { useAppStoreActions, useAppStore, useCurrentSong } from '/@/renderer/store';
import { fadeIn } from '/@/renderer/styles'; import { fadeIn } from '/@/renderer/styles';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { LibraryItem } from '/@/renderer/api/types'; 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` const LeftControlsContainer = styled.div`
display: flex; display: flex;
@ -82,41 +82,10 @@ export const LeftControls = () => {
const isSongDefined = Boolean(currentSong?.id); const isSongDefined = Boolean(currentSong?.id);
const openAddToPlaylistModal = () => { const handleGeneralContextMenu = useHandleGeneralContextMenu(
openContextModal({ LibraryItem.SONG,
innerProps: { SONG_CONTEXT_MENU_ITEMS,
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,
},
});
};
return ( return (
<LeftControlsContainer> <LeftControlsContainer>
@ -196,28 +165,13 @@ export const LeftControls = () => {
{title || '—'} {title || '—'}
</Text> </Text>
{isSongDefined && ( {isSongDefined && (
<DropdownMenu> <Button
<DropdownMenu.Target> compact
<Button variant="subtle"
compact onClick={(e) => handleGeneralContextMenu(e, [currentSong!])}
variant="subtle" >
> <RiMore2Fill size="1.2rem" />
<RiMore2Fill size="1.2rem" /> </Button>
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item onClick={openAddToPlaylistModal}>
Add to playlist
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item onClick={handleAddToFavorites}>
Add to favorites
</DropdownMenu.Item>
<DropdownMenu.Item onClick={handleRemoveFromFavorites}>
Remove from favorites
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
)} )}
</Group> </Group>
</LineItem> </LineItem>

View file

@ -1,3 +1,4 @@
import { MouseEvent } from 'react';
import { Flex, Group } from '@mantine/core'; import { Flex, Group } from '@mantine/core';
import { HiOutlineQueueList } from 'react-icons/hi2'; import { HiOutlineQueueList } from 'react-icons/hi2';
import { import {
@ -19,7 +20,7 @@ import {
import { useRightControls } from '../hooks/use-right-controls'; import { useRightControls } from '../hooks/use-right-controls';
import { PlayerButton } from './player-button'; import { PlayerButton } from './player-button';
import { LibraryItem, ServerType } from '/@/renderer/api/types'; 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 { Rating } from '/@/renderer/components';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider'; import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
@ -32,6 +33,7 @@ export const RightControls = () => {
const { rightExpanded: isQueueExpanded } = useSidebarStore(); const { rightExpanded: isQueueExpanded } = useSidebarStore();
const { handleVolumeSlider, handleVolumeWheel, handleMute } = useRightControls(); const { handleVolumeSlider, handleVolumeWheel, handleMute } = useRightControls();
const updateRatingMutation = useUpdateRating();
const addToFavoritesMutation = useCreateFavorite(); const addToFavoritesMutation = useCreateFavorite();
const removeFromFavoritesMutation = useDeleteFavorite(); const removeFromFavoritesMutation = useDeleteFavorite();
const setFavorite = useSetQueueFavorite(); 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<HTMLDivElement>, rating?: number) => {
if (!currentSong || !rating) return;
updateRatingMutation.mutate({
_serverId: currentSong?.serverId,
query: {
item: [currentSong],
rating: 0,
},
});
};
const handleRemoveFromFavorites = () => { const handleRemoveFromFavorites = () => {
if (!currentSong) return; if (!currentSong) return;
@ -96,9 +122,10 @@ export const RightControls = () => {
<Group h="calc(100% / 3)"> <Group h="calc(100% / 3)">
{showRating && ( {showRating && (
<Rating <Rating
readOnly
size="sm" size="sm"
value={currentSong?.userRating ?? 0} value={currentSong?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/> />
)} )}
</Group> </Group>

View file

@ -6,3 +6,4 @@ export * from './components/library-header';
export * from './components/library-header-bar'; export * from './components/library-header-bar';
export * from './mutations/create-favorite-mutation'; export * from './mutations/create-favorite-mutation';
export * from './mutations/delete-favorite-mutation'; export * from './mutations/delete-favorite-mutation';
export * from './mutations/update-rating-mutation';

View file

@ -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<RatingArgs, 'server'>,
{ 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<any>(queryKey);
if (previous) {
switch (serverType) {
case ServerType.NAVIDROME:
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
...previous,
rating: variables.query.rating,
});
break;
case ServerType.SUBSONIC:
queryClient.setQueryData<SSAlbumDetail>(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<any>(queryKey);
if (previous) {
switch (serverType) {
case ServerType.NAVIDROME:
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
...previous,
rating: variables.query.rating,
});
break;
case ServerType.SUBSONIC:
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.JELLYFIN:
// Jellyfin does not support ratings
break;
}
}
}
},
...options,
});
};

View file

@ -718,6 +718,15 @@ export const usePlayerStore = create<PlayerSlice>()(
} }
} }
const currentSongId = get().current.song?.id;
if (currentSongId && ids.includes(currentSongId)) {
set((state) => {
if (state.current.song) {
state.current.song.userRating = rating;
}
});
}
return foundUniqueIds; return foundUniqueIds;
}, },
setRepeat: (type: PlayerRepeat) => { setRepeat: (type: PlayerRepeat) => {

View file

@ -65,7 +65,7 @@
--dropdown-menu-bg: rgb(40, 40, 40); --dropdown-menu-bg: rgb(40, 40, 40);
--dropdown-menu-fg: rgb(235, 235, 235); --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-item-font-size: 1rem;
--dropdown-menu-bg-hover: rgb(62, 62, 62); --dropdown-menu-bg-hover: rgb(62, 62, 62);
--dropdown-menu-border: none; --dropdown-menu-border: none;