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:
parent
f50ec5cf31
commit
22fec8f9d3
27 changed files with 1189 additions and 503 deletions
46
package-lock.json
generated
46
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -322,7 +322,9 @@ const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
|
|||
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<RatingResponse> => {
|
|||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
// .json<SSRatingResponse>();
|
||||
}
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
rating: query.rating,
|
||||
};
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs): Promise<SSTopSongList> => {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
openContextModal({
|
||||
innerProps: {
|
||||
albumId: itemType === LibraryItem.ALBUM ? [itemData.id] : undefined,
|
||||
artistId: itemType === LibraryItem.ALBUM_ARTIST ? [itemData.id] : undefined,
|
||||
},
|
||||
modal: 'addToPlaylist',
|
||||
size: 'md',
|
||||
title: 'Add to playlist',
|
||||
});
|
||||
};
|
||||
const handleContextMenu = useHandleGeneralContextMenu(
|
||||
itemType,
|
||||
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
return (
|
||||
<GridCardControlsContainer>
|
||||
|
@ -175,40 +155,21 @@ export const CardControls = ({
|
|||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<DropdownMenu
|
||||
withinPortal
|
||||
position="bottom-start"
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
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>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</Group>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
|
|
|
@ -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)<Omit<ContextMenuProps, 'children
|
|||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
|
||||
`;
|
||||
|
||||
export const ContextMenuButton = styled(UnstyledButton)`
|
||||
padding: 1rem 1.5rem;
|
||||
export const StyledContextMenuButton = styled(UnstyledButton)`
|
||||
padding: var(--dropdown-menu-item-padding);
|
||||
color: var(--dropdown-menu-fg);
|
||||
font-weight: 500;
|
||||
font-family: var(--content-font-family);
|
||||
|
@ -46,20 +46,84 @@ export const ContextMenuButton = styled(UnstyledButton)`
|
|||
}
|
||||
`;
|
||||
|
||||
export const ContextMenu = forwardRef(
|
||||
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
|
||||
export const ContextMenuButton = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
rightIcon,
|
||||
leftIcon,
|
||||
...props
|
||||
}: UnstyledButtonProps &
|
||||
React.ComponentPropsWithoutRef<'button'> & {
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
},
|
||||
ref: any,
|
||||
) => {
|
||||
return (
|
||||
<Portal>
|
||||
<ContextMenuContainer
|
||||
ref={ref}
|
||||
maxWidth={maxWidth}
|
||||
minWidth={minWidth}
|
||||
xPos={xPos}
|
||||
yPos={yPos}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuContainer>
|
||||
</Portal>
|
||||
<StyledContextMenuButton
|
||||
{...props}
|
||||
key={props.key}
|
||||
ref={ref}
|
||||
as="button"
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
23
src/renderer/components/hover-card/index.tsx
Normal file
23
src/renderer/components/hover-card/index.tsx
Normal 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;
|
|
@ -33,3 +33,4 @@ export * from './motion';
|
|||
export * from './context-menu';
|
||||
export * from './query-builder';
|
||||
export * from './rating';
|
||||
export * from './hover-card';
|
||||
|
|
|
@ -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<MantineRatingProps, 'onClick'> {
|
||||
onClick: (e: MouseEvent<HTMLDivElement>, 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 <StyledRating {...props} />;
|
||||
export const Rating = ({ onClick, ...props }: RatingProps) => {
|
||||
// const debouncedOnClick = debounce(onClick, 100);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label="Double click to clear"
|
||||
openDelay={1000}
|
||||
>
|
||||
<StyledRating
|
||||
{...props}
|
||||
onDoubleClick={(e) => onClick(e, props.value)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
openContextModal({
|
||||
innerProps: {
|
||||
albumId: itemType === LibraryItem.ALBUM ? [itemData.id] : undefined,
|
||||
artistId: itemType === LibraryItem.ALBUM_ARTIST ? [itemData.id] : undefined,
|
||||
},
|
||||
modal: 'addToPlaylist',
|
||||
size: 'md',
|
||||
title: 'Add to playlist',
|
||||
});
|
||||
};
|
||||
const handleContextMenu = useHandleGeneralContextMenu(
|
||||
itemType,
|
||||
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
return (
|
||||
<GridCardControlsContainer>
|
||||
{/* <TopControls /> */}
|
||||
{/* <CenterControls /> */}
|
||||
<BottomControls>
|
||||
<PlayButton onClick={handlePlay}>
|
||||
<RiPlayFill size={25} />
|
||||
|
@ -191,40 +169,21 @@ export const GridCardControls = ({
|
|||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<DropdownMenu
|
||||
withinPortal
|
||||
position="bottom-start"
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
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>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</Group>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
|
|
|
@ -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<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: value?.serverId,
|
||||
query: {
|
||||
item: [value],
|
||||
rating: 0,
|
||||
},
|
||||
});
|
||||
|
||||
setRatingValue(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<CellContainer position="center">
|
||||
<Rating
|
||||
defaultValue={value?.userRating || 0}
|
||||
size="xs"
|
||||
value={value}
|
||||
value={ratingValue}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</CellContainer>
|
||||
);
|
||||
|
|
131
src/renderer/components/virtual-table/hooks/use-rating.ts
Normal file
131
src/renderer/components/virtual-table/hooks/use-rating.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 { 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';
|
||||
useHandleGeneralContextMenu,
|
||||
useHandleTableContextMenu,
|
||||
} from '/@/renderer/features/context-menu';
|
||||
import { Play } from '/@/renderer/types';
|
||||
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 { 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 (
|
||||
<ContentContainer>
|
||||
|
@ -220,28 +213,16 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
|||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{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
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
|
|
@ -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 (
|
||||
<Stack ref={cq.ref}>
|
||||
<LibraryHeader
|
||||
|
@ -55,6 +83,17 @@ export const AlbumDetailHeader = forwardRef(
|
|||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{showRating && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Rating
|
||||
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
spacing="sm"
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
getColumnDefs,
|
||||
GridCarousel,
|
||||
Text,
|
||||
|
@ -10,8 +9,7 @@ import {
|
|||
} from '/@/renderer/components';
|
||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import { RiArrowDownSLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { createSearchParams, Link } from 'react-router-dom';
|
||||
|
@ -19,15 +17,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 { Play, TableColumn } 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';
|
||||
useHandleGeneralContextMenu,
|
||||
useHandleTableContextMenu,
|
||||
} from '/@/renderer/features/context-menu';
|
||||
import { Play, TableColumn } from '/@/renderer/types';
|
||||
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 {
|
||||
AlbumListSort,
|
||||
|
@ -267,16 +266,10 @@ export const AlbumArtistDetailContent = () => {
|
|||
}
|
||||
};
|
||||
|
||||
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 = () => {
|
|||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{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
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
|
@ -422,22 +403,6 @@ export const AlbumArtistDetailContent = () => {
|
|||
View all
|
||||
</Button>
|
||||
</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>
|
||||
<VirtualTable
|
||||
autoFitColumns
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { Group, Stack } from '@mantine/core';
|
||||
import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { Group, Rating, Stack } from '@mantine/core';
|
||||
import { forwardRef, Fragment, Ref, MouseEvent } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components';
|
||||
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 { AppRoute } from '/@/renderer/router/routes';
|
||||
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 (
|
||||
<Stack ref={cq.ref}>
|
||||
<LibraryHeader
|
||||
|
@ -56,6 +89,17 @@ export const AlbumArtistDetailHeader = forwardRef(
|
|||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{showRating && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Rating
|
||||
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
sx={{
|
||||
|
|
|
@ -7,7 +7,7 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ divider: true, id: 'addToPlaylist' },
|
||||
{ id: 'addToFavorites' },
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ disabled: true, id: 'setRating' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
];
|
||||
|
||||
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
|
@ -18,7 +18,7 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ divider: true, id: 'removeFromPlaylist' },
|
||||
{ id: 'addToFavorites' },
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ disabled: true, id: 'setRating' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
];
|
||||
|
||||
export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
|
@ -28,7 +28,7 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ divider: true, id: 'addToPlaylist' },
|
||||
{ id: 'addToFavorites' },
|
||||
{ id: 'removeFromFavorites' },
|
||||
{ disabled: true, id: 'setRating' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
];
|
||||
|
||||
export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
|
@ -38,7 +38,7 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ divider: true, id: 'addToPlaylist' },
|
||||
{ id: 'addToFavorites' },
|
||||
{ divider: true, id: 'removeFromFavorites' },
|
||||
{ disabled: true, id: 'setRating' },
|
||||
{ children: true, disabled: false, id: 'setRating' },
|
||||
];
|
||||
|
||||
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
|
|
|
@ -1,17 +1,45 @@
|
|||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import { useClickOutside, useResizeObserver, useSetState, useViewportSize } from '@mantine/hooks';
|
||||
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 { RowNode } from '@ag-grid-community/core';
|
||||
import { Divider, Group, Portal, Stack } from '@mantine/core';
|
||||
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,
|
||||
useContextMenuEvents,
|
||||
} from '/@/renderer/features/context-menu/events';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists';
|
||||
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { useCreateFavorite, useDeleteFavorite, useUpdateRating } from '/@/renderer/features/shared';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
|
||||
|
@ -20,6 +48,16 @@ type ContextMenuContextProps = {
|
|||
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>({
|
||||
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: (
|
||||
<ConfirmModal onConfirm={handleDeletePlaylist}>
|
||||
|
@ -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<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(
|
||||
{
|
||||
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 = () => {
|
||||
if (!ctx.dataNodes) return;
|
||||
const nodesToUnfavorite = ctx.dataNodes.filter((item) => item.data.userFavorite);
|
||||
const handleRemoveFromFavorites = useCallback(() => {
|
||||
if (!ctx.dataNodes && !ctx.data) return;
|
||||
|
||||
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(
|
||||
{
|
||||
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,
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
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,
|
||||
},
|
||||
setRating: { id: 'setRating', label: 'Set rating', onClick: () => {} },
|
||||
};
|
||||
[ctx.data, ctx.dataNodes, updateRatingMutation],
|
||||
);
|
||||
|
||||
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
|
||||
return {
|
||||
addToFavorites: {
|
||||
id: 'addToFavorites',
|
||||
label: 'Add to favorites',
|
||||
leftIcon: <RiHeartFill size="1.1rem" />,
|
||||
onClick: handleAddToFavorites,
|
||||
},
|
||||
addToPlaylist: {
|
||||
id: 'addToPlaylist',
|
||||
label: 'Add to playlist',
|
||||
leftIcon: <RiPlayListAddFill size="1.1rem" />,
|
||||
onClick: handleAddToPlaylist,
|
||||
},
|
||||
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
|
||||
deletePlaylist: {
|
||||
id: 'deletePlaylist',
|
||||
label: 'Delete playlist',
|
||||
leftIcon: <RiDeleteBinFill size="1.1rem" />,
|
||||
onClick: openDeletePlaylistModal,
|
||||
},
|
||||
play: {
|
||||
id: 'play',
|
||||
label: 'Play',
|
||||
leftIcon: <RiPlayFill size="1.1rem" />,
|
||||
onClick: () => handlePlay(Play.NOW),
|
||||
},
|
||||
playLast: {
|
||||
id: 'playLast',
|
||||
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 (
|
||||
<ContextMenuContext.Provider
|
||||
|
@ -334,43 +590,89 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
openContextMenu,
|
||||
}}
|
||||
>
|
||||
{opened && (
|
||||
<ContextMenu
|
||||
ref={ref}
|
||||
minWidth={125}
|
||||
xPos={ctx.xPos}
|
||||
yPos={ctx.yPos}
|
||||
>
|
||||
<Stack
|
||||
ref={clickOutsideRef}
|
||||
spacing={0}
|
||||
onClick={closeContextMenu}
|
||||
>
|
||||
{ctx.menuItems?.map((item) => {
|
||||
return (
|
||||
<Fragment key={`context-menu-${item.id}`}>
|
||||
<ContextMenuButton
|
||||
as="button"
|
||||
disabled={item.disabled}
|
||||
onClick={contextMenuItems[item.id as keyof typeof contextMenuItems].onClick}
|
||||
>
|
||||
{contextMenuItems[item.id as keyof typeof contextMenuItems].label}
|
||||
</ContextMenuButton>
|
||||
{item.divider && (
|
||||
<Divider
|
||||
key={`context-menu-divider-${item.id}`}
|
||||
color="rgb(62, 62, 62)"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ContextMenu>
|
||||
)}
|
||||
<Portal>
|
||||
<AnimatePresence>
|
||||
{opened && (
|
||||
<ContextMenu
|
||||
ref={mergedRef}
|
||||
minWidth={125}
|
||||
xPos={ctx.xPos}
|
||||
yPos={ctx.yPos}
|
||||
>
|
||||
<Stack spacing={0}>
|
||||
<Stack
|
||||
spacing={0}
|
||||
onClick={closeContextMenu}
|
||||
>
|
||||
{ctx.menuItems?.map((item) => {
|
||||
return (
|
||||
<Fragment key={`context-menu-${item.id}`}>
|
||||
{item.children ? (
|
||||
<HoverCard
|
||||
offset={5}
|
||||
position="right"
|
||||
>
|
||||
<HoverCard.Target>
|
||||
<ContextMenuButton
|
||||
disabled={item.disabled}
|
||||
leftIcon={contextMenuItems[item.id].leftIcon}
|
||||
rightIcon={contextMenuItems[item.id].rightIcon}
|
||||
onClick={contextMenuItems[item.id].onClick}
|
||||
>
|
||||
{contextMenuItems[item.id].label}
|
||||
</ContextMenuButton>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}[];
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<LeftControlsContainer>
|
||||
|
@ -196,28 +165,13 @@ export const LeftControls = () => {
|
|||
{title || '—'}
|
||||
</Text>
|
||||
{isSongDefined && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMore2Fill size="1.2rem" />
|
||||
</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>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => handleGeneralContextMenu(e, [currentSong!])}
|
||||
>
|
||||
<RiMore2Fill size="1.2rem" />
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</LineItem>
|
||||
|
|
|
@ -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<HTMLDivElement>, 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 = () => {
|
|||
<Group h="calc(100% / 3)">
|
||||
{showRating && (
|
||||
<Rating
|
||||
readOnly
|
||||
size="sm"
|
||||
value={currentSong?.userRating ?? 0}
|
||||
value={currentSong?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
|
|
@ -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';
|
||||
|
|
133
src/renderer/features/shared/mutations/update-rating-mutation.ts
Normal file
133
src/renderer/features/shared/mutations/update-rating-mutation.ts
Normal 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,
|
||||
});
|
||||
};
|
|
@ -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;
|
||||
},
|
||||
setRepeat: (type: PlayerRepeat) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
Reference in a new issue