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/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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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> => {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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,11 +155,6 @@ export const CardControls = ({
|
||||||
)}
|
)}
|
||||||
</FavoriteWrapper>
|
</FavoriteWrapper>
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<DropdownMenu
|
|
||||||
withinPortal
|
|
||||||
position="bottom-start"
|
|
||||||
>
|
|
||||||
<DropdownMenu.Target>
|
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
p={5}
|
p={5}
|
||||||
sx={{ svg: { fill: 'white !important' } }}
|
sx={{ svg: { fill: 'white !important' } }}
|
||||||
|
@ -187,6 +162,7 @@ export const CardControls = ({
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
handleContextMenu(e, [itemData]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiMore2Fill
|
<RiMore2Fill
|
||||||
|
@ -194,21 +170,6 @@ export const CardControls = ({
|
||||||
size={20}
|
size={20}
|
||||||
/>
|
/>
|
||||||
</SecondaryButton>
|
</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>
|
||||||
|
|
|
@ -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 ContextMenuButton = forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
rightIcon,
|
||||||
|
leftIcon,
|
||||||
|
...props
|
||||||
|
}: UnstyledButtonProps &
|
||||||
|
React.ComponentPropsWithoutRef<'button'> & {
|
||||||
|
leftIcon?: ReactNode;
|
||||||
|
rightIcon?: ReactNode;
|
||||||
|
},
|
||||||
|
ref: any,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<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(
|
export const ContextMenu = forwardRef(
|
||||||
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
|
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
|
||||||
return (
|
return (
|
||||||
<Portal>
|
|
||||||
<ContextMenuContainer
|
<ContextMenuContainer
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
animate="open"
|
||||||
|
initial="closed"
|
||||||
maxWidth={maxWidth}
|
maxWidth={maxWidth}
|
||||||
minWidth={minWidth}
|
minWidth={minWidth}
|
||||||
|
variants={variants}
|
||||||
xPos={xPos}
|
xPos={xPos}
|
||||||
yPos={yPos}
|
yPos={yPos}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ContextMenuContainer>
|
</ContextMenuContainer>
|
||||||
</Portal>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
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 './context-menu';
|
||||||
export * from './query-builder';
|
export * from './query-builder';
|
||||||
export * from './rating';
|
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 { 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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,11 +169,6 @@ export const GridCardControls = ({
|
||||||
)}
|
)}
|
||||||
</FavoriteWrapper>
|
</FavoriteWrapper>
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<DropdownMenu
|
|
||||||
withinPortal
|
|
||||||
position="bottom-start"
|
|
||||||
>
|
|
||||||
<DropdownMenu.Target>
|
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
p={5}
|
p={5}
|
||||||
sx={{ svg: { fill: 'white !important' } }}
|
sx={{ svg: { fill: 'white !important' } }}
|
||||||
|
@ -203,6 +176,7 @@ export const GridCardControls = ({
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
handleContextMenu(e, [itemData]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiMore2Fill
|
<RiMore2Fill
|
||||||
|
@ -210,21 +184,6 @@ export const GridCardControls = ({
|
||||||
size={20}
|
size={20}
|
||||||
/>
|
/>
|
||||||
</SecondaryButton>
|
</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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
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' }),
|
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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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">
|
|
||||||
<DropdownMenu.Target>
|
|
||||||
<Button
|
<Button
|
||||||
compact
|
compact
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!detailQuery?.data) return;
|
||||||
|
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RiMoreFill size={20} />
|
<RiMoreFill size={20} />
|
||||||
</Button>
|
</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>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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">
|
|
||||||
<DropdownMenu.Target>
|
|
||||||
<Button
|
<Button
|
||||||
compact
|
compact
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!detailQuery?.data) return;
|
||||||
|
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RiMoreFill size={20} />
|
<RiMoreFill size={20} />
|
||||||
</Button>
|
</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
|
<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
|
||||||
|
|
|
@ -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={{
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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,7 +134,8 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
openContextMenu,
|
openContextMenu,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlePlay = (play: Play) => {
|
const handlePlay = useCallback(
|
||||||
|
(play: Play) => {
|
||||||
switch (ctx.type) {
|
switch (ctx.type) {
|
||||||
case LibraryItem.ALBUM:
|
case LibraryItem.ALBUM:
|
||||||
handlePlayQueueAdd?.({
|
handlePlayQueueAdd?.({
|
||||||
|
@ -123,11 +168,13 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
|
|
||||||
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: () => {
|
||||||
|
if (ctx.dataNodes) {
|
||||||
for (const node of nodesToFavorite) {
|
for (const node of nodesToFavorite) {
|
||||||
node.setData({ ...node.data, userFavorite: true });
|
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();
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ctx.data, ctx.dataNodes, updateRatingMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
|
||||||
|
return {
|
||||||
addToFavorites: {
|
addToFavorites: {
|
||||||
id: 'addToFavorites',
|
id: 'addToFavorites',
|
||||||
label: 'Add to favorites',
|
label: 'Add to favorites',
|
||||||
|
leftIcon: <RiHeartFill size="1.1rem" />,
|
||||||
onClick: handleAddToFavorites,
|
onClick: handleAddToFavorites,
|
||||||
},
|
},
|
||||||
addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: handleAddToPlaylist },
|
addToPlaylist: {
|
||||||
|
id: 'addToPlaylist',
|
||||||
|
label: 'Add to playlist',
|
||||||
|
leftIcon: <RiPlayListAddFill size="1.1rem" />,
|
||||||
|
onClick: handleAddToPlaylist,
|
||||||
|
},
|
||||||
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
|
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
|
||||||
deletePlaylist: {
|
deletePlaylist: {
|
||||||
id: 'deletePlaylist',
|
id: 'deletePlaylist',
|
||||||
label: 'Delete playlist',
|
label: 'Delete playlist',
|
||||||
|
leftIcon: <RiDeleteBinFill size="1.1rem" />,
|
||||||
onClick: openDeletePlaylistModal,
|
onClick: openDeletePlaylistModal,
|
||||||
},
|
},
|
||||||
play: {
|
play: {
|
||||||
id: 'play',
|
id: 'play',
|
||||||
label: 'Play',
|
label: 'Play',
|
||||||
|
leftIcon: <RiPlayFill size="1.1rem" />,
|
||||||
onClick: () => handlePlay(Play.NOW),
|
onClick: () => handlePlay(Play.NOW),
|
||||||
},
|
},
|
||||||
playLast: {
|
playLast: {
|
||||||
id: 'playLast',
|
id: 'playLast',
|
||||||
label: 'Add to queue',
|
label: 'Add to queue',
|
||||||
|
leftIcon: <RiAddBoxFill size="1.1rem" />,
|
||||||
onClick: () => handlePlay(Play.LAST),
|
onClick: () => handlePlay(Play.LAST),
|
||||||
},
|
},
|
||||||
playNext: {
|
playNext: {
|
||||||
id: 'playNext',
|
id: 'playNext',
|
||||||
label: 'Add to queue next',
|
label: 'Add to queue next',
|
||||||
|
leftIcon: <RiAddCircleFill size="1.1rem" />,
|
||||||
onClick: () => handlePlay(Play.NEXT),
|
onClick: () => handlePlay(Play.NEXT),
|
||||||
},
|
},
|
||||||
removeFromFavorites: {
|
removeFromFavorites: {
|
||||||
id: 'removeFromFavorites',
|
id: 'removeFromFavorites',
|
||||||
label: 'Remove from favorites',
|
label: 'Remove from favorites',
|
||||||
|
leftIcon: <RiDislikeFill size="1.1rem" />,
|
||||||
onClick: handleRemoveFromFavorites,
|
onClick: handleRemoveFromFavorites,
|
||||||
},
|
},
|
||||||
removeFromPlaylist: {
|
removeFromPlaylist: {
|
||||||
id: 'removeFromPlaylist',
|
id: 'removeFromPlaylist',
|
||||||
label: 'Remove from playlist',
|
label: 'Remove from playlist',
|
||||||
|
leftIcon: <RiDeleteBinFill size="1.1rem" />,
|
||||||
onClick: handleRemoveFromPlaylist,
|
onClick: handleRemoveFromPlaylist,
|
||||||
},
|
},
|
||||||
setRating: { id: 'setRating', label: 'Set rating', onClick: () => {} },
|
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,28 +590,67 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
openContextMenu,
|
openContextMenu,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Portal>
|
||||||
|
<AnimatePresence>
|
||||||
{opened && (
|
{opened && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
ref={ref}
|
ref={mergedRef}
|
||||||
minWidth={125}
|
minWidth={125}
|
||||||
xPos={ctx.xPos}
|
xPos={ctx.xPos}
|
||||||
yPos={ctx.yPos}
|
yPos={ctx.yPos}
|
||||||
>
|
>
|
||||||
|
<Stack spacing={0}>
|
||||||
<Stack
|
<Stack
|
||||||
ref={clickOutsideRef}
|
|
||||||
spacing={0}
|
spacing={0}
|
||||||
onClick={closeContextMenu}
|
onClick={closeContextMenu}
|
||||||
>
|
>
|
||||||
{ctx.menuItems?.map((item) => {
|
{ctx.menuItems?.map((item) => {
|
||||||
return (
|
return (
|
||||||
<Fragment key={`context-menu-${item.id}`}>
|
<Fragment key={`context-menu-${item.id}`}>
|
||||||
<ContextMenuButton
|
{item.children ? (
|
||||||
as="button"
|
<HoverCard
|
||||||
disabled={item.disabled}
|
offset={5}
|
||||||
onClick={contextMenuItems[item.id as keyof typeof contextMenuItems].onClick}
|
position="right"
|
||||||
>
|
>
|
||||||
{contextMenuItems[item.id as keyof typeof contextMenuItems].label}
|
<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>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{item.divider && (
|
{item.divider && (
|
||||||
<Divider
|
<Divider
|
||||||
key={`context-menu-divider-${item.id}`}
|
key={`context-menu-divider-${item.id}`}
|
||||||
|
@ -367,10 +662,17 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Divider
|
||||||
|
color="rgb(62, 62, 62)"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<ContextMenuButton disabled>{ctx.data?.length} selected</ContextMenuButton>
|
||||||
|
</Stack>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
{children}
|
{children}
|
||||||
|
</Portal>
|
||||||
</ContextMenuContext.Provider>
|
</ContextMenuContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
@ -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>
|
|
||||||
<DropdownMenu.Target>
|
|
||||||
<Button
|
<Button
|
||||||
compact
|
compact
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
onClick={(e) => handleGeneralContextMenu(e, [currentSong!])}
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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';
|
||||||
|
|
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;
|
return foundUniqueIds;
|
||||||
},
|
},
|
||||||
setRepeat: (type: PlayerRepeat) => {
|
setRepeat: (type: PlayerRepeat) => {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Reference in a new issue