mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 02:13:33 +00:00
Migrate to Mantine v8 and Design Changes (#961)
* mantine v8 migration * various design changes and improvements
This commit is contained in:
parent
bea55d48a8
commit
c1330d92b2
473 changed files with 12469 additions and 11607 deletions
|
|
@ -1,8 +1,9 @@
|
|||
import { Group, Stack } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
import { RiAlertFill } from 'react-icons/ri';
|
||||
|
||||
import { Text } from '/@/renderer/components';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
interface ActionRequiredContainerProps {
|
||||
children: ReactNode;
|
||||
|
|
@ -10,15 +11,16 @@ interface ActionRequiredContainerProps {
|
|||
}
|
||||
|
||||
export const ActionRequiredContainer = ({ children, title }: ActionRequiredContainerProps) => (
|
||||
<Stack sx={{ cursor: 'default', maxWidth: '700px' }}>
|
||||
<Stack style={{ cursor: 'default', maxWidth: '700px' }}>
|
||||
<Group>
|
||||
<RiAlertFill
|
||||
color="var(--warning-color)"
|
||||
size={30}
|
||||
<Icon
|
||||
fill="warn"
|
||||
icon="warn"
|
||||
size="lg"
|
||||
/>
|
||||
<Text
|
||||
size="xl"
|
||||
sx={{ textTransform: 'uppercase' }}
|
||||
style={{ textTransform: 'uppercase' }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
.container {
|
||||
background: var(--theme-colors-background);
|
||||
}
|
||||
|
|
@ -1,39 +1,42 @@
|
|||
import type { FallbackProps } from 'react-error-boundary';
|
||||
|
||||
import { Box, Center, Group, Stack } from '@mantine/core';
|
||||
import { RiErrorWarningLine } from 'react-icons/ri';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouteError } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import styles from './error-fallback.module.css';
|
||||
|
||||
const Container = styled(Box)`
|
||||
background: var(--main-bg);
|
||||
`;
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
export const ErrorFallback = ({ resetErrorBoundary }: FallbackProps) => {
|
||||
const error = useRouteError() as any;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<Stack sx={{ maxWidth: '50%' }}>
|
||||
<Group spacing="xs">
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
size={30}
|
||||
<div className={styles.container}>
|
||||
<Center style={{ height: '100vh' }}>
|
||||
<Stack style={{ maxWidth: '50%' }}>
|
||||
<Group gap="xs">
|
||||
<Icon
|
||||
fill="error"
|
||||
icon="error"
|
||||
size="lg"
|
||||
/>
|
||||
<Text size="lg">Something went wrong</Text>
|
||||
<Text size="lg">{t('error.genericError')}</Text>
|
||||
</Group>
|
||||
<Text>{error?.message}</Text>
|
||||
<Button
|
||||
onClick={resetErrorBoundary}
|
||||
variant="filled"
|
||||
>
|
||||
Reload
|
||||
{t('common.reload')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ import isElectron from 'is-electron';
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Checkbox, FileInput, Text } from '/@/renderer/components';
|
||||
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { FileInput } from '/@/shared/components/file-input/file-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlaybackType } from '/@/shared/types/types';
|
||||
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
|
|
@ -15,7 +18,8 @@ export const MpvRequired = () => {
|
|||
const [disabled, setDisabled] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSetMpvPath = (e: File) => {
|
||||
const handleSetMpvPath = (e: File | null) => {
|
||||
if (!e) return;
|
||||
localSettings?.set('mpv_path', e.path);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import { Box, Center, Divider, Group, Stack } from '@mantine/core';
|
||||
import { RiArrowLeftSLine, RiErrorWarningLine, RiHome4Line, RiMenuFill } from 'react-icons/ri';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useRouteError } from 'react-router';
|
||||
|
||||
import { Button, DropdownMenu, Text } from '/@/renderer/components';
|
||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
const RouteErrorBoundary = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const error = useRouteError() as any;
|
||||
console.log('error', error);
|
||||
|
|
@ -24,47 +32,47 @@ const RouteErrorBoundary = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Box bg="var(--main-bg)">
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<Stack sx={{ maxWidth: '50%' }}>
|
||||
<div style={{ backgroundColor: 'var(--theme-colors-background)' }}>
|
||||
<Center style={{ height: '100vh' }}>
|
||||
<Stack style={{ maxWidth: '50%' }}>
|
||||
<Group>
|
||||
<Button
|
||||
<ActionIcon
|
||||
icon="arrowLeftS"
|
||||
onClick={handleReturn}
|
||||
px={10}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiArrowLeftSLine size={20} />
|
||||
</Button>
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="lg">Something went wrong</Text>
|
||||
<Icon
|
||||
fill="error"
|
||||
icon="error"
|
||||
size="lg"
|
||||
/>
|
||||
<Text size="lg">{t('error.genericError')}</Text>
|
||||
</Group>
|
||||
<Divider my={5} />
|
||||
<Text size="sm">{error?.message}</Text>
|
||||
<Group
|
||||
gap="sm"
|
||||
grow
|
||||
spacing="sm"
|
||||
>
|
||||
<Button
|
||||
leftIcon={<RiHome4Line />}
|
||||
leftSection={<Icon icon="home" />}
|
||||
onClick={handleHome}
|
||||
size="md"
|
||||
sx={{ flex: 0.5 }}
|
||||
style={{ flex: 0.5 }}
|
||||
variant="default"
|
||||
>
|
||||
Go home
|
||||
{t('page.home.title')}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
leftIcon={<RiMenuFill />}
|
||||
leftSection={<Icon icon="menu" />}
|
||||
size="md"
|
||||
sx={{ flex: 0.5 }}
|
||||
style={{ flex: 0.5 }}
|
||||
variant="default"
|
||||
>
|
||||
Menu
|
||||
{t('common.menu')}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
|
|
@ -78,12 +86,12 @@ const RouteErrorBoundary = () => {
|
|||
size="md"
|
||||
variant="filled"
|
||||
>
|
||||
Reload
|
||||
{t('common.reload')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Text } from '/@/renderer/components';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
export const ServerCredentialRequired = () => {
|
||||
const currentServer = useCurrentServer();
|
||||
|
|
|
|||
|
|
@ -1,25 +1,161 @@
|
|||
import { RiMenuFill } from 'react-icons/ri';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { Button, DropdownMenu, Text } from '/@/renderer/components';
|
||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
import { AddServerForm } from '/@/renderer/features/servers';
|
||||
import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
|
||||
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
|
||||
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||
import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useAuthStoreActions, useCurrentServer, useServerList } from '/@/renderer/store';
|
||||
import { Accordion } from '/@/shared/components/accordion/accordion';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { ServerListItem, ServerType } from '/@/shared/types/domain-types';
|
||||
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
|
||||
export const ServerRequired = () => {
|
||||
const { t } = useTranslation();
|
||||
const serverList = useServerList();
|
||||
|
||||
const serverLock =
|
||||
(localSettings
|
||||
? !!localSettings.env.SERVER_LOCK
|
||||
: !!window.SERVER_LOCK &&
|
||||
window.SERVER_TYPE &&
|
||||
window.SERVER_NAME &&
|
||||
window.SERVER_URL) || false;
|
||||
|
||||
if (Object.keys(serverList).length > 0) {
|
||||
return (
|
||||
<ScrollArea>
|
||||
<Stack miw="300px">
|
||||
<ServerSelector />
|
||||
{serverLock && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
<Accordion>
|
||||
<Accordion.Item value="add-server">
|
||||
<Accordion.Control>
|
||||
{t('form.addServer.title', { postProcess: 'titleCase' })}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<AddServerForm onCancel={null} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return <AddServerForm onCancel={null} />;
|
||||
};
|
||||
|
||||
function ServerSelector() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const serverList = useServerList();
|
||||
const currentServer = useCurrentServer();
|
||||
const { setCurrentServer } = useAuthStoreActions();
|
||||
|
||||
const handleSetCurrentServer = (server: ServerListItem) => {
|
||||
navigate(AppRoute.HOME);
|
||||
setCurrentServer(server);
|
||||
};
|
||||
|
||||
const handleCredentialsModal = async (server: ServerListItem) => {
|
||||
let password: null | string = null;
|
||||
|
||||
try {
|
||||
if (localSettings && server.savePassword) {
|
||||
password = await localSettings.passwordGet(server.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
openModal({
|
||||
children: server && (
|
||||
<EditServerForm
|
||||
isUpdate
|
||||
onCancel={closeAllModals}
|
||||
password={password}
|
||||
server={server}
|
||||
/>
|
||||
),
|
||||
size: 'sm',
|
||||
title: t('form.updateServer.title', { postProcess: 'titleCase' }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>No server selected.</Text>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Target>
|
||||
{Object.keys(serverList).map((serverId) => {
|
||||
const server = serverList[serverId];
|
||||
const isNavidromeExpired =
|
||||
server.type === ServerType.NAVIDROME && !server.ndCredential;
|
||||
const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential;
|
||||
const isSessionExpired = isNavidromeExpired || isJellyfinExpired;
|
||||
|
||||
const logo =
|
||||
server.type === ServerType.NAVIDROME
|
||||
? NavidromeLogo
|
||||
: server.type === ServerType.JELLYFIN
|
||||
? JellyfinLogo
|
||||
: OpenSubsonicLogo;
|
||||
|
||||
return (
|
||||
<Button
|
||||
leftIcon={<RiMenuFill />}
|
||||
variant="filled"
|
||||
key={`server-${server.id}`}
|
||||
onClick={() => {
|
||||
if (!isSessionExpired) return handleSetCurrentServer(server);
|
||||
return handleCredentialsModal(server);
|
||||
}}
|
||||
size="lg"
|
||||
styles={{
|
||||
label: {
|
||||
width: '100%',
|
||||
},
|
||||
root: {
|
||||
padding: 'var(--theme-spacing-sm)',
|
||||
},
|
||||
}}
|
||||
variant={server.id === currentServer?.id ? 'filled' : 'default'}
|
||||
>
|
||||
Open menu
|
||||
<Group
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
>
|
||||
<Group>
|
||||
<img
|
||||
src={logo}
|
||||
style={{
|
||||
height: 'var(--theme-font-size-2xl)',
|
||||
width: 'var(--theme-font-size-2xl)',
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fw={600}
|
||||
size="lg"
|
||||
>
|
||||
{server.name}
|
||||
</Text>
|
||||
</Group>
|
||||
{isSessionExpired ? <Icon icon="lock" /> : <Icon icon="arrowRight" />}
|
||||
</Group>
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<AppMenu />
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { Center, Group, Stack } from '@mantine/core';
|
||||
import { openModal } from '@mantine/modals';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiCheckFill, RiEdit2Line, RiHome4Line } from 'react-icons/ri';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { Button, PageHeader, Text } from '/@/renderer/components';
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container';
|
||||
import { ServerCredentialRequired } from '/@/renderer/features/action-required/components/server-credential-required';
|
||||
import { ServerRequired } from '/@/renderer/features/action-required/components/server-required';
|
||||
|
|
@ -12,6 +10,11 @@ import { ServerList } from '/@/renderer/features/servers';
|
|||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
|
||||
const ActionRequiredRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -45,12 +48,12 @@ const ActionRequiredRoute = () => {
|
|||
return (
|
||||
<AnimatedPage>
|
||||
<PageHeader />
|
||||
<Center sx={{ height: '100%', width: '100vw' }}>
|
||||
<Center style={{ height: '100%', width: '100vw' }}>
|
||||
<Stack
|
||||
spacing="xl"
|
||||
sx={{ maxWidth: '50%' }}
|
||||
gap="xl"
|
||||
style={{ maxWidth: '50%' }}
|
||||
>
|
||||
<Group noWrap>
|
||||
<Group wrap="nowrap">
|
||||
{displayedCheck && (
|
||||
<ActionRequiredContainer title={displayedCheck.title}>
|
||||
{displayedCheck?.component}
|
||||
|
|
@ -58,37 +61,15 @@ const ActionRequiredRoute = () => {
|
|||
)}
|
||||
</Group>
|
||||
<Stack mt="2rem">
|
||||
{canReturnHome && (
|
||||
<>
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
>
|
||||
<RiCheckFill
|
||||
color="var(--success-color)"
|
||||
size={30}
|
||||
/>
|
||||
<Text size="xl">No issues found</Text>
|
||||
</Group>
|
||||
<Button
|
||||
component={Link}
|
||||
disabled={!canReturnHome}
|
||||
leftIcon={<RiHome4Line />}
|
||||
to={AppRoute.HOME}
|
||||
variant="filled"
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canReturnHome && <Navigate to={AppRoute.HOME} />}
|
||||
{!displayedCheck && (
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
justify="center"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
leftIcon={<RiEdit2Line />}
|
||||
leftSection={<Icon icon="edit" />}
|
||||
onClick={handleManageServersModal}
|
||||
variant="filled"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,41 @@
|
|||
import { Center, Group, Stack } from '@mantine/core';
|
||||
import { RiQuestionLine } from 'react-icons/ri';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
const InvalidRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<Center sx={{ height: '100%', width: '100%' }}>
|
||||
<Center style={{ height: '100%', width: '100%' }}>
|
||||
<Stack>
|
||||
<Group
|
||||
noWrap
|
||||
position="center"
|
||||
justify="center"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<RiQuestionLine
|
||||
color="var(--warning-color)"
|
||||
size={30}
|
||||
<Icon
|
||||
color="warn"
|
||||
icon="error"
|
||||
/>
|
||||
<Text size="xl">Page not found</Text>
|
||||
<Text size="xl">
|
||||
{t('error.apiRouteError', { postProcess: 'sentenceCase' })}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text>{location.pathname}</Text>
|
||||
<Button
|
||||
<ActionIcon
|
||||
icon="arrowLeftS"
|
||||
onClick={() => navigate(-1)}
|
||||
variant="filled"
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
/>
|
||||
</Stack>
|
||||
</Center>
|
||||
</AnimatedPage>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
.content-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-lg);
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -1,20 +1,16 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { RowDoubleClickedEvent, RowHeightParams, RowNode } from '@ag-grid-community/core';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaLastfmSquare } from 'react-icons/fa';
|
||||
import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri';
|
||||
import { SiMusicbrainz } from 'react-icons/si';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import styles from './album-detail-content.module.css';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Button, Popover, Spoiler } from '/@/renderer/components';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel';
|
||||
import {
|
||||
getColumnDefs,
|
||||
TableConfigDropdown,
|
||||
|
|
@ -47,6 +43,12 @@ import {
|
|||
useTableSettings,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
AlbumListSort,
|
||||
|
|
@ -60,19 +62,6 @@ const isFullWidthRow = (node: RowNode) => {
|
|||
return node.id?.startsWith('disc-');
|
||||
};
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
`;
|
||||
|
||||
const DetailContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
interface AlbumDetailContentProps {
|
||||
background?: string;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
|
|
@ -330,72 +319,73 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
|
|||
const mbzId = detailQuery?.data?.mbzId;
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<LibraryBackgroundOverlay $backgroundColor={background} />
|
||||
<DetailContainer>
|
||||
<Box component="section">
|
||||
<div
|
||||
className={styles.contentContainer}
|
||||
ref={cq.ref}
|
||||
>
|
||||
<LibraryBackgroundOverlay backgroundColor={background} />
|
||||
<div className={styles.detailContainer}>
|
||||
<section>
|
||||
<Group
|
||||
position="apart"
|
||||
spacing="sm"
|
||||
gap="sm"
|
||||
justify="space-between"
|
||||
>
|
||||
<Group>
|
||||
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<Button
|
||||
compact
|
||||
loading={
|
||||
createFavoriteMutation.isLoading ||
|
||||
deleteFavoriteMutation.isLoading
|
||||
}
|
||||
onClick={handleFavorite}
|
||||
variant="subtle"
|
||||
>
|
||||
{detailQuery?.data?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="red"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: detailQuery?.data?.userFavorite
|
||||
? 'primary'
|
||||
: undefined,
|
||||
}}
|
||||
loading={
|
||||
createFavoriteMutation.isLoading ||
|
||||
deleteFavoriteMutation.isLoading
|
||||
}
|
||||
onClick={handleFavorite}
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="ellipsisHorizontal"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Popover position="bottom-end">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings2Fill size={20} />
|
||||
</Button>
|
||||
<ActionIcon
|
||||
icon="settings"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
/>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<TableConfigDropdown type="albumDetail" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
</Box>
|
||||
</section>
|
||||
{showGenres && (
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
<section>
|
||||
<Group gap="sm">
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
<Button
|
||||
compact
|
||||
component={Link}
|
||||
key={`genre-${genre.id}`}
|
||||
radius={0}
|
||||
size="md"
|
||||
radius="md"
|
||||
size="compact-md"
|
||||
to={generatePath(genreRoute, {
|
||||
genreId: genre.id,
|
||||
})}
|
||||
|
|
@ -405,35 +395,38 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
|
|||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
</section>
|
||||
)}
|
||||
{externalLinks && (lastFM || musicBrainz) ? (
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
{lastFM && (
|
||||
<Button
|
||||
compact
|
||||
component="a"
|
||||
href={`https://www.last.fm/music/${encodeURIComponent(
|
||||
detailQuery?.data?.albumArtist || '',
|
||||
)}/${encodeURIComponent(detailQuery.data?.name || '')}`}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.lastfm'),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<FaLastfmSquare size={25} />
|
||||
</Button>
|
||||
)}
|
||||
{musicBrainz && mbzId ? (
|
||||
<Button
|
||||
compact
|
||||
<section>
|
||||
<Group gap="sm">
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={`https://www.last.fm/music/${encodeURIComponent(
|
||||
detailQuery?.data?.albumArtist || '',
|
||||
)}/${encodeURIComponent(detailQuery.data?.name || '')}`}
|
||||
icon="brandLastfm"
|
||||
iconProps={{
|
||||
fill: 'default',
|
||||
size: 'xl',
|
||||
}}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.lastfm'),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
{mbzId ? (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={`https://musicbrainz.org/release/${mbzId}`}
|
||||
icon="brandMusicBrainz"
|
||||
iconProps={{
|
||||
fill: 'default',
|
||||
size: 'xl',
|
||||
}}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
|
|
@ -442,19 +435,17 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
|
|||
label: t('action.openIn.musicbrainz'),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<SiMusicbrainz size={25} />
|
||||
</Button>
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</section>
|
||||
) : null}
|
||||
{comment && (
|
||||
<Box component="section">
|
||||
<section>
|
||||
<Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>
|
||||
</Box>
|
||||
</section>
|
||||
)}
|
||||
<Box style={{ minHeight: '300px' }}>
|
||||
<div style={{ minHeight: '300px' }}>
|
||||
<VirtualTable
|
||||
autoFitColumns={tableConfig.autoFit}
|
||||
autoHeight
|
||||
|
|
@ -491,11 +482,11 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
|
|||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
<Stack
|
||||
gap="lg"
|
||||
mt="3rem"
|
||||
ref={cq.ref}
|
||||
spacing="lg"
|
||||
>
|
||||
{cq.height || cq.width ? (
|
||||
<>
|
||||
|
|
@ -547,7 +538,7 @@ export const AlbumDetailContent = ({ background, tableRef }: AlbumDetailContentP
|
|||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</DetailContainer>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { Group, Stack } from '@mantine/core';
|
||||
import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Rating, Text } from '/@/renderer/components';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
import { LibraryHeader, useSetRating } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
|
|
@ -14,6 +12,10 @@ import { queryClient } from '/@/renderer/lib/react-query';
|
|||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { AlbumDetailResponse, LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||
|
||||
interface AlbumDetailHeaderProps {
|
||||
|
|
@ -129,17 +131,17 @@ export const AlbumDetailHeader = forwardRef(
|
|||
title={detailQuery?.data?.name || ''}
|
||||
{...background}
|
||||
>
|
||||
<Stack spacing="sm">
|
||||
<Group spacing="sm">
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm">
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
{index > 0 && <Text isNoSelect>•</Text>}
|
||||
<Text>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{showRating && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Text isNoSelect>•</Text>
|
||||
<Rating
|
||||
onChange={handleUpdateRating}
|
||||
readOnly={
|
||||
|
|
@ -152,9 +154,9 @@ export const AlbumDetailHeader = forwardRef(
|
|||
)}
|
||||
</Group>
|
||||
<Group
|
||||
gap="md"
|
||||
mah="4rem"
|
||||
spacing="md"
|
||||
sx={{
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
|
|
@ -162,9 +164,9 @@ export const AlbumDetailHeader = forwardRef(
|
|||
>
|
||||
{detailQuery?.data?.albumArtists.map((artist) => (
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw={600}
|
||||
isLink
|
||||
key={`artist-${artist.id}`}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
|||
|
||||
import { lazy, MutableRefObject, Suspense } from 'react';
|
||||
|
||||
import { Spinner } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { useListStoreByKey } from '/@/renderer/store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const AlbumListGridView = lazy(() =>
|
||||
|
|
@ -32,7 +32,7 @@ export const AlbumListContent = ({ gridRef, itemCount, tableRef }: AlbumListCont
|
|||
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.GRID ? (
|
||||
<AlbumListGridView
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { ListOnScrollProps } from 'react-window';
|
|||
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { ALBUM_CARD_ROWS } from '/@/renderer/components';
|
||||
import { ALBUM_CARD_ROWS } from '/@/renderer/components/card/card-rows';
|
||||
import {
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
|
|
|
|||
|
|
@ -1,24 +1,13 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { openModal } from '@mantine/modals';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiAddBoxFill,
|
||||
RiAddCircleFill,
|
||||
RiFilterFill,
|
||||
RiFolder2Fill,
|
||||
RiMoreFill,
|
||||
RiPlayFill,
|
||||
RiRefreshLine,
|
||||
RiSettings3Fill,
|
||||
} from 'react-icons/ri';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
|
|
@ -26,6 +15,11 @@ import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jel
|
|||
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
|
||||
import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters';
|
||||
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { FilterButton } from '/@/renderer/features/shared/components/filter-button';
|
||||
import { FolderButton } from '/@/renderer/features/shared/components/folder-button';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
|
||||
import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
|
||||
import {
|
||||
|
|
@ -34,6 +28,12 @@ import {
|
|||
useListStoreActions,
|
||||
useListStoreByKey,
|
||||
} from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
AlbumListSort,
|
||||
|
|
@ -228,7 +228,7 @@ export const AlbumListHeaderFilters = ({
|
|||
?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
|
||||
|
||||
const onFilterChange = useCallback(
|
||||
(filter: AlbumListFilter) => {
|
||||
|
|
@ -355,19 +355,20 @@ export const AlbumListHeaderFilters = ({
|
|||
}
|
||||
};
|
||||
|
||||
const debouncedHandleItemSize = debounce(handleItemSize, 20);
|
||||
|
||||
const handleItemGap = (e: number) => {
|
||||
setGrid({ data: { itemGap: e }, key: pageKey });
|
||||
};
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey });
|
||||
(displayType: ListDisplayType) => {
|
||||
setDisplayType({ data: displayType, key: pageKey });
|
||||
},
|
||||
[pageKey, setDisplayType],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const handleTableColumns = (values: string[]) => {
|
||||
const existingColumns = table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
|
|
@ -379,7 +380,7 @@ export const AlbumListHeaderFilters = ({
|
|||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
const newColumn = { column: values[values.length - 1] as TableColumn, width: 100 };
|
||||
|
||||
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
|
||||
} else {
|
||||
|
|
@ -393,10 +394,10 @@ export const AlbumListHeaderFilters = ({
|
|||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
|
||||
const handleAutoFitColumns = (autoFitColumns: boolean) => {
|
||||
setTable({ data: { autoFit: autoFitColumns }, key: pageKey });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
if (autoFitColumns) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
|
@ -439,25 +440,18 @@ export const AlbumListHeaderFilters = ({
|
|||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
gap="sm"
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
<Button variant="subtle">{sortByLabel}</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
|
||||
<DropdownMenu.Item
|
||||
$isActive={f.value === filter.sortBy}
|
||||
isSelected={f.value === filter.sortBy}
|
||||
key={`filter-${f.name}`}
|
||||
onClick={handleSetSortBy}
|
||||
value={f.value}
|
||||
|
|
@ -477,26 +471,12 @@ export const AlbumListHeaderFilters = ({
|
|||
<Divider orientation="vertical" />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
size="md"
|
||||
sx={{
|
||||
svg: {
|
||||
fill: isFolderFilterApplied
|
||||
? 'var(--primary-color) !important'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiFolder2Fill size="1.3rem" />
|
||||
</Button>
|
||||
<FolderButton isActive={!!isFolderFilterApplied} />
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.items.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
$isActive={filter.musicFolderId === folder.id}
|
||||
isSelected={filter.musicFolderId === folder.id}
|
||||
key={`musicFolder-${folder.id}`}
|
||||
onClick={handleSetMusicFolder}
|
||||
value={folder.id}
|
||||
|
|
@ -508,66 +488,37 @@ export const AlbumListHeaderFilters = ({
|
|||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
compact
|
||||
<FilterButton
|
||||
isActive={!!isFilterApplied}
|
||||
onClick={handleOpenFiltersModal}
|
||||
size="md"
|
||||
sx={{
|
||||
svg: {
|
||||
fill: isFilterApplied ? 'var(--primary-color) !important' : undefined,
|
||||
},
|
||||
}}
|
||||
tooltip={{
|
||||
label: t('common.filters', { count: 2, postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiFilterFill size="1.3rem" />
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
compact
|
||||
onClick={handleRefresh}
|
||||
size="md"
|
||||
tooltip={{ label: t('common.refresh', { postProcess: 'sentenceCase' }) }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiRefreshLine size="1.3rem" />
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
/>
|
||||
<RefreshButton onClick={handleRefresh} />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
<MoreButton />
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiPlayFill />}
|
||||
leftSection={<Icon icon="mediaPlay" />}
|
||||
onClick={() => handlePlay?.({ playType: Play.NOW })}
|
||||
>
|
||||
{t('player.play', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddBoxFill />}
|
||||
leftSection={<Icon icon="mediaPlayLast" />}
|
||||
onClick={() => handlePlay?.({ playType: Play.LAST })}
|
||||
>
|
||||
{t('player.addLast', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddCircleFill />}
|
||||
leftSection={<Icon icon="mediaPlayNext" />}
|
||||
onClick={() => handlePlay?.({ playType: Play.NEXT })}
|
||||
>
|
||||
{t('player.addNext', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
leftSection={<Icon icon="refresh" />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{t('common.refresh', { postProcess: 'sentenceCase' })}
|
||||
|
|
@ -576,118 +527,23 @@ export const AlbumListHeaderFilters = ({
|
|||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group
|
||||
noWrap
|
||||
spacing="sm"
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<DropdownMenu
|
||||
position="bottom-end"
|
||||
width={425}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{
|
||||
label: t('common.configure', { postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.CARD}
|
||||
>
|
||||
{t('table.config.view.card', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.POSTER}
|
||||
>
|
||||
{t('table.config.view.poster', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.TABLE}
|
||||
>
|
||||
{t('table.config.view.table', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
{/* <DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item> */}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
max={isGrid ? 300 : 100}
|
||||
min={isGrid ? 100 : 25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemGap', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={grid?.itemGap || 0}
|
||||
max={30}
|
||||
min={0}
|
||||
onChangeEnd={handleItemGap}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
{(display === ListDisplayType.TABLE ||
|
||||
display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={ALBUM_TABLE_COLUMNS}
|
||||
defaultValue={table?.columns.map(
|
||||
(column) => column.column,
|
||||
)}
|
||||
onChange={handleTableColumns}
|
||||
width={300}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<ListConfigMenu
|
||||
autoFitColumns={table.autoFit}
|
||||
disabledViewTypes={[ListDisplayType.LIST]}
|
||||
displayType={display}
|
||||
itemGap={grid?.itemGap || 0}
|
||||
itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
onChangeAutoFitColumns={handleAutoFitColumns}
|
||||
onChangeDisplayType={handleSetViewType}
|
||||
onChangeItemGap={handleItemGap}
|
||||
onChangeItemSize={debouncedHandleItemSize}
|
||||
onChangeTableColumns={handleTableColumns}
|
||||
tableColumns={table?.columns.map((column) => column.column)}
|
||||
tableColumnsData={ALBUM_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { type ChangeEvent, type MutableRefObject, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
import { AlbumListFilter, useCurrentServer, usePlayButtonBehavior } from '/@/renderer/store';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { AlbumListQuery, LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
interface AlbumListHeaderProps {
|
||||
|
|
@ -59,10 +62,10 @@ export const AlbumListHeader = ({
|
|||
|
||||
return (
|
||||
<Stack
|
||||
gap={0}
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<PageHeader backgroundColor="var(--theme-colors-background)">
|
||||
<Flex
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
|
|
@ -85,7 +88,6 @@ export const AlbumListHeader = ({
|
|||
<SearchInput
|
||||
defaultValue={filter.searchTerm}
|
||||
onChange={handleSearch}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
|
||||
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
|
||||
import { AlbumListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import {
|
||||
AlbumArtistListSort,
|
||||
AlbumListQuery,
|
||||
|
|
@ -186,8 +191,8 @@ export const JellyfinAlbumFilters = ({
|
|||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
justify="space-between"
|
||||
key={`nd-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
|
||||
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
|
||||
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import {
|
||||
AlbumArtistListSort,
|
||||
AlbumListQuery,
|
||||
|
|
@ -233,8 +238,8 @@ export const NavidromeAlbumFilters = ({
|
|||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
justify="space-between"
|
||||
key={`nd-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
GenreListSort,
|
||||
|
|
@ -100,8 +105,8 @@ export const SubsonicAlbumFilters = ({
|
|||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
justify="space-between"
|
||||
key={`nd-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
|||
import { useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { NativeScrollArea } from '/@/renderer/components';
|
||||
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
||||
import { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content';
|
||||
import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-detail-header';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
.detail-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -1,15 +1,13 @@
|
|||
import { Box, Center, Group, Stack } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiErrorWarningLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
import styles from './dummy-album-detail-route.module.css';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Button, Spoiler, Text } from '/@/renderer/components';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { SONG_ALBUM_PAGE } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
|
@ -27,16 +25,16 @@ import { useCurrentServer } from '/@/renderer/store';
|
|||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem, SongDetailResponse } from '/@/shared/types/domain-types';
|
||||
|
||||
const DetailContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const DummyAlbumDetailRoute = () => {
|
||||
const cq = useContainerQuery();
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -137,19 +135,19 @@ const DummyAlbumDetailRoute = () => {
|
|||
loading={!background || colorId !== albumId}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
>
|
||||
<Stack spacing="sm">
|
||||
<Group spacing="sm">
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm">
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
{index > 0 && <Text isNoSelect>•</Text>}
|
||||
<Text isMuted={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
<Group
|
||||
gap="md"
|
||||
mah="4rem"
|
||||
spacing="md"
|
||||
sx={{
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
|
|
@ -157,9 +155,9 @@ const DummyAlbumDetailRoute = () => {
|
|||
>
|
||||
{detailQuery?.data?.albumArtists.map((artist) => (
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw={600}
|
||||
isLink
|
||||
key={`artist-${artist.id}`}
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
|
|
@ -174,55 +172,46 @@ const DummyAlbumDetailRoute = () => {
|
|||
</Stack>
|
||||
</LibraryHeader>
|
||||
</Stack>
|
||||
<DetailContainer>
|
||||
<Box component="section">
|
||||
<div className={styles.detailContainer}>
|
||||
<section>
|
||||
<Group
|
||||
position="apart"
|
||||
spacing="sm"
|
||||
gap="sm"
|
||||
justify="space-between"
|
||||
>
|
||||
<Group>
|
||||
<PlayButton onClick={() => handlePlay()} />
|
||||
<Button
|
||||
compact
|
||||
<ActionIcon
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: detailQuery?.data?.userFavorite ? 'primary' : undefined,
|
||||
}}
|
||||
loading={
|
||||
createFavoriteMutation.isLoading ||
|
||||
deleteFavoriteMutation.isLoading
|
||||
}
|
||||
onClick={handleFavorite}
|
||||
variant="subtle"
|
||||
>
|
||||
{detailQuery?.data?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="red"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="ellipsisHorizontal"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</section>
|
||||
{showGenres && (
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
<section>
|
||||
<Group gap="sm">
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
<Button
|
||||
compact
|
||||
component={Link}
|
||||
key={`genre-${genre.id}`}
|
||||
radius={0}
|
||||
size="md"
|
||||
size="compact-md"
|
||||
to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, {
|
||||
genreId: genre.id,
|
||||
})}
|
||||
|
|
@ -232,25 +221,26 @@ const DummyAlbumDetailRoute = () => {
|
|||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
</section>
|
||||
)}
|
||||
{comment && (
|
||||
<Box component="section">
|
||||
<section>
|
||||
<Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>
|
||||
</Box>
|
||||
</section>
|
||||
)}
|
||||
<Box component="section">
|
||||
<section>
|
||||
<Center>
|
||||
<Group mr={5}>
|
||||
<RiErrorWarningLine
|
||||
color="var(--danger-color)"
|
||||
<Icon
|
||||
fill="error"
|
||||
icon="error"
|
||||
size={30}
|
||||
/>
|
||||
</Group>
|
||||
<h2>{t('error.badAlbum', { postProcess: 'sentenceCase' })}</h2>
|
||||
</Center>
|
||||
</Box>
|
||||
</DetailContainer>
|
||||
</section>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
.content-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-lg);
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
:global(.ag-theme-alpine-dark) {
|
||||
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,12 @@
|
|||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import { Box, Grid, Group, Stack } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaLastfmSquare } from 'react-icons/fa';
|
||||
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { SiMusicbrainz } from 'react-icons/si';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { createSearchParams, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Button, Spoiler, TextTitle } from '/@/renderer/components';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
import styles from './album-artist-detail-content.module.css';
|
||||
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel';
|
||||
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
|
|
@ -32,6 +28,13 @@ import { AppRoute } from '/@/renderer/router/routes';
|
|||
import { ArtistItem, useCurrentServer } from '/@/renderer/store';
|
||||
import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { sanitize } from '/@/renderer/utils/sanitize';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Grid } from '/@/shared/components/grid/grid';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
|
|
@ -43,23 +46,6 @@ import {
|
|||
} from '/@/shared/types/domain-types';
|
||||
import { CardRow, Play, TableColumn } from '/@/shared/types/types';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
`;
|
||||
|
||||
const DetailContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
padding: 1rem 2rem 5rem;
|
||||
overflow: hidden;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
interface AlbumArtistDetailContentProps {
|
||||
background?: string;
|
||||
}
|
||||
|
|
@ -216,21 +202,20 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
title: (
|
||||
<Group align="flex-end">
|
||||
<TextTitle
|
||||
fw={700}
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{t('page.albumArtistDetail.recentReleases', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
<Button
|
||||
compact
|
||||
component={Link}
|
||||
size="compact-md"
|
||||
to={artistDiscographyLink}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{t('page.albumArtistDetail.viewDiscography')}
|
||||
{String(t('page.albumArtistDetail.viewDiscography')).toUpperCase()}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
|
|
@ -247,8 +232,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
order: itemOrder.compilations,
|
||||
title: (
|
||||
<TextTitle
|
||||
fw={700}
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{t('page.albumArtistDetail.appearsOn', { postProcess: 'sentenceCase' })}
|
||||
</TextTitle>
|
||||
|
|
@ -262,8 +247,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
order: itemOrder.similarArtists,
|
||||
title: (
|
||||
<TextTitle
|
||||
fw={700}
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{t('page.albumArtistDetail.relatedArtists', {
|
||||
postProcess: 'sentenceCase',
|
||||
|
|
@ -369,77 +354,77 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
detailQuery?.isLoading ||
|
||||
(server?.type === ServerType.NAVIDROME && enabledItem.topSongs && topSongsQuery?.isLoading);
|
||||
|
||||
if (isLoading) return <ContentContainer ref={cq.ref} />;
|
||||
if (isLoading)
|
||||
return (
|
||||
<div
|
||||
className={styles.contentContainer}
|
||||
ref={cq.ref}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentContainer ref={cq.ref}>
|
||||
<LibraryBackgroundOverlay $backgroundColor={background} />
|
||||
<DetailContainer>
|
||||
<Group spacing="md">
|
||||
<div
|
||||
className={styles.contentContainer}
|
||||
ref={cq.ref}
|
||||
>
|
||||
<LibraryBackgroundOverlay backgroundColor={background} />
|
||||
<div className={styles.detailContainer}>
|
||||
<Group gap="md">
|
||||
<PlayButton
|
||||
disabled={albumCount === 0}
|
||||
onClick={() => handlePlay(playButtonBehavior)}
|
||||
/>
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: detailQuery?.data?.userFavorite ? 'primary' : undefined,
|
||||
}}
|
||||
loading={
|
||||
createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading
|
||||
}
|
||||
onClick={handleFavorite}
|
||||
variant="subtle"
|
||||
>
|
||||
{detailQuery?.data?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="red"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="ellipsisHorizontal"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group spacing="md">
|
||||
<Group gap="md">
|
||||
<Button
|
||||
compact
|
||||
component={Link}
|
||||
size="compact-md"
|
||||
to={artistDiscographyLink}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{t('page.albumArtistDetail.viewDiscography')}
|
||||
{String(t('page.albumArtistDetail.viewDiscography')).toUpperCase()}
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
component={Link}
|
||||
size="compact-md"
|
||||
to={artistSongsLink}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{t('page.albumArtistDetail.viewAllTracks')}
|
||||
{String(t('page.albumArtistDetail.viewAllTracks')).toUpperCase()}
|
||||
</Button>
|
||||
</Group>
|
||||
{showGenres ? (
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
<section>
|
||||
<Group gap="sm">
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
<Button
|
||||
compact
|
||||
component={Link}
|
||||
key={`genre-${genre.id}`}
|
||||
radius="md"
|
||||
size="md"
|
||||
size="compact-md"
|
||||
to={generatePath(genrePath, {
|
||||
genreId: genre.id,
|
||||
})}
|
||||
|
|
@ -449,70 +434,65 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
</section>
|
||||
) : null}
|
||||
{externalLinks && (lastFM || musicBrainz) ? (
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
{lastFM && (
|
||||
<Button
|
||||
compact
|
||||
component="a"
|
||||
href={`https://www.last.fm/music/${encodeURIComponent(
|
||||
detailQuery?.data?.name || '',
|
||||
)}`}
|
||||
radius="md"
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.lastfm'),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<FaLastfmSquare size={25} />
|
||||
</Button>
|
||||
)}
|
||||
{musicBrainz && mbzId ? (
|
||||
<Button
|
||||
compact
|
||||
<section>
|
||||
<Group gap="sm">
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={`https://www.last.fm/music/${encodeURIComponent(
|
||||
detailQuery?.data?.name || '',
|
||||
)}`}
|
||||
icon="brandLastfm"
|
||||
iconProps={{
|
||||
fill: 'default',
|
||||
size: 'xl',
|
||||
}}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.lastfm'),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
{mbzId ? (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={`https://musicbrainz.org/artist/${mbzId}`}
|
||||
radius="md"
|
||||
icon="brandMusicBrainz"
|
||||
iconProps={{
|
||||
fill: 'default',
|
||||
size: 'xl',
|
||||
}}
|
||||
rel="noopener noreferrer"
|
||||
size="md"
|
||||
target="_blank"
|
||||
tooltip={{
|
||||
label: t('action.openIn.musicbrainz'),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<SiMusicbrainz size={25} />
|
||||
</Button>
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</section>
|
||||
) : null}
|
||||
<Grid>
|
||||
<Grid gutter="xl">
|
||||
{biography ? (
|
||||
<Grid.Col
|
||||
order={itemOrder.biography}
|
||||
span={12}
|
||||
>
|
||||
<Box
|
||||
component="section"
|
||||
maw="1280px"
|
||||
>
|
||||
<section style={{ maxWidth: '1280px' }}>
|
||||
<TextTitle
|
||||
fw={700}
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{t('page.albumArtistDetail.about', {
|
||||
artist: detailQuery?.data?.name,
|
||||
})}
|
||||
</TextTitle>
|
||||
<Spoiler dangerouslySetInnerHTML={{ __html: biography }} />
|
||||
</Box>
|
||||
</section>
|
||||
</Grid.Col>
|
||||
) : null}
|
||||
{showTopSongs ? (
|
||||
|
|
@ -520,26 +500,26 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
order={itemOrder.topSongs}
|
||||
span={12}
|
||||
>
|
||||
<Box component="section">
|
||||
<section>
|
||||
<Group
|
||||
noWrap
|
||||
position="apart"
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Group
|
||||
align="flex-end"
|
||||
noWrap
|
||||
wrap="nowrap"
|
||||
>
|
||||
<TextTitle
|
||||
fw={700}
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{t('page.albumArtistDetail.topSongs', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
<Button
|
||||
compact
|
||||
component={Link}
|
||||
size="compact-md"
|
||||
to={generatePath(
|
||||
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS,
|
||||
{
|
||||
|
|
@ -577,7 +557,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
/>
|
||||
</Box>
|
||||
</section>
|
||||
</Grid.Col>
|
||||
) : null}
|
||||
|
||||
|
|
@ -589,8 +569,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
order={carousel.order}
|
||||
span={12}
|
||||
>
|
||||
<Box component="section">
|
||||
<Stack spacing="xl">
|
||||
<section>
|
||||
<Stack gap="xl">
|
||||
<MemoizedSwiperGridCarousel
|
||||
cardRows={
|
||||
cardRows[carousel.itemType as keyof typeof cardRows]
|
||||
|
|
@ -614,11 +594,11 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
|
|||
uniqueId={carousel.uniqueId}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
</section>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</DetailContainer>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { Group, Rating, Stack } from '@mantine/core';
|
||||
import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Text } from '/@/renderer/components';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
import { LibraryHeader, useSetRating } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||
|
||||
interface AlbumArtistDetailHeaderProps {
|
||||
|
|
@ -87,13 +89,13 @@ export const AlbumArtistDetailHeader = forwardRef(
|
|||
.filter((i) => i.enabled)
|
||||
.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text $noSelect>•</Text>}
|
||||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
{index > 0 && <Text isNoSelect>•</Text>}
|
||||
<Text isMuted={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{showRating && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Text isNoSelect>•</Text>
|
||||
<Rating
|
||||
onChange={handleUpdateRating}
|
||||
readOnly={
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { RiAddBoxFill, RiAddCircleFill, RiMoreFill, RiPlayFill } from 'react-icons/ri';
|
||||
|
||||
import { Button, DropdownMenu, PageHeader, Paper, SpinnerIcon } from '/@/renderer/components';
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { Badge } from '/@/shared/components/badge/badge';
|
||||
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
|
|
@ -35,47 +36,14 @@ export const AlbumArtistDetailTopSongsListHeader = ({
|
|||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<LibraryHeaderBar.Title>
|
||||
{t('page.albumArtistDetail.topSongsFrom', { title })}
|
||||
{t('page.albumArtistDetail.topSongsFrom', {
|
||||
postProcess: 'titleCase',
|
||||
title,
|
||||
})}
|
||||
</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
<Badge>
|
||||
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
|
||||
</Paper>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiPlayFill />}
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
>
|
||||
{t('player.play', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddBoxFill />}
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
>
|
||||
{t('player.addLast', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddCircleFill />}
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
>
|
||||
{t('player.addNext', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Badge>
|
||||
</LibraryHeaderBar>
|
||||
</PageHeader>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
|||
|
||||
import { lazy, MutableRefObject, Suspense } from 'react';
|
||||
|
||||
import { Spinner } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { useListStoreByKey } from '/@/renderer/store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const AlbumArtistListGridView = lazy(() =>
|
||||
|
|
@ -37,7 +37,7 @@ export const AlbumArtistListContent = ({
|
|||
}: AlbumArtistListContentProps) => {
|
||||
const { pageKey } = useListContext();
|
||||
const { display } = useListStoreByKey({ key: pageKey });
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ListOnScrollProps } from 'react-window';
|
|||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
|
||||
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components/card/card-rows';
|
||||
import {
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
|
|
|
|||
|
|
@ -1,28 +1,36 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { FolderButton } from '/@/renderer/features/shared/components/folder-button';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
|
||||
import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
AlbumArtistListFilter,
|
||||
PersistedTableColumn,
|
||||
useCurrentServer,
|
||||
useListStoreActions,
|
||||
useListStoreByKey,
|
||||
} from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import {
|
||||
AlbumArtistListQuery,
|
||||
AlbumArtistListSort,
|
||||
|
|
@ -30,7 +38,7 @@ import {
|
|||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ListDisplayType, TableColumn } from '/@/shared/types/types';
|
||||
import { ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
|
|
@ -137,7 +145,7 @@ export const AlbumArtistListHeaderFilters = ({
|
|||
useListStoreActions();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
|
||||
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
|
||||
|
||||
const sortByLabel =
|
||||
|
|
@ -308,15 +316,13 @@ export const AlbumArtistListHeaderFilters = ({
|
|||
}, [filter.sortOrder, handleFilterChange, pageKey, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
|
||||
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey });
|
||||
(displayType: ListDisplayType) => {
|
||||
setDisplayType({ data: displayType, key: pageKey });
|
||||
},
|
||||
[pageKey, setDisplayType],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const handleTableColumns = (values: string[]) => {
|
||||
const existingColumns = table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
|
|
@ -330,7 +336,10 @@ export const AlbumArtistListHeaderFilters = ({
|
|||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
const newColumn = {
|
||||
column: values[values.length - 1],
|
||||
width: 100,
|
||||
} as PersistedTableColumn;
|
||||
|
||||
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
|
||||
} else {
|
||||
|
|
@ -344,10 +353,10 @@ export const AlbumArtistListHeaderFilters = ({
|
|||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
|
||||
const handleAutoFitColumns = (autoFitColumns: boolean) => {
|
||||
setTable({ data: { autoFit: autoFitColumns }, key: pageKey });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
if (autoFitColumns) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
|
@ -357,28 +366,25 @@ export const AlbumArtistListHeaderFilters = ({
|
|||
handleFilterChange(filter);
|
||||
}, [filter, handleFilterChange, queryClient, server?.id]);
|
||||
|
||||
const isFolderFilterApplied = useMemo(() => {
|
||||
return filter.musicFolderId !== undefined;
|
||||
}, [filter.musicFolderId]);
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
gap="sm"
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
<Button variant="subtle">{sortByLabel}</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
|
||||
<DropdownMenu.Item
|
||||
$isActive={f.value === filter.sortBy}
|
||||
isSelected={f.value === filter.sortBy}
|
||||
key={`filter-${f.name}`}
|
||||
onClick={handleSetSortBy}
|
||||
value={f.value}
|
||||
|
|
@ -395,22 +401,14 @@ export const AlbumArtistListHeaderFilters = ({
|
|||
/>
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
|
||||
</Button>
|
||||
<FolderButton isActive={!!isFolderFilterApplied} />
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.items.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
$isActive={filter.musicFolderId === folder.id}
|
||||
isSelected={filter.musicFolderId === folder.id}
|
||||
key={`musicFolder-${folder.id}`}
|
||||
onClick={handleSetMusicFolder}
|
||||
value={folder.id}
|
||||
|
|
@ -422,30 +420,14 @@ export const AlbumArtistListHeaderFilters = ({
|
|||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
compact
|
||||
onClick={handleRefresh}
|
||||
size="md"
|
||||
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiRefreshLine size="1.3rem" />
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
<RefreshButton onClick={handleRefresh} />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
<MoreButton />
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
leftSection={<Icon icon="refresh" />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{t('common.refresh', {
|
||||
|
|
@ -455,136 +437,24 @@ export const AlbumArtistListHeaderFilters = ({
|
|||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group>
|
||||
<DropdownMenu
|
||||
position="bottom-end"
|
||||
width={425}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.CARD}
|
||||
>
|
||||
{t('table.config.view.card', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.POSTER}
|
||||
>
|
||||
{t('table.config.view.poster', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.TABLE}
|
||||
>
|
||||
{t('table.config.view.table', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
{/* <DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item> */}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
{display === ListDisplayType.CARD ||
|
||||
display === ListDisplayType.POSTER ? (
|
||||
<Slider
|
||||
defaultValue={grid?.itemSize}
|
||||
max={300}
|
||||
min={150}
|
||||
onChange={debouncedHandleItemSize}
|
||||
/>
|
||||
) : (
|
||||
<Slider
|
||||
defaultValue={table.rowHeight}
|
||||
max={100}
|
||||
min={30}
|
||||
onChange={debouncedHandleItemSize}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu.Item>
|
||||
{isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemGap', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={grid?.itemGap || 0}
|
||||
max={30}
|
||||
min={0}
|
||||
onChangeEnd={handleItemGap}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
{!isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.tableColumns', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={ALBUMARTIST_TABLE_COLUMNS}
|
||||
defaultValue={table?.columns.map(
|
||||
(column) => column.column,
|
||||
)}
|
||||
onChange={handleTableColumns}
|
||||
width={300}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>
|
||||
{t('table.config.general.autoFitColumns', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Text>
|
||||
<Switch
|
||||
defaultChecked={table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<ListConfigMenu
|
||||
autoFitColumns={table.autoFit}
|
||||
disabledViewTypes={[ListDisplayType.LIST]}
|
||||
displayType={display}
|
||||
itemGap={grid?.itemGap || 0}
|
||||
itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
onChangeAutoFitColumns={handleAutoFitColumns}
|
||||
onChangeDisplayType={handleSetViewType}
|
||||
onChangeItemGap={handleItemGap}
|
||||
onChangeItemSize={debouncedHandleItemSize}
|
||||
onChangeTableColumns={handleTableColumns}
|
||||
tableColumns={table?.columns.map((column) => column.column)}
|
||||
tableColumnsData={ALBUMARTIST_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import type { ChangeEvent, MutableRefObject } from 'react';
|
||||
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
import { AlbumArtistListFilter, useCurrentServer } from '/@/renderer/store';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { AlbumArtistListQuery, LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
interface AlbumArtistListHeaderProps {
|
||||
|
|
@ -44,10 +47,10 @@ export const AlbumArtistListHeader = ({
|
|||
|
||||
return (
|
||||
<Stack
|
||||
gap={0}
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<PageHeader>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
|
|
@ -66,7 +69,6 @@ export const AlbumArtistListHeader = ({
|
|||
<SearchInput
|
||||
defaultValue={filter.searchTerm}
|
||||
onChange={handleSearch}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
|||
|
||||
import { lazy, MutableRefObject, Suspense } from 'react';
|
||||
|
||||
import { Spinner } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { useListStoreByKey } from '/@/renderer/store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const ArtistListGridView = lazy(() =>
|
||||
|
|
@ -29,7 +29,7 @@ interface ArtistListContentProps {
|
|||
export const ArtistListContent = ({ gridRef, itemCount, tableRef }: ArtistListContentProps) => {
|
||||
const { pageKey } = useListContext();
|
||||
const { display } = useListStoreByKey({ key: pageKey });
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ListOnScrollProps } from 'react-window';
|
|||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
|
||||
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components/card/card-rows';
|
||||
import {
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
|
|
|
|||
|
|
@ -1,37 +1,38 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Slider,
|
||||
Switch,
|
||||
Text,
|
||||
} from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { useRoles } from '/@/renderer/features/artists/queries/roles-query';
|
||||
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
|
||||
import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
ArtistListFilter,
|
||||
PersistedTableColumn,
|
||||
useCurrentServer,
|
||||
useListStoreActions,
|
||||
useListStoreByKey,
|
||||
} from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import {
|
||||
ArtistListQuery,
|
||||
ArtistListSort,
|
||||
|
|
@ -39,7 +40,7 @@ import {
|
|||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ListDisplayType, TableColumn } from '/@/shared/types/types';
|
||||
import { ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
|
|
@ -150,7 +151,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
|
|||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
|
||||
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
|
||||
|
||||
const sortByLabel =
|
||||
|
|
@ -321,15 +322,13 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
|
|||
}, [filter.sortOrder, handleFilterChange, pageKey, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
|
||||
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey });
|
||||
(displayType: ListDisplayType) => {
|
||||
setDisplayType({ data: displayType, key: pageKey });
|
||||
},
|
||||
[pageKey, setDisplayType],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const handleTableColumns = (values: string[]) => {
|
||||
const existingColumns = table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
|
|
@ -343,7 +342,10 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
|
|||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
const newColumn = {
|
||||
column: values[values.length - 1],
|
||||
width: 100,
|
||||
} as PersistedTableColumn;
|
||||
|
||||
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
|
||||
} else {
|
||||
|
|
@ -357,10 +359,10 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
|
|||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
|
||||
const handleAutoFitColumns = (autoFitColumns: boolean) => {
|
||||
setTable({ data: { autoFit: autoFitColumns }, key: pageKey });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
if (autoFitColumns) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
|
@ -387,25 +389,18 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
|
|||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
gap="sm"
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
<Button variant="subtle">{sortByLabel}</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
|
||||
<DropdownMenu.Item
|
||||
$isActive={f.value === filter.sortBy}
|
||||
isSelected={f.value === filter.sortBy}
|
||||
key={`filter-${f.name}`}
|
||||
onClick={handleSetSortBy}
|
||||
value={f.value}
|
||||
|
|
@ -425,19 +420,15 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
|
|||
<Divider orientation="vertical" />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
<ActionIcon
|
||||
icon="folder"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
|
||||
</Button>
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.items.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
$isActive={filter.musicFolderId === folder.id}
|
||||
isSelected={filter.musicFolderId === folder.id}
|
||||
key={`musicFolder-${folder.id}`}
|
||||
onClick={handleSetMusicFolder}
|
||||
value={folder.id}
|
||||
|
|
@ -451,7 +442,6 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
|
|||
)}
|
||||
{roles.data?.length && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<Select
|
||||
data={roles.data}
|
||||
onChange={handleSetRole}
|
||||
|
|
@ -459,30 +449,14 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
compact
|
||||
onClick={handleRefresh}
|
||||
size="md"
|
||||
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiRefreshLine size="1.3rem" />
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
<RefreshButton onClick={handleRefresh} />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
<MoreButton />
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
leftSection={<Icon icon="refresh" />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{t('common.refresh', {
|
||||
|
|
@ -492,129 +466,23 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
|
|||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group>
|
||||
<DropdownMenu
|
||||
position="bottom-end"
|
||||
width={425}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.CARD}
|
||||
>
|
||||
{t('table.config.view.card', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.POSTER}
|
||||
>
|
||||
{t('table.config.view.poster', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.TABLE}
|
||||
>
|
||||
{t('table.config.view.table', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
{display === ListDisplayType.CARD ||
|
||||
display === ListDisplayType.POSTER ? (
|
||||
<Slider
|
||||
defaultValue={grid?.itemSize}
|
||||
max={300}
|
||||
min={150}
|
||||
onChange={debouncedHandleItemSize}
|
||||
/>
|
||||
) : (
|
||||
<Slider
|
||||
defaultValue={table.rowHeight}
|
||||
max={100}
|
||||
min={30}
|
||||
onChange={debouncedHandleItemSize}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu.Item>
|
||||
{isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemGap', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={grid?.itemGap || 0}
|
||||
max={30}
|
||||
min={0}
|
||||
onChangeEnd={handleItemGap}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
{!isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.tableColumns', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={ALBUMARTIST_TABLE_COLUMNS}
|
||||
defaultValue={table?.columns.map(
|
||||
(column) => column.column,
|
||||
)}
|
||||
onChange={handleTableColumns}
|
||||
width={300}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>
|
||||
{t('table.config.general.autoFitColumns', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Text>
|
||||
<Switch
|
||||
defaultChecked={table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<ListConfigMenu
|
||||
autoFitColumns={table.autoFit}
|
||||
displayType={display}
|
||||
itemGap={grid?.itemGap || 0}
|
||||
itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
onChangeAutoFitColumns={handleAutoFitColumns}
|
||||
onChangeDisplayType={handleSetViewType}
|
||||
onChangeItemGap={handleItemGap}
|
||||
onChangeItemSize={debouncedHandleItemSize}
|
||||
onChangeTableColumns={handleTableColumns}
|
||||
tableColumns={table?.columns.map((column) => column.column)}
|
||||
tableColumnsData={ALBUMARTIST_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import type { ChangeEvent, MutableRefObject } from 'react';
|
||||
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ArtistListHeaderFilters } from '/@/renderer/features/artists/components/artist-list-header-filters';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
import { ArtistListFilter, useCurrentServer } from '/@/renderer/store';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { ArtistListQuery, LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
interface ArtistListHeaderProps {
|
||||
|
|
@ -40,10 +43,10 @@ export const ArtistListHeader = ({ gridRef, itemCount, tableRef }: ArtistListHea
|
|||
|
||||
return (
|
||||
<Stack
|
||||
gap={0}
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<PageHeader>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
|
|
@ -62,7 +65,6 @@ export const ArtistListHeader = ({ gridRef, itemCount, tableRef }: ArtistListHea
|
|||
<SearchInput
|
||||
defaultValue={filter.searchTerm}
|
||||
onChange={handleSearch}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { NativeScrollArea } from '/@/renderer/components';
|
||||
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
||||
import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content';
|
||||
import { AlbumArtistDetailHeader } from '/@/renderer/features/artists/components/album-artist-detail-header';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { RowNode } from '@ag-grid-community/core';
|
||||
import { Divider, Group, Portal, Stack } from '@mantine/core';
|
||||
import {
|
||||
useClickOutside,
|
||||
useMergedRef,
|
||||
|
|
@ -8,42 +7,14 @@ import {
|
|||
useViewportSize,
|
||||
} from '@mantine/hooks';
|
||||
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import isElectron from 'is-electron';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import { createContext, Fragment, ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiAddBoxFill,
|
||||
RiAddCircleFill,
|
||||
RiArrowDownLine,
|
||||
RiArrowGoForwardLine,
|
||||
RiArrowRightSFill,
|
||||
RiArrowUpLine,
|
||||
RiCloseCircleLine,
|
||||
RiDeleteBinFill,
|
||||
RiDislikeFill,
|
||||
RiDownload2Line,
|
||||
RiHeartFill,
|
||||
RiInformationFill,
|
||||
RiPlayFill,
|
||||
RiPlayListAddFill,
|
||||
RiRadio2Fill,
|
||||
RiShareForwardFill,
|
||||
RiShuffleFill,
|
||||
RiStarFill,
|
||||
} from 'react-icons/ri';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import {
|
||||
ConfirmModal,
|
||||
ContextMenu,
|
||||
ContextMenuButton,
|
||||
HoverCard,
|
||||
Rating,
|
||||
Text,
|
||||
toast,
|
||||
} from '/@/renderer/components';
|
||||
import { ContextMenu, ContextMenuButton } from '/@/renderer/components/context-menu/context-menu';
|
||||
import {
|
||||
ContextMenuItemType,
|
||||
OpenContextMenuProps,
|
||||
|
|
@ -66,6 +37,16 @@ import {
|
|||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
||||
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||
import { Portal } from '/@/shared/components/portal/portal';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import {
|
||||
AnyLibraryItem,
|
||||
AnyLibraryItems,
|
||||
|
|
@ -113,6 +94,7 @@ function RatingIcon({ rating }: { rating: number }) {
|
|||
readOnly
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
size: 'var(--theme-font-size-md)',
|
||||
}}
|
||||
value={rating}
|
||||
/>
|
||||
|
|
@ -292,7 +274,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
{ctx.data.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Group>
|
||||
—<Text $secondary>{item.name}</Text>
|
||||
—<Text isMuted>{item.name}</Text>
|
||||
</Group>
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -750,13 +732,13 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
addToFavorites: {
|
||||
id: 'addToFavorites',
|
||||
label: t('page.contextMenu.addToFavorites', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiHeartFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="favorite" />,
|
||||
onClick: handleAddToFavorites,
|
||||
},
|
||||
addToPlaylist: {
|
||||
id: 'addToPlaylist',
|
||||
label: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiPlayListAddFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="playlistAdd" />,
|
||||
onClick: handleAddToPlaylist,
|
||||
},
|
||||
createPlaylist: {
|
||||
|
|
@ -767,86 +749,86 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
deletePlaylist: {
|
||||
id: 'deletePlaylist',
|
||||
label: t('page.contextMenu.deletePlaylist', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiDeleteBinFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="playlistDelete" />,
|
||||
onClick: openDeletePlaylistModal,
|
||||
},
|
||||
deselectAll: {
|
||||
id: 'deselectAll',
|
||||
label: t('page.contextMenu.deselectAll', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiCloseCircleLine size="1.1rem" />,
|
||||
leftIcon: <Icon icon="remove" />,
|
||||
onClick: handleDeselectAll,
|
||||
},
|
||||
download: {
|
||||
disabled: ctx.data?.length !== 1,
|
||||
id: 'download',
|
||||
label: t('page.contextMenu.download', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiDownload2Line size="1.1rem" />,
|
||||
leftIcon: <Icon icon="download" />,
|
||||
onClick: handleDownload,
|
||||
},
|
||||
moveToBottomOfQueue: {
|
||||
id: 'moveToBottomOfQueue',
|
||||
label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiArrowDownLine size="1.1rem" />,
|
||||
leftIcon: <Icon icon="arrowDownToLine" />,
|
||||
onClick: handleMoveToBottom,
|
||||
},
|
||||
moveToNextOfQueue: {
|
||||
id: 'moveToNext',
|
||||
label: t('page.contextMenu.moveToNext', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiArrowGoForwardLine size="1.1rem" />,
|
||||
leftIcon: <Icon icon="mediaPlayNext" />,
|
||||
onClick: handleMoveToNext,
|
||||
},
|
||||
moveToTopOfQueue: {
|
||||
id: 'moveToTopOfQueue',
|
||||
label: t('page.contextMenu.moveToTop', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiArrowUpLine size="1.1rem" />,
|
||||
leftIcon: <Icon icon="arrowUpToLine" />,
|
||||
onClick: handleMoveToTop,
|
||||
},
|
||||
play: {
|
||||
id: 'play',
|
||||
label: t('page.contextMenu.play', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiPlayFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="mediaPlay" />,
|
||||
onClick: () => handlePlay(Play.NOW),
|
||||
},
|
||||
playLast: {
|
||||
id: 'playLast',
|
||||
label: t('page.contextMenu.addLast', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiAddBoxFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="mediaPlayLast" />,
|
||||
onClick: () => handlePlay(Play.LAST),
|
||||
},
|
||||
playNext: {
|
||||
id: 'playNext',
|
||||
label: t('page.contextMenu.addNext', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiAddCircleFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="mediaPlayNext" />,
|
||||
onClick: () => handlePlay(Play.NEXT),
|
||||
},
|
||||
playShuffled: {
|
||||
id: 'playShuffled',
|
||||
label: t('page.contextMenu.playShuffled', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiShuffleFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="mediaShuffle" />,
|
||||
onClick: () => handlePlay(Play.SHUFFLE),
|
||||
},
|
||||
playSimilarSongs: {
|
||||
id: 'playSimilarSongs',
|
||||
label: t('page.contextMenu.playSimilarSongs', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiRadio2Fill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="radio" />,
|
||||
onClick: handleSimilar,
|
||||
},
|
||||
removeFromFavorites: {
|
||||
id: 'removeFromFavorites',
|
||||
label: t('page.contextMenu.removeFromFavorites', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiDislikeFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="unfavorite" />,
|
||||
onClick: handleRemoveFromFavorites,
|
||||
},
|
||||
removeFromPlaylist: {
|
||||
id: 'removeFromPlaylist',
|
||||
label: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiDeleteBinFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="playlistDelete" />,
|
||||
onClick: handleRemoveFromPlaylist,
|
||||
},
|
||||
removeFromQueue: {
|
||||
id: 'removeSongs',
|
||||
label: t('page.contextMenu.removeFromQueue', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiDeleteBinFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="delete" />,
|
||||
onClick: handleRemoveSelected,
|
||||
},
|
||||
setRating: {
|
||||
|
|
@ -884,22 +866,22 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
],
|
||||
id: 'setRating',
|
||||
label: t('action.setRating', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiStarFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="star" />,
|
||||
onClick: () => {},
|
||||
rightIcon: <RiArrowRightSFill size="1.2rem" />,
|
||||
rightIcon: <Icon icon="arrowRightS" />,
|
||||
},
|
||||
shareItem: {
|
||||
disabled: !hasFeature(server, ServerFeature.SHARING_ALBUM_SONG),
|
||||
id: 'shareItem',
|
||||
label: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiShareForwardFill size="1.1rem" />,
|
||||
leftIcon: <Icon icon="share" />,
|
||||
onClick: handleShareItem,
|
||||
},
|
||||
showDetails: {
|
||||
disabled: ctx.data?.length !== 1 || !ctx.data[0].itemType,
|
||||
id: 'showDetails',
|
||||
label: t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }),
|
||||
leftIcon: <RiInformationFill />,
|
||||
leftIcon: <Icon icon="info" />,
|
||||
onClick: handleOpenItemDetails,
|
||||
},
|
||||
};
|
||||
|
|
@ -946,10 +928,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
xPos={ctx.xPos}
|
||||
yPos={ctx.yPos}
|
||||
>
|
||||
<Stack spacing={0}>
|
||||
<Stack gap={0}>
|
||||
<Stack
|
||||
gap={0}
|
||||
onClick={closeContextMenu}
|
||||
spacing={0}
|
||||
>
|
||||
{ctx.menuItems?.map((item) => {
|
||||
return (
|
||||
|
|
@ -957,7 +939,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
<Fragment key={`context-menu-${item.id}`}>
|
||||
{item.children ? (
|
||||
<HoverCard
|
||||
offset={5}
|
||||
offset={0}
|
||||
position="right"
|
||||
>
|
||||
<HoverCard.Target>
|
||||
|
|
@ -982,7 +964,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
</ContextMenuButton>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Stack spacing={0}>
|
||||
<Stack gap={0}>
|
||||
{contextMenuItems[
|
||||
item.id
|
||||
].children?.map((child) => (
|
||||
|
|
@ -1020,9 +1002,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
|
||||
{item.divider && (
|
||||
<Divider
|
||||
color="rgb(62, 62, 62)"
|
||||
key={`context-menu-divider-${item.id}`}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
|
|
@ -1030,10 +1010,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
|||
);
|
||||
})}
|
||||
</Stack>
|
||||
<Divider
|
||||
color="rgb(62, 62, 62)"
|
||||
size="sm"
|
||||
/>
|
||||
<ContextMenuButton disabled>
|
||||
{t('page.contextMenu.numberSelected', {
|
||||
count: ctx.data?.length || 0,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { GridOptions, RowNode } from '@ag-grid-community/core';
|
||||
import { createUseExternalEvents } from '@mantine/utils';
|
||||
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { createUseExternalEvents } from '/@/shared/utils/create-use-external-events';
|
||||
|
||||
export type ContextMenuEvents = {
|
||||
closeContextMenu: () => void;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
|||
|
||||
import { lazy, MutableRefObject, Suspense } from 'react';
|
||||
|
||||
import { Spinner } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { useListStoreByKey } from '/@/renderer/store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const GenreListGridView = lazy(() =>
|
||||
|
|
@ -32,7 +32,7 @@ export const GenreListContent = ({ gridRef, itemCount, tableRef }: AlbumListCont
|
|||
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.GRID ? (
|
||||
<GenreListGridView
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { ListOnScrollProps } from 'react-window';
|
|||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { ALBUM_CARD_ROWS } from '/@/renderer/components';
|
||||
import { ALBUM_CARD_ROWS } from '/@/renderer/components/card/card-rows';
|
||||
import {
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
|
|
|
|||
|
|
@ -1,36 +1,38 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiAlbumLine,
|
||||
RiFolder2Fill,
|
||||
RiMoreFill,
|
||||
RiMusic2Line,
|
||||
RiRefreshLine,
|
||||
RiSettings3Fill,
|
||||
} from 'react-icons/ri';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { FolderButton } from '/@/renderer/features/shared/components/folder-button';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
|
||||
import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
|
||||
import {
|
||||
GenreListFilter,
|
||||
GenreTarget,
|
||||
PersistedTableColumn,
|
||||
useCurrentServer,
|
||||
useGeneralSettings,
|
||||
useListStoreActions,
|
||||
useListStoreByKey,
|
||||
useSettingsStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import {
|
||||
GenreListQuery,
|
||||
GenreListSort,
|
||||
|
|
@ -38,7 +40,7 @@ import {
|
|||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ListDisplayType, TableColumn } from '/@/shared/types/types';
|
||||
import { ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
|
|
@ -99,7 +101,7 @@ export const GenreListHeaderFilters = ({
|
|||
?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
|
||||
|
||||
const onFilterChange = useCallback(
|
||||
(filter: GenreListFilter) => {
|
||||
|
|
@ -191,19 +193,20 @@ export const GenreListHeaderFilters = ({
|
|||
}
|
||||
};
|
||||
|
||||
const debouncedHandleItemSize = debounce(handleItemSize, 20);
|
||||
|
||||
const handleItemGap = (e: number) => {
|
||||
setGrid({ data: { itemGap: e }, key: pageKey });
|
||||
};
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey });
|
||||
(displayType: ListDisplayType) => {
|
||||
setDisplayType({ data: displayType, key: pageKey });
|
||||
},
|
||||
[pageKey, setDisplayType],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const handleTableColumns = (values: string[]) => {
|
||||
const existingColumns = table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
|
|
@ -215,7 +218,10 @@ export const GenreListHeaderFilters = ({
|
|||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
const newColumn = {
|
||||
column: values[values.length - 1],
|
||||
width: 100,
|
||||
} as PersistedTableColumn;
|
||||
|
||||
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
|
||||
} else {
|
||||
|
|
@ -229,10 +235,10 @@ export const GenreListHeaderFilters = ({
|
|||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
|
||||
const handleAutoFitColumns = (autoFitColumns: boolean) => {
|
||||
setTable({ data: { autoFit: autoFitColumns }, key: pageKey });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
if (autoFitColumns) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
|
@ -249,25 +255,18 @@ export const GenreListHeaderFilters = ({
|
|||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
gap="sm"
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
<Button variant="subtle">{sortByLabel}</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
|
||||
<DropdownMenu.Item
|
||||
$isActive={f.value === filter.sortBy}
|
||||
isSelected={f.value === filter.sortBy}
|
||||
key={`filter-${f.name}`}
|
||||
onClick={handleSetSortBy}
|
||||
value={f.value}
|
||||
|
|
@ -287,26 +286,15 @@ export const GenreListHeaderFilters = ({
|
|||
<Divider orientation="vertical" />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
size="md"
|
||||
sx={{
|
||||
svg: {
|
||||
fill: isFolderFilterApplied
|
||||
? 'var(--primary-color) !important'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiFolder2Fill size="1.3rem" />
|
||||
</Button>
|
||||
<FolderButton
|
||||
isActive={isFolderFilterApplied}
|
||||
onClick={handleSetMusicFolder}
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.items.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
$isActive={filter.musicFolderId === folder.id}
|
||||
isSelected={filter.musicFolderId === folder.id}
|
||||
key={`musicFolder-${folder.id}`}
|
||||
onClick={handleSetMusicFolder}
|
||||
value={folder.id}
|
||||
|
|
@ -318,168 +306,58 @@ export const GenreListHeaderFilters = ({
|
|||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
compact
|
||||
onClick={handleRefresh}
|
||||
size="md"
|
||||
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiRefreshLine size="1.3rem" />
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
<RefreshButton onClick={handleRefresh} />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
<MoreButton />
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
leftSection={<Icon icon="refresh" />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{t('common.refresh', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
compact
|
||||
onClick={handleGenreToggle}
|
||||
size="md"
|
||||
size="compact-md"
|
||||
tooltip={{
|
||||
label: t(
|
||||
genreTarget === GenreTarget.ALBUM
|
||||
? 'page.genreList.showAlbums'
|
||||
: 'page.genreList.showTracks',
|
||||
? 'page.genreList.showTracks'
|
||||
: 'page.genreList.showAlbums',
|
||||
{ postProcess: 'sentenceCase' },
|
||||
),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
{genreTarget === GenreTarget.ALBUM ? <RiAlbumLine /> : <RiMusic2Line />}
|
||||
{genreTarget === GenreTarget.ALBUM ? (
|
||||
<Icon icon="itemAlbum" />
|
||||
) : (
|
||||
<Icon icon="itemSong" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group
|
||||
noWrap
|
||||
spacing="sm"
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<DropdownMenu
|
||||
position="bottom-end"
|
||||
width={425}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{
|
||||
label: t('common.configure', { postProcess: 'titleCase' }),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.CARD}
|
||||
>
|
||||
{t('table.config.view.card', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.POSTER}
|
||||
>
|
||||
{t('table.config.view.poster', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.TABLE}
|
||||
>
|
||||
{t('table.config.view.table', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.size', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
max={isGrid ? 300 : 100}
|
||||
min={isGrid ? 100 : 25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemGap', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={grid?.itemGap || 0}
|
||||
max={30}
|
||||
min={0}
|
||||
onChangeEnd={handleItemGap}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
{(display === ListDisplayType.TABLE ||
|
||||
display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.tableColumns', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={GENRE_TABLE_COLUMNS}
|
||||
defaultValue={table?.columns.map(
|
||||
(column) => column.column,
|
||||
)}
|
||||
onChange={handleTableColumns}
|
||||
width={300}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>
|
||||
{t('table.config.general.autoFitColumns', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</Text>
|
||||
<Switch
|
||||
defaultChecked={table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<ListConfigMenu
|
||||
autoFitColumns={table.autoFit}
|
||||
disabledViewTypes={[ListDisplayType.LIST]}
|
||||
displayType={display}
|
||||
itemGap={grid?.itemGap || 0}
|
||||
itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
onChangeAutoFitColumns={handleAutoFitColumns}
|
||||
onChangeDisplayType={handleSetViewType}
|
||||
onChangeItemGap={handleItemGap}
|
||||
onChangeItemSize={debouncedHandleItemSize}
|
||||
onChangeTableColumns={handleTableColumns}
|
||||
tableColumns={table?.columns.map((column) => column.column)}
|
||||
tableColumnsData={GENRE_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, MutableRefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
import { GenreListFilter, useCurrentServer } from '/@/renderer/store';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { GenreListQuery, LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
interface GenreListHeaderProps {
|
||||
|
|
@ -38,10 +41,10 @@ export const GenreListHeader = ({ gridRef, itemCount, tableRef }: GenreListHeade
|
|||
|
||||
return (
|
||||
<Stack
|
||||
gap={0}
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<PageHeader>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
|
|
@ -60,7 +63,6 @@ export const GenreListHeader = ({ gridRef, itemCount, tableRef }: GenreListHeade
|
|||
<SearchInput
|
||||
defaultValue={filter.searchTerm}
|
||||
onChange={handleSearch}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { ActionIcon, Group, Stack } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiRefreshLine } from 'react-icons/ri';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { FeatureCarousel, NativeScrollArea, Spinner, TextTitle } from '/@/renderer/components';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel';
|
||||
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
||||
import { useAlbumList } from '/@/renderer/features/albums';
|
||||
import { useRecentlyPlayed } from '/@/renderer/features/home/queries/recently-played-query';
|
||||
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
|
|
@ -18,6 +17,12 @@ import {
|
|||
useGeneralSettings,
|
||||
useWindowSettings,
|
||||
} from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
import {
|
||||
AlbumListSort,
|
||||
LibraryItem,
|
||||
|
|
@ -233,7 +238,6 @@ const HomeRoute = () => {
|
|||
<AnimatedPage>
|
||||
<NativeScrollArea
|
||||
pageHeaderProps={{
|
||||
backgroundColor: 'var(--titlebar-bg)',
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>
|
||||
|
|
@ -246,10 +250,10 @@ const HomeRoute = () => {
|
|||
ref={scrollAreaRef}
|
||||
>
|
||||
<Stack
|
||||
gap="lg"
|
||||
mb="5rem"
|
||||
pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'}
|
||||
px="2rem"
|
||||
spacing="lg"
|
||||
>
|
||||
{homeFeature && <FeatureCarousel data={featureItemsWithImage} />}
|
||||
{sortedCarousel.map((carousel) => (
|
||||
|
|
@ -304,17 +308,12 @@ const HomeRoute = () => {
|
|||
title={{
|
||||
label: (
|
||||
<Group>
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{carousel.title}
|
||||
</TextTitle>
|
||||
|
||||
<TextTitle order={3}>{carousel.title}</TextTitle>
|
||||
<ActionIcon
|
||||
onClick={() => invalidateCarouselQuery(carousel)}
|
||||
variant="transparent"
|
||||
>
|
||||
<RiRefreshLine />
|
||||
<Icon icon="refresh" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import { Group, Table } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
import { TFunction, useTranslation } from 'react-i18next';
|
||||
import { RiCheckFill, RiCloseFill } from 'react-icons/ri';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Spoiler, Text } from '/@/renderer/components';
|
||||
import { Separator } from '/@/renderer/components/separator';
|
||||
import { SongPath } from '/@/renderer/features/item-details/components/song-path';
|
||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
|
|
@ -15,6 +11,11 @@ import { formatDateRelative, formatRating } from '/@/renderer/utils/format';
|
|||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
import { sanitize } from '/@/renderer/utils/sanitize';
|
||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Separator } from '/@/shared/components/separator/separator';
|
||||
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
||||
import { Table } from '/@/shared/components/table/table';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
|
|
@ -49,10 +50,12 @@ const handleRow = <T extends AnyLibraryItem>(t: TFunction, item: T, rule: ItemDe
|
|||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<tr key={rule.label}>
|
||||
<td>{t(rule.label, { postProcess: rule.postprocess || 'sentenceCase' })}</td>
|
||||
<td>{value}</td>
|
||||
</tr>
|
||||
<Table.Tr key={rule.label}>
|
||||
<Table.Th>
|
||||
{t(rule.label, { postProcess: rule.postprocess || 'sentenceCase' })}
|
||||
</Table.Th>
|
||||
<Table.Td>{value}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -62,8 +65,9 @@ const formatArtists = (artists: null | RelatedArtist[] | undefined) =>
|
|||
{index > 0 && <Separator />}
|
||||
{artist.id ? (
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw={500}
|
||||
isLink
|
||||
overflow="visible"
|
||||
size="md"
|
||||
to={
|
||||
|
|
@ -73,7 +77,6 @@ const formatArtists = (artists: null | RelatedArtist[] | undefined) =>
|
|||
})
|
||||
: ''
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{artist.name || '—'}
|
||||
</Text>
|
||||
|
|
@ -102,12 +105,12 @@ const FormatGenre = (item: Album | AlbumArtist | Playlist | Song) => {
|
|||
<span key={genre.id}>
|
||||
{index > 0 && <Separator />}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw={500}
|
||||
isLink
|
||||
overflow="visible"
|
||||
size="md"
|
||||
to={genre.id ? generatePath(genreRoute, { genreId: genre.id }) : ''}
|
||||
weight={500}
|
||||
>
|
||||
{genre.name || '—'}
|
||||
</Text>
|
||||
|
|
@ -116,7 +119,17 @@ const FormatGenre = (item: Album | AlbumArtist | Playlist | Song) => {
|
|||
};
|
||||
|
||||
const BoolField = (key: boolean) =>
|
||||
key ? <RiCheckFill size="1.1rem" /> : <RiCloseFill size="1.1rem" />;
|
||||
key ? (
|
||||
<Icon
|
||||
color="success"
|
||||
icon="check"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
color="error"
|
||||
icon="x"
|
||||
/>
|
||||
);
|
||||
|
||||
const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
|
||||
{ key: 'name', label: 'common.title' },
|
||||
|
|
@ -154,13 +167,13 @@ const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
|
|||
postprocess: [],
|
||||
render: (album) =>
|
||||
album.mbzId ? (
|
||||
<a
|
||||
href={`https://musicbrainz.org/release/${album.mbzId}`}
|
||||
<Link
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
to={`https://musicbrainz.org/release/${album.mbzId}`}
|
||||
>
|
||||
{album.mbzId}
|
||||
</a>
|
||||
</Link>
|
||||
) : null,
|
||||
},
|
||||
{ key: 'id', label: 'filter.id' },
|
||||
|
|
@ -189,13 +202,13 @@ const AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [
|
|||
postprocess: [],
|
||||
render: (artist) =>
|
||||
artist.mbz ? (
|
||||
<a
|
||||
href={`https://musicbrainz.org/artist/${artist.mbz}`}
|
||||
<Link
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
to={`https://musicbrainz.org/artist/${artist.mbz}`}
|
||||
>
|
||||
{artist.mbz}
|
||||
</a>
|
||||
</Link>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
|
|
@ -246,8 +259,9 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [
|
|||
song.albumId &&
|
||||
song.album && (
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw={500}
|
||||
isLink
|
||||
overflow="visible"
|
||||
size="md"
|
||||
to={
|
||||
|
|
@ -257,7 +271,6 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [
|
|||
})
|
||||
: ''
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{song.album}
|
||||
</Text>
|
||||
|
|
@ -314,26 +327,24 @@ const handleTags = (item: Album | Song, t: TFunction) => {
|
|||
if (item.tags) {
|
||||
const tags = Object.entries(item.tags).map(([tag, fields]) => {
|
||||
return (
|
||||
<tr key={tag}>
|
||||
<td>
|
||||
<Table.Tr key={tag}>
|
||||
<Table.Th>
|
||||
{tag.slice(0, 1).toLocaleUpperCase()}
|
||||
{tag.slice(1)}
|
||||
</td>
|
||||
<td>{fields.length === 0 ? BoolField(true) : fields.join(SEPARATOR_STRING)}</td>
|
||||
</tr>
|
||||
</Table.Th>
|
||||
<Table.Td>
|
||||
{fields.length === 0 ? BoolField(true) : fields.join(SEPARATOR_STRING)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
});
|
||||
|
||||
if (tags.length) {
|
||||
return [
|
||||
<tr key="tags">
|
||||
<td>
|
||||
<h3>{t('common.tags', { postProcess: 'sentenceCase' })}</h3>
|
||||
</td>
|
||||
<td>
|
||||
<h3>{tags.length}</h3>
|
||||
</td>
|
||||
</tr>,
|
||||
<Table.Tr key="tags">
|
||||
<Table.Th>{t('common.tags', { postProcess: 'sentenceCase' })}</Table.Th>
|
||||
<Table.Td>{tags.length}</Table.Td>
|
||||
</Table.Tr>,
|
||||
].concat(tags);
|
||||
}
|
||||
}
|
||||
|
|
@ -345,30 +356,26 @@ const handleParticipants = (item: Album | Song, t: TFunction) => {
|
|||
if (item.participants) {
|
||||
const participants = Object.entries(item.participants).map(([role, participants]) => {
|
||||
return (
|
||||
<tr key={role}>
|
||||
<td>
|
||||
<Table.Tr key={role}>
|
||||
<Table.Th>
|
||||
{role.slice(0, 1).toLocaleUpperCase()}
|
||||
{role.slice(1)}
|
||||
</td>
|
||||
<td>{formatArtists(participants)}</td>
|
||||
</tr>
|
||||
</Table.Th>
|
||||
<Table.Td>{formatArtists(participants)}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
});
|
||||
|
||||
if (participants.length) {
|
||||
return [
|
||||
<tr key="participants">
|
||||
<td>
|
||||
<h3>
|
||||
{t('common.additionalParticipants', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</h3>
|
||||
</td>
|
||||
<td>
|
||||
<h3>{participants.length}</h3>
|
||||
</td>
|
||||
</tr>,
|
||||
<Table.Tr key="participants">
|
||||
<Table.Th>
|
||||
{t('common.additionalParticipants', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Table.Th>
|
||||
<Table.Td>{participants.length}</Table.Td>
|
||||
</Table.Tr>,
|
||||
].concat(participants);
|
||||
}
|
||||
}
|
||||
|
|
@ -402,15 +409,13 @@ export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Table
|
||||
highlightOnHover
|
||||
horizontalSpacing="sm"
|
||||
sx={{ userSelect: 'text', whiteSpace: 'pre-line' }}
|
||||
verticalSpacing="sm"
|
||||
>
|
||||
<tbody>{body}</tbody>
|
||||
</Table>
|
||||
</Group>
|
||||
<Table
|
||||
highlightOnHover
|
||||
variant="vertical"
|
||||
withRowBorders={false}
|
||||
withTableBorder
|
||||
>
|
||||
<Table.Tbody>{body}</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { ActionIcon, CopyButton, Group } from '@mantine/core';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiCheckFill, RiClipboardFill, RiExternalLinkFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { toast, Tooltip } from '/@/renderer/components';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { CopyButton } from '/@/shared/components/copy-button/copy-button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
|
||||
const util = isElectron() ? window.api.utils : null;
|
||||
|
||||
|
|
@ -12,10 +15,6 @@ export type SongPathProps = {
|
|||
path: null | string;
|
||||
};
|
||||
|
||||
const PathText = styled.div`
|
||||
user-select: all;
|
||||
`;
|
||||
|
||||
export const SongPath = ({ path }: SongPathProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -37,8 +36,11 @@ export const SongPath = ({ path }: SongPathProps) => {
|
|||
)}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon onClick={copy}>
|
||||
{copied ? <RiCheckFill /> : <RiClipboardFill />}
|
||||
<ActionIcon
|
||||
onClick={copy}
|
||||
variant="transparent"
|
||||
>
|
||||
{copied ? <Icon icon="check" /> : <Icon icon="clipboardCopy" />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
@ -48,23 +50,23 @@ export const SongPath = ({ path }: SongPathProps) => {
|
|||
label={t('page.itemDetail.openFile', { postProcess: 'sentenceCase' })}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon>
|
||||
<RiExternalLinkFill
|
||||
onClick={() => {
|
||||
util.openItem(path).catch((error) => {
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
title: t('error.openError', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
});
|
||||
<ActionIcon
|
||||
icon="externalLink"
|
||||
onClick={() => {
|
||||
util.openItem(path).catch((error) => {
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
title: t('error.openError', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
});
|
||||
}}
|
||||
variant="transparent"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<PathText>{path}</PathText>
|
||||
<Text style={{ userSelect: 'all' }}>{path}</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
.search-item {
|
||||
all: unset;
|
||||
box-sizing: border-box !important;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--theme-btn-default-fg-hover);
|
||||
background: var(--theme-btn-default-bg-hover);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +1,27 @@
|
|||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { openModal } from '@mantine/modals';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import styles from './lyrics-search-form.module.css';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { ScrollArea, Spinner, Text, TextInput } from '/@/renderer/components';
|
||||
import { useLyricSearch } from '/@/renderer/features/lyrics/queries/lyric-search-query';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import {
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSource,
|
||||
LyricsOverride,
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
const SearchItem = styled.button`
|
||||
all: unset;
|
||||
box-sizing: border-box !important;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--btn-default-fg-hover);
|
||||
background: var(--btn-default-bg-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
interface SearchResultProps {
|
||||
data: InternetProviderLyricSearchResponse;
|
||||
onClick?: () => void;
|
||||
|
|
@ -46,28 +38,31 @@ const SearchResult = ({ data, onClick }: SearchResultProps) => {
|
|||
source === LyricSource.GENIUS ? id.replace(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)/g, '') : id;
|
||||
|
||||
return (
|
||||
<SearchItem onClick={onClick}>
|
||||
<button
|
||||
className={styles.searchItem}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Group
|
||||
noWrap
|
||||
position="apart"
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Stack
|
||||
gap={0}
|
||||
maw="65%"
|
||||
spacing={0}
|
||||
>
|
||||
<Text
|
||||
fw={600}
|
||||
size="md"
|
||||
weight={600}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Text $secondary>{artist}</Text>
|
||||
<Text isMuted>{artist}</Text>
|
||||
<Group
|
||||
noWrap
|
||||
spacing="sm"
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text
|
||||
$secondary
|
||||
isMuted
|
||||
size="sm"
|
||||
>
|
||||
{[source, cleanId].join(' — ')}
|
||||
|
|
@ -76,7 +71,7 @@ const SearchResult = ({ data, onClick }: SearchResultProps) => {
|
|||
</Stack>
|
||||
<Text>{percentageScore}%</Text>
|
||||
</Group>
|
||||
</SearchItem>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -141,13 +136,12 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
|
|||
<Spinner container />
|
||||
) : (
|
||||
<ScrollArea
|
||||
h={400}
|
||||
offsetScrollbars
|
||||
pr="1rem"
|
||||
type="auto"
|
||||
w="100%"
|
||||
style={{
|
||||
height: '400px',
|
||||
paddingRight: '1rem',
|
||||
}}
|
||||
>
|
||||
<Stack spacing="md">
|
||||
<Stack gap="md">
|
||||
{searchResults.map((result) => (
|
||||
<SearchResult
|
||||
data={result}
|
||||
|
|
|
|||
21
src/renderer/features/lyrics/lyric-line.module.css
Normal file
21
src/renderer/features/lyrics/lyric-line.module.css
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
.lyric-line {
|
||||
padding: 0 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-colors-foreground);
|
||||
opacity: 0.5;
|
||||
transition:
|
||||
opacity 0.3s ease-in-out,
|
||||
transform 0.3s ease-in-out;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.unsynchronized {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.synchronized {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
.lyric-line {
|
||||
color: var(--main-fg);
|
||||
font-weight: 400;
|
||||
|
||||
font-size: 2.5vmax;
|
||||
transform: scale(0.95);
|
||||
opacity: 0.5;
|
||||
|
||||
.active {
|
||||
font-weight: 800 !important;
|
||||
transform: scale(1) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.active.unsynchronized {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.lyric-line.active {
|
||||
font-weight: 800;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { TitleProps } from '@mantine/core';
|
||||
import clsx from 'clsx';
|
||||
import { ComponentPropsWithoutRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { TextTitle } from '/@/renderer/components/text-title';
|
||||
import styles from './lyric-line.module.css';
|
||||
|
||||
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
|
||||
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
|
||||
alignment: 'center' | 'left' | 'right';
|
||||
|
|
@ -10,39 +11,17 @@ interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
|
|||
text: string;
|
||||
}
|
||||
|
||||
const StyledText = styled(TextTitle)<TitleProps & { $alignment: string; $fontSize: number }>`
|
||||
padding: 0 1rem;
|
||||
font-size: ${(props) => props.$fontSize}px;
|
||||
font-weight: 600;
|
||||
color: var(--main-fg);
|
||||
text-align: ${(props) => props.$alignment};
|
||||
opacity: 0.5;
|
||||
|
||||
transition:
|
||||
opacity 0.3s ease-in-out,
|
||||
transform 0.3s ease-in-out;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.unsynchronized {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.synchronized {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
export const LyricLine = ({ alignment, fontSize, text, ...props }: LyricLineProps) => {
|
||||
export const LyricLine = ({ alignment, className, fontSize, text, ...props }: LyricLineProps) => {
|
||||
return (
|
||||
<StyledText
|
||||
$alignment={alignment}
|
||||
$fontSize={fontSize}
|
||||
<TextTitle
|
||||
className={clsx(styles.lyricLine, className)}
|
||||
style={{
|
||||
fontSize,
|
||||
textAlign: alignment,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</StyledText>
|
||||
</TextTitle>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { Box, Center, Group, Select, SelectItem } from '@mantine/core';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiAddFill, RiSubtractFill } from 'react-icons/ri';
|
||||
|
||||
import { Button, NumberInput, Tooltip } from '/@/renderer/components';
|
||||
import { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form';
|
||||
import {
|
||||
useCurrentSong,
|
||||
|
|
@ -11,11 +8,18 @@ import {
|
|||
useSettingsStore,
|
||||
useSettingsStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { LyricsOverride } from '/@/shared/types/domain-types';
|
||||
|
||||
interface LyricsActionsProps {
|
||||
index: number;
|
||||
languages: SelectItem[];
|
||||
languages: { label: string; value: string }[];
|
||||
|
||||
onRemoveLyric: () => void;
|
||||
onResetLyric: () => void;
|
||||
|
|
@ -38,7 +42,7 @@ export const LyricsActions = ({
|
|||
const { setSettings } = useSettingsStoreActions();
|
||||
const { delayMs, sources } = useLyricsSettings();
|
||||
|
||||
const handleLyricOffset = (e: number) => {
|
||||
const handleLyricOffset = (e: number | string) => {
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...useSettingsStore.getState().lyrics,
|
||||
|
|
@ -51,7 +55,7 @@ export const LyricsActions = ({
|
|||
const isDesktop = isElectron();
|
||||
|
||||
return (
|
||||
<Box style={{ position: 'relative', width: '100%' }}>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
{languages.length > 1 && (
|
||||
<Center>
|
||||
<Select
|
||||
|
|
@ -64,7 +68,7 @@ export const LyricsActions = ({
|
|||
</Center>
|
||||
)}
|
||||
|
||||
<Group position="center">
|
||||
<Group justify="center">
|
||||
{isDesktop && sources.length ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
|
|
@ -81,13 +85,12 @@ export const LyricsActions = ({
|
|||
{t('common.search', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
<ActionIcon
|
||||
aria-label="Decrease lyric offset"
|
||||
icon="minus"
|
||||
onClick={() => handleLyricOffset(delayMs - 50)}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSubtractFill />
|
||||
</Button>
|
||||
/>
|
||||
<Tooltip
|
||||
label={t('setting.lyricOffset', { postProcess: 'sentenceCase' })}
|
||||
openDelay={500}
|
||||
|
|
@ -100,13 +103,12 @@ export const LyricsActions = ({
|
|||
width={55}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
<ActionIcon
|
||||
aria-label="Increase lyric offset"
|
||||
icon="plus"
|
||||
onClick={() => handleLyricOffset(delayMs + 50)}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiAddFill />
|
||||
</Button>
|
||||
/>
|
||||
{isDesktop && sources.length ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
|
|
@ -119,7 +121,7 @@ export const LyricsActions = ({
|
|||
) : null}
|
||||
</Group>
|
||||
|
||||
<Box style={{ position: 'absolute', right: 0, top: 0 }}>
|
||||
<div style={{ position: 'absolute', right: 0, top: 0 }}>
|
||||
{isDesktop && sources.length ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
|
|
@ -130,9 +132,9 @@ export const LyricsActions = ({
|
|||
{t('common.clear', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<Box style={{ position: 'absolute', right: 0, top: -50 }}>
|
||||
<div style={{ position: 'absolute', right: 0, top: -50 }}>
|
||||
{isDesktop && sources.length ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
|
|
@ -143,7 +145,7 @@ export const LyricsActions = ({
|
|||
{t('common.translation', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
49
src/renderer/features/lyrics/lyrics.module.css
Normal file
49
src/renderer/features/lyrics/lyrics.module.css
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
.actions-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
.actions-container {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
mask-image: linear-gradient(
|
||||
180deg,
|
||||
transparent 5%,
|
||||
rgb(0 0 0 / 100%) 20%,
|
||||
rgb(0 0 0 / 100%) 85%,
|
||||
transparent 95%
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
import { Center, Group } from '@mantine/core';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiInformationFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import styles from './lyrics.module.css';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Spinner, TextTitle } from '/@/renderer/components';
|
||||
import { ErrorFallback } from '/@/renderer/features/action-required';
|
||||
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
|
||||
import {
|
||||
|
|
@ -25,72 +23,13 @@ import {
|
|||
} from '/@/renderer/features/lyrics/unsynchronized-lyrics';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentSong, useLyricsSettings, usePlayerStore } from '/@/renderer/store';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { FullLyricsMetadata, LyricSource, LyricsOverride } from '/@/shared/types/domain-types';
|
||||
|
||||
const ActionsContainer = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const LyricsContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
${ActionsContainer} {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ScrollContainer = styled(motion.div)`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
|
||||
mask-image: linear-gradient(
|
||||
180deg,
|
||||
transparent 5%,
|
||||
rgb(0 0 0 / 100%) 20%,
|
||||
rgb(0 0 0 / 100%) 85%,
|
||||
transparent 95%
|
||||
);
|
||||
|
||||
&.mantine-ScrollArea-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& .mantine-ScrollArea-viewport {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
& .mantine-ScrollArea-viewport > div {
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Lyrics = () => {
|
||||
const currentSong = useCurrentSong();
|
||||
const lyricsSettings = useLyricsSettings();
|
||||
|
|
@ -210,7 +149,7 @@ export const Lyrics = () => {
|
|||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<LyricsContainer>
|
||||
<div className={styles.lyricsContainer}>
|
||||
{isLoadingLyrics ? (
|
||||
<Spinner
|
||||
container
|
||||
|
|
@ -221,20 +160,18 @@ export const Lyrics = () => {
|
|||
{hasNoLyrics ? (
|
||||
<Center w="100%">
|
||||
<Group>
|
||||
<RiInformationFill size="2rem" />
|
||||
<TextTitle
|
||||
order={3}
|
||||
weight={700}
|
||||
>
|
||||
<Icon icon="info" />
|
||||
<Text>
|
||||
{t('page.fullscreenPlayer.noLyrics', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</TextTitle>
|
||||
</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
) : (
|
||||
<ScrollContainer
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className={styles.scrollContainer}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
|
|
@ -249,11 +186,11 @@ export const Lyrics = () => {
|
|||
translatedLyrics={showTranslation ? translatedLyrics : null}
|
||||
/>
|
||||
)}
|
||||
</ScrollContainer>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
<ActionsContainer>
|
||||
<div className={styles.actionsContainer}>
|
||||
<LyricsActions
|
||||
index={index}
|
||||
languages={languages}
|
||||
|
|
@ -263,8 +200,8 @@ export const Lyrics = () => {
|
|||
onTranslateLyric={handleOnTranslateLyric}
|
||||
setIndex={setIndex}
|
||||
/>
|
||||
</ActionsContainer>
|
||||
</LyricsContainer>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
3
src/renderer/features/lyrics/synchronized-lyrics.css
Normal file
3
src/renderer/features/lyrics/synchronized-lyrics.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.active {
|
||||
opacity: 1;
|
||||
}
|
||||
21
src/renderer/features/lyrics/synchronized-lyrics.module.css
Normal file
21
src/renderer/features/lyrics/synchronized-lyrics.module.css
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10vh 0 50vh;
|
||||
overflow: scroll;
|
||||
word-break: break-all;
|
||||
mask-image: linear-gradient(
|
||||
180deg,
|
||||
transparent 5%,
|
||||
rgb(0 0 0 / 100%) 20%,
|
||||
rgb(0 0 0 / 100%) 85%,
|
||||
transparent 95%
|
||||
);
|
||||
transform: translateY(-2rem);
|
||||
|
||||
@media screen and (orientation: portrait) {
|
||||
padding: 5vh 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import clsx from 'clsx';
|
||||
import isElectron from 'is-electron';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import styles from './synchronized-lyrics.module.css';
|
||||
import './synchronized-lyrics.css';
|
||||
|
||||
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
|
||||
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
|
||||
|
|
@ -22,38 +25,6 @@ const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
|||
const utils = isElectron() ? window.api.utils : null;
|
||||
const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
|
||||
|
||||
const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${(props) => props.$gap || 5}px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10vh 0 50vh;
|
||||
overflow: scroll;
|
||||
word-break: break-word;
|
||||
|
||||
-webkit-mask-image: linear-gradient(
|
||||
180deg,
|
||||
transparent 5%,
|
||||
rgb(0 0 0 / 100%) 20%,
|
||||
rgb(0 0 0 / 100%) 85%,
|
||||
transparent 95%
|
||||
);
|
||||
|
||||
mask-image: linear-gradient(
|
||||
180deg,
|
||||
transparent 5%,
|
||||
rgb(0 0 0 / 100%) 20%,
|
||||
rgb(0 0 0 / 100%) 85%,
|
||||
transparent 95%
|
||||
);
|
||||
transform: translateY(-2rem);
|
||||
|
||||
@media screen and (orientation: portrait) {
|
||||
padding: 5vh 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||
lyrics: SynchronizedLyricsArray;
|
||||
translatedLyrics?: null | string;
|
||||
|
|
@ -344,12 +315,12 @@ export const SynchronizedLyrics = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<SynchronizedLyricsContainer
|
||||
$gap={settings.gap}
|
||||
className="synchronized-lyrics overlay-scrollbar"
|
||||
<div
|
||||
className={clsx(styles.container, 'synchronized-lyrics overlay-scrollbar')}
|
||||
id="sychronized-lyrics-scroll-container"
|
||||
onMouseEnter={showScrollbar}
|
||||
onMouseLeave={hideScrollbar}
|
||||
style={{ gap: `${settings.gap}px` }}
|
||||
>
|
||||
{settings.showProvider && source && (
|
||||
<LyricLine
|
||||
|
|
@ -388,6 +359,6 @@ export const SynchronizedLyrics = ({
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
</SynchronizedLyricsContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10vh 0 6vh;
|
||||
overflow: scroll;
|
||||
mask-image: linear-gradient(
|
||||
180deg,
|
||||
transparent 5%,
|
||||
rgb(0 0 0 / 100%) 20%,
|
||||
rgb(0 0 0 / 100%) 85%,
|
||||
transparent 95%
|
||||
);
|
||||
transform: translateY(-2rem);
|
||||
|
||||
@media screen and (orientation: portrait) {
|
||||
padding: 5vh 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import styles from './unsynchronized-lyrics.module.css';
|
||||
|
||||
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
|
||||
import { useLyricsSettings } from '/@/renderer/store';
|
||||
|
|
@ -10,37 +11,6 @@ export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyr
|
|||
translatedLyrics?: null | string;
|
||||
}
|
||||
|
||||
const UnsynchronizedLyricsContainer = styled.div<{ $gap: number }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${(props) => props.$gap || 5}px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10vh 0 6vh;
|
||||
overflow: scroll;
|
||||
|
||||
-webkit-mask-image: linear-gradient(
|
||||
180deg,
|
||||
transparent 5%,
|
||||
rgb(0 0 0 / 100%) 20%,
|
||||
rgb(0 0 0 / 100%) 85%,
|
||||
transparent 95%
|
||||
);
|
||||
|
||||
mask-image: linear-gradient(
|
||||
180deg,
|
||||
transparent 5%,
|
||||
rgb(0 0 0 / 100%) 20%,
|
||||
rgb(0 0 0 / 100%) 85%,
|
||||
transparent 95%
|
||||
);
|
||||
transform: translateY(-2rem);
|
||||
|
||||
@media screen and (orientation: portrait) {
|
||||
padding: 5vh 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const UnsynchronizedLyrics = ({
|
||||
artist,
|
||||
lyrics,
|
||||
|
|
@ -59,9 +29,9 @@ export const UnsynchronizedLyrics = ({
|
|||
}, [translatedLyrics]);
|
||||
|
||||
return (
|
||||
<UnsynchronizedLyricsContainer
|
||||
$gap={settings.gapUnsync}
|
||||
className="unsynchronized-lyrics"
|
||||
<div
|
||||
className={styles.container}
|
||||
style={{ gap: `${settings.gapUnsync}px` }}
|
||||
>
|
||||
{settings.showProvider && source && (
|
||||
<LyricLine
|
||||
|
|
@ -98,6 +68,6 @@ export const UnsynchronizedLyrics = ({
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
</UnsynchronizedLyricsContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { Box, Flex } from '@mantine/core';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export const DrawerPlayQueue = () => {
|
||||
|
|
@ -15,17 +15,19 @@ export const DrawerPlayQueue = () => {
|
|||
direction="column"
|
||||
h="100%"
|
||||
>
|
||||
<Box
|
||||
bg="var(--main-bg)"
|
||||
sx={{ borderRadius: '10px' }}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-colors-background)',
|
||||
borderRadius: '10px',
|
||||
}}
|
||||
>
|
||||
<PlayQueueListControls
|
||||
tableRef={queueRef}
|
||||
type="sideQueue"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
<Flex
|
||||
bg="var(--main-bg)"
|
||||
bg="var(--theme-colors-background)"
|
||||
h="100%"
|
||||
mb="0.6rem"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { PageHeader } from '/@/renderer/components';
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
|
||||
export const NowPlayingHeader = () => {
|
||||
|
|
@ -6,7 +6,7 @@ export const NowPlayingHeader = () => {
|
|||
// const theme = useTheme();
|
||||
|
||||
return (
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<PageHeader>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>Queue</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,18 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
import { Group } from '@mantine/core';
|
||||
import isElectron from 'is-electron';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiArrowDownLine,
|
||||
RiArrowGoForwardLine,
|
||||
RiArrowUpLine,
|
||||
RiDeleteBinLine,
|
||||
RiEraserLine,
|
||||
RiListSettingsLine,
|
||||
RiShuffleLine,
|
||||
} from 'react-icons/ri';
|
||||
|
||||
import { Button, Popover } from '/@/renderer/components';
|
||||
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
|
||||
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||
import { usePlayerControls, useQueueControls } from '/@/renderer/store';
|
||||
import { usePlayerStore, useSetCurrentTime } from '/@/renderer/store/player.store';
|
||||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
||||
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
import { PlaybackType, TableType } from '/@/shared/types/types';
|
||||
|
||||
|
|
@ -129,69 +121,57 @@ export const PlayQueueListControls = ({ tableRef, type }: PlayQueueListOptionsPr
|
|||
|
||||
return (
|
||||
<Group
|
||||
position="apart"
|
||||
justify="space-between"
|
||||
px="1rem"
|
||||
py="1rem"
|
||||
sx={{ alignItems: 'center' }}
|
||||
style={{ alignItems: 'center' }}
|
||||
w="100%"
|
||||
>
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
compact
|
||||
<Group gap="sm">
|
||||
<ActionIcon
|
||||
icon="mediaShuffle"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleShuffleQueue}
|
||||
size="md"
|
||||
tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}
|
||||
variant="default"
|
||||
>
|
||||
<RiShuffleLine size="1.1rem" />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="mediaPlayNext"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleMoveToNext}
|
||||
size="md"
|
||||
tooltip={{ label: t('action.moveToNext', { postProcess: 'sentenceCase' }) }}
|
||||
variant="default"
|
||||
>
|
||||
<RiArrowGoForwardLine size="1.1rem" />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="arrowDownToLine"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleMoveToBottom}
|
||||
size="md"
|
||||
tooltip={{ label: t('action.moveToBottom', { postProcess: 'sentenceCase' }) }}
|
||||
variant="default"
|
||||
>
|
||||
<RiArrowDownLine size="1.1rem" />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="arrowUpToLine"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleMoveToTop}
|
||||
size="md"
|
||||
tooltip={{ label: t('action.moveToTop', { postProcess: 'sentenceCase' }) }}
|
||||
variant="default"
|
||||
>
|
||||
<RiArrowUpLine size="1.1rem" />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="delete"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleRemoveSelected}
|
||||
size="md"
|
||||
tooltip={{
|
||||
label: t('action.removeFromQueue', { postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
<RiEraserLine size="1.1rem" />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="x"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleClearQueue}
|
||||
size="md"
|
||||
tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }}
|
||||
variant="default"
|
||||
>
|
||||
<RiDeleteBinLine size="1.1rem" />
|
||||
</Button>
|
||||
variant="subtle"
|
||||
/>
|
||||
</Group>
|
||||
<Group>
|
||||
<Popover
|
||||
|
|
@ -199,16 +179,14 @@ export const PlayQueueListControls = ({ tableRef, type }: PlayQueueListOptionsPr
|
|||
transitionProps={{ transition: 'fade' }}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
<ActionIcon
|
||||
icon="settings"
|
||||
iconProps={{ size: 'lg' }}
|
||||
tooltip={{
|
||||
label: t('common.configure', { postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiListSettingsLine size="1.1rem" />
|
||||
</Button>
|
||||
/>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<TableConfigDropdown type={type} />
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { Stack } from '@mantine/core';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { PlayQueueListControls } from './play-queue-list-controls';
|
||||
|
||||
import { PageHeader, Paper } from '/@/renderer/components';
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { VirtualGridContainer } from '/@/renderer/components/virtual-grid';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
import { useWindowSettings } from '/@/renderer/store/settings.store';
|
||||
import { Box } from '/@/shared/components/box/box';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
|
|
@ -21,10 +22,10 @@ export const SidebarPlayQueue = () => {
|
|||
<VirtualGridContainer>
|
||||
{isWeb && (
|
||||
<Stack mr={isWeb ? '130px' : undefined}>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)" />
|
||||
<PageHeader />
|
||||
</Stack>
|
||||
)}
|
||||
<Paper
|
||||
<Box
|
||||
display={!isWeb ? 'flex' : undefined}
|
||||
h={!isWeb ? '65px' : undefined}
|
||||
>
|
||||
|
|
@ -32,7 +33,7 @@ export const SidebarPlayQueue = () => {
|
|||
tableRef={queueRef}
|
||||
type="sideQueue"
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
<PlayQueue
|
||||
ref={queueRef}
|
||||
type="sideQueue"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
|||
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { Paper } from '/@/renderer/components';
|
||||
import { VirtualGridContainer } from '/@/renderer/components/virtual-grid';
|
||||
import { NowPlayingHeader } from '/@/renderer/features/now-playing/components/now-playing-header';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
|
|
@ -17,12 +16,10 @@ const NowPlayingRoute = () => {
|
|||
<AnimatedPage>
|
||||
<VirtualGridContainer>
|
||||
<NowPlayingHeader />
|
||||
<Paper sx={{ borderTop: '1px solid var(--generic-border-color)' }}>
|
||||
<PlayQueueListControls
|
||||
tableRef={queueRef}
|
||||
type="nowPlaying"
|
||||
/>
|
||||
</Paper>
|
||||
<PlayQueueListControls
|
||||
tableRef={queueRef}
|
||||
type="nowPlaying"
|
||||
/>
|
||||
<PlayQueue
|
||||
ref={queueRef}
|
||||
type="nowPlaying"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
.buttons-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
display: flex;
|
||||
width: 95%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.slider-value-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
max-width: 50px;
|
||||
|
||||
@media (width < 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-wrapper {
|
||||
display: flex;
|
||||
flex: 6;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.controls-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 35px;
|
||||
|
||||
@media (width < 768px) {
|
||||
.buttons-container {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.slider-value-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,22 +4,9 @@ import formatDuration from 'format-duration';
|
|||
import isElectron from 'is-electron';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BsDice3 } from 'react-icons/bs';
|
||||
import { IoIosPause } from 'react-icons/io';
|
||||
import {
|
||||
RiPlayFill,
|
||||
RiRepeat2Line,
|
||||
RiRepeatOneLine,
|
||||
RiRewindFill,
|
||||
RiShuffleFill,
|
||||
RiSkipBackFill,
|
||||
RiSkipForwardFill,
|
||||
RiSpeedFill,
|
||||
RiStopFill,
|
||||
} from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Text } from '/@/renderer/components';
|
||||
import styles from './center-controls.module.css';
|
||||
|
||||
import { PlayerButton } from '/@/renderer/features/player/components/player-button';
|
||||
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||
import { openShuffleAllModal } from '/@/renderer/features/player/components/shuffle-all-modal';
|
||||
|
|
@ -39,60 +26,14 @@ import {
|
|||
usePlaybackType,
|
||||
useSettingsStore,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
interface CenterControlsProps {
|
||||
playersRef: any;
|
||||
}
|
||||
|
||||
const ButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SliderContainer = styled.div`
|
||||
display: flex;
|
||||
width: 95%;
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
const SliderValueWrapper = styled.div<{ $position: 'left' | 'right' }>`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
max-width: 50px;
|
||||
|
||||
@media (width <= 768px) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const SliderWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 6;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 35px;
|
||||
|
||||
@media (width <= 768px) {
|
||||
${ButtonsContainer} {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
${SliderValueWrapper} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -171,10 +112,16 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ControlsContainer>
|
||||
<ButtonsContainer>
|
||||
<div className={styles.controlsContainer}>
|
||||
<div className={styles.buttonsContainer}>
|
||||
<PlayerButton
|
||||
icon={<RiStopFill size={buttonSize} />}
|
||||
icon={
|
||||
<Icon
|
||||
fill="default"
|
||||
icon="mediaStop"
|
||||
size={buttonSize}
|
||||
/>
|
||||
}
|
||||
onClick={handleStop}
|
||||
tooltip={{
|
||||
label: t('player.stop', { postProcess: 'sentenceCase' }),
|
||||
|
|
@ -182,8 +129,14 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||
variant="tertiary"
|
||||
/>
|
||||
<PlayerButton
|
||||
$isActive={shuffle !== PlayerShuffle.NONE}
|
||||
icon={<RiShuffleFill size={buttonSize} />}
|
||||
icon={
|
||||
<Icon
|
||||
fill={shuffle === PlayerShuffle.NONE ? 'default' : 'primary'}
|
||||
icon="mediaShuffle"
|
||||
size={buttonSize}
|
||||
/>
|
||||
}
|
||||
isActive={shuffle !== PlayerShuffle.NONE}
|
||||
onClick={handleToggleShuffle}
|
||||
tooltip={{
|
||||
label:
|
||||
|
|
@ -197,7 +150,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||
variant="tertiary"
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<RiSkipBackFill size={buttonSize} />}
|
||||
icon={
|
||||
<Icon
|
||||
fill="default"
|
||||
icon="mediaPrevious"
|
||||
size={buttonSize}
|
||||
/>
|
||||
}
|
||||
onClick={handlePrevTrack}
|
||||
tooltip={{
|
||||
label: t('player.previous', { postProcess: 'sentenceCase' }),
|
||||
|
|
@ -206,7 +165,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||
/>
|
||||
{skip?.enabled && (
|
||||
<PlayerButton
|
||||
icon={<RiRewindFill size={buttonSize} />}
|
||||
icon={
|
||||
<Icon
|
||||
fill="default"
|
||||
icon="mediaStepBackward"
|
||||
size={buttonSize}
|
||||
/>
|
||||
}
|
||||
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
|
||||
tooltip={{
|
||||
label: t('player.skip', {
|
||||
|
|
@ -221,9 +186,15 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||
disabled={currentSong?.id === undefined}
|
||||
icon={
|
||||
status === PlayerStatus.PAUSED ? (
|
||||
<RiPlayFill size={buttonSize} />
|
||||
<Icon
|
||||
icon="mediaPlay"
|
||||
size={buttonSize}
|
||||
/>
|
||||
) : (
|
||||
<IoIosPause size={buttonSize} />
|
||||
<Icon
|
||||
icon="mediaPause"
|
||||
size={buttonSize}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={handlePlayPause}
|
||||
|
|
@ -237,7 +208,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||
/>
|
||||
{skip?.enabled && (
|
||||
<PlayerButton
|
||||
icon={<RiSpeedFill size={buttonSize} />}
|
||||
icon={
|
||||
<Icon
|
||||
fill="default"
|
||||
icon="mediaStepForward"
|
||||
size={buttonSize}
|
||||
/>
|
||||
}
|
||||
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
|
||||
tooltip={{
|
||||
label: t('player.skip', {
|
||||
|
|
@ -249,7 +226,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||
/>
|
||||
)}
|
||||
<PlayerButton
|
||||
icon={<RiSkipForwardFill size={buttonSize} />}
|
||||
icon={
|
||||
<Icon
|
||||
fill="default"
|
||||
icon="mediaNext"
|
||||
size={buttonSize}
|
||||
/>
|
||||
}
|
||||
onClick={handleNextTrack}
|
||||
tooltip={{
|
||||
label: t('player.next', { postProcess: 'sentenceCase' }),
|
||||
|
|
@ -257,14 +240,22 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||
variant="secondary"
|
||||
/>
|
||||
<PlayerButton
|
||||
$isActive={repeat !== PlayerRepeat.NONE}
|
||||
icon={
|
||||
repeat === PlayerRepeat.ONE ? (
|
||||
<RiRepeatOneLine size={buttonSize} />
|
||||
<Icon
|
||||
fill="primary"
|
||||
icon="mediaRepeatOne"
|
||||
size={buttonSize}
|
||||
/>
|
||||
) : (
|
||||
<RiRepeat2Line size={buttonSize} />
|
||||
<Icon
|
||||
fill={repeat === PlayerRepeat.NONE ? 'default' : 'primary'}
|
||||
icon="mediaRepeat"
|
||||
size={buttonSize}
|
||||
/>
|
||||
)
|
||||
}
|
||||
isActive={repeat !== PlayerRepeat.NONE}
|
||||
onClick={handleToggleRepeat}
|
||||
tooltip={{
|
||||
label: `${
|
||||
|
|
@ -288,7 +279,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||
/>
|
||||
|
||||
<PlayerButton
|
||||
icon={<BsDice3 size={buttonSize} />}
|
||||
icon={
|
||||
<Icon
|
||||
fill="default"
|
||||
icon="mediaRandom"
|
||||
size={buttonSize}
|
||||
/>
|
||||
}
|
||||
onClick={() =>
|
||||
openShuffleAllModal({
|
||||
handlePlayQueueAdd,
|
||||
|
|
@ -300,20 +297,20 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||
}}
|
||||
variant="tertiary"
|
||||
/>
|
||||
</ButtonsContainer>
|
||||
</ControlsContainer>
|
||||
<SliderContainer>
|
||||
<SliderValueWrapper $position="left">
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.sliderContainer}>
|
||||
<div className={styles.sliderValueWrapper}>
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
fw={600}
|
||||
isMuted
|
||||
isNoSelect
|
||||
size="xs"
|
||||
weight={600}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
<SliderWrapper>
|
||||
</div>
|
||||
<div className={styles.sliderWrapper}>
|
||||
<PlayerbarSlider
|
||||
label={(value) => formatDuration(value * 1000)}
|
||||
max={songDuration}
|
||||
|
|
@ -335,18 +332,18 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
|||
value={!isSeeking ? currentTime : seekValue}
|
||||
w="100%"
|
||||
/>
|
||||
</SliderWrapper>
|
||||
<SliderValueWrapper $position="right">
|
||||
</div>
|
||||
<div className={styles.sliderValueWrapper}>
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary
|
||||
fw={600}
|
||||
isMuted
|
||||
isNoSelect
|
||||
size="xs"
|
||||
weight={600}
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
</SliderValueWrapper>
|
||||
</SliderContainer>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
.image {
|
||||
position: absolute;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
object-fit: var(--theme-image-fit);
|
||||
object-position: 50% 100%;
|
||||
border-radius: 5px;
|
||||
filter: drop-shadow(0 0 5px rgb(0 0 0 / 40%)) drop-shadow(0 0 5px rgb(0 0 0 / 40%));
|
||||
}
|
||||
|
||||
.image-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
height: 65%;
|
||||
aspect-ratio: 1/1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
|
||||
h1 {
|
||||
font-size: 3.5vh;
|
||||
}
|
||||
}
|
||||
|
||||
.player-container {
|
||||
@media screen and (height < 640px) {
|
||||
.full-screen-player-image-metadata {
|
||||
display: none;
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +1,27 @@
|
|||
import { Center, Flex, Group, Stack } from '@mantine/core';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'framer-motion';
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'motion/react';
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Badge, Text, TextTitle } from '/@/renderer/components';
|
||||
import styles from './full-screen-player-image.module.css';
|
||||
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useFullScreenPlayerStore, usePlayerData, usePlayerStore } from '/@/renderer/store';
|
||||
import { usePlayerData, usePlayerStore } from '/@/renderer/store';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { Badge } from '/@/shared/components/badge/badge';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlayerData, QueueSong } from '/@/shared/types/domain-types';
|
||||
|
||||
const Image = styled(motion.img)<any>`
|
||||
position: absolute;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
object-fit: ${({ $useAspectRatio }) => ($useAspectRatio ? 'contain' : 'cover')};
|
||||
object-position: 50% 100%;
|
||||
filter: drop-shadow(0 0 5px rgb(0 0 0 / 40%)) drop-shadow(0 0 5px rgb(0 0 0 / 40%));
|
||||
border-radius: 5px;
|
||||
`;
|
||||
|
||||
const ImageContainer = styled(motion.div)`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
height: 65%;
|
||||
aspect-ratio: 1/1;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
interface TransparentMetadataContainer {
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
const MetadataContainer = styled(Stack)<TransparentMetadataContainer>`
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
|
||||
h1 {
|
||||
font-size: 3.5vh;
|
||||
}
|
||||
`;
|
||||
|
||||
const PlayerContainer = styled(Flex)`
|
||||
@media screen and (height <= 640px) {
|
||||
.full-screen-player-image-metadata {
|
||||
display: none;
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
${ImageContainer} {
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const imageVariants: Variants = {
|
||||
closed: {
|
||||
opacity: 0,
|
||||
|
|
@ -93,22 +52,22 @@ const scaleImageUrl = (imageSize: number, url?: null | string) => {
|
|||
.replace(/&height=\d+/, `&height=${imageSize}`);
|
||||
};
|
||||
|
||||
const ImageWithPlaceholder = ({
|
||||
useAspectRatio,
|
||||
...props
|
||||
}: HTMLMotionProps<'img'> & { placeholder?: string; useAspectRatio: boolean }) => {
|
||||
const MotionImage = motion.create(Image);
|
||||
|
||||
const ImageWithPlaceholder = ({ ...props }: HTMLMotionProps<'img'> & { placeholder?: string }) => {
|
||||
if (!props.src) {
|
||||
return (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
style={{
|
||||
background: 'var(--theme-colors-surface)',
|
||||
borderRadius: 'var(--theme-card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
<Icon
|
||||
color="muted"
|
||||
icon="itemAlbum"
|
||||
size="25%"
|
||||
/>
|
||||
</Center>
|
||||
|
|
@ -116,8 +75,8 @@ const ImageWithPlaceholder = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
$useAspectRatio={useAspectRatio}
|
||||
<MotionImage
|
||||
className={styles.image}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -130,7 +89,6 @@ export const FullScreenPlayerImage = () => {
|
|||
const albumArtRes = useSettingsStore((store) => store.general.albumArtRes);
|
||||
|
||||
const { queue } = usePlayerData();
|
||||
const { useImageAspectRatio } = useFullScreenPlayerStore();
|
||||
const currentSong = queue.current;
|
||||
const { background } = useFastAverageColor({
|
||||
algorithm: 'dominant',
|
||||
|
|
@ -195,14 +153,17 @@ export const FullScreenPlayerImage = () => {
|
|||
}, [imageState, mainImageDimensions.idealSize, queue, setImageState]);
|
||||
|
||||
return (
|
||||
<PlayerContainer
|
||||
<Flex
|
||||
align="center"
|
||||
className="full-screen-player-image-container"
|
||||
className={clsx(styles.playerContainer, 'full-screen-player-image-container')}
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
p="1rem"
|
||||
>
|
||||
<ImageContainer ref={mainImageRef}>
|
||||
<div
|
||||
className={styles.imageContainer}
|
||||
ref={mainImageRef}
|
||||
>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="sync"
|
||||
|
|
@ -216,9 +177,8 @@ export const FullScreenPlayerImage = () => {
|
|||
exit="closed"
|
||||
initial="closed"
|
||||
key={imageKey}
|
||||
placeholder="var(--placeholder-bg)"
|
||||
placeholder="var(--theme-colors-foreground-muted)"
|
||||
src={imageState.topImage || ''}
|
||||
useAspectRatio={useImageAspectRatio}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -232,62 +192,55 @@ export const FullScreenPlayerImage = () => {
|
|||
exit="closed"
|
||||
initial="closed"
|
||||
key={imageKey}
|
||||
placeholder="var(--placeholder-bg)"
|
||||
placeholder="var(--theme-colors-foreground-muted)"
|
||||
src={imageState.bottomImage || ''}
|
||||
useAspectRatio={useImageAspectRatio}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ImageContainer>
|
||||
<MetadataContainer
|
||||
className="full-screen-player-image-metadata"
|
||||
</div>
|
||||
<Stack
|
||||
className={styles.metadataContainer}
|
||||
gap="xs"
|
||||
maw="100%"
|
||||
spacing="xs"
|
||||
>
|
||||
<TextTitle
|
||||
align="center"
|
||||
fw={900}
|
||||
order={1}
|
||||
overflow="hidden"
|
||||
style={{
|
||||
textShadow: 'var(--fullscreen-player-text-shadow)',
|
||||
}}
|
||||
w="100%"
|
||||
weight={900}
|
||||
>
|
||||
{currentSong?.name}
|
||||
</TextTitle>
|
||||
<TextTitle
|
||||
$link
|
||||
align="center"
|
||||
component={Link}
|
||||
fw={600}
|
||||
isLink
|
||||
order={3}
|
||||
overflow="hidden"
|
||||
style={{
|
||||
textShadow: 'var(--fullscreen-player-text-shadow)',
|
||||
textShadow: 'var(--theme-fullscreen-player-text-shadow)',
|
||||
}}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong?.albumId || '',
|
||||
})}
|
||||
w="100%"
|
||||
weight={600}
|
||||
>
|
||||
{currentSong?.album}{' '}
|
||||
</TextTitle>
|
||||
<TextTitle
|
||||
align="center"
|
||||
key="fs-artists"
|
||||
order={3}
|
||||
style={{
|
||||
textShadow: 'var(--fullscreen-player-text-shadow)',
|
||||
textShadow: 'var(--theme-fullscreen-player-text-shadow)',
|
||||
}}
|
||||
>
|
||||
{currentSong?.artists?.map((artist, index) => (
|
||||
<Fragment key={`fs-artist-${artist.id}`}>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
$secondary
|
||||
sx={{
|
||||
isMuted
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
|
|
@ -296,16 +249,16 @@ export const FullScreenPlayerImage = () => {
|
|||
</Text>
|
||||
)}
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
fw={600}
|
||||
isLink
|
||||
isMuted
|
||||
style={{
|
||||
textShadow: 'var(--fullscreen-player-text-shadow)',
|
||||
textShadow: 'var(--theme-fullscreen-player-text-shadow)',
|
||||
}}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
weight={600}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
|
|
@ -313,8 +266,8 @@ export const FullScreenPlayerImage = () => {
|
|||
))}
|
||||
</TextTitle>
|
||||
<Group
|
||||
justify="center"
|
||||
mt="sm"
|
||||
position="center"
|
||||
>
|
||||
{currentSong?.container && (
|
||||
<Badge size="lg">
|
||||
|
|
@ -325,7 +278,7 @@ export const FullScreenPlayerImage = () => {
|
|||
<Badge size="lg">{currentSong?.releaseYear}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</MetadataContainer>
|
||||
</PlayerContainer>
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
.queue-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
:global(.ag-header) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.ag-theme-alpine-dark) {
|
||||
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
|
||||
--ag-background-color: rgb(0 0 0 / 0%) !important;
|
||||
--ag-odd-row-background-color: rgb(0 0 0 / 0%) !important;
|
||||
}
|
||||
|
||||
:global(.ag-row) {
|
||||
&::before {
|
||||
background: rgb(0 0 0 / 10%) !important;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ag-row-hover) {
|
||||
background: rgb(0 0 0 / 10%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.active-tab-indicator {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--theme-colors-foreground);
|
||||
}
|
||||
|
||||
.header-item-wrapper {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background: var(--theme-colors-background);
|
||||
border-radius: 5px;
|
||||
opacity: var(--opacity, 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import { Group } from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'motion/react';
|
||||
import { CSSProperties, lazy, Suspense, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HiOutlineQueueList } from 'react-icons/hi2';
|
||||
import { RiFileMusicLine, RiFileTextLine } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Button } from '/@/renderer/components';
|
||||
import styles from './full-screen-player-queue.module.css';
|
||||
|
||||
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing';
|
||||
import { FullScreenSimilarSongs } from '/@/renderer/features/player/components/full-screen-similar-songs';
|
||||
|
|
@ -15,6 +13,8 @@ import {
|
|||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
} from '/@/renderer/store/full-screen-player.store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { PlaybackType } from '/@/shared/types/types';
|
||||
|
||||
const Visualizer = lazy(() =>
|
||||
|
|
@ -23,50 +23,6 @@ const Visualizer = lazy(() =>
|
|||
})),
|
||||
);
|
||||
|
||||
const QueueContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
|
||||
--ag-background-color: rgb(0 0 0 / 0%) !important;
|
||||
--ag-odd-row-background-color: rgb(0 0 0 / 0%) !important;
|
||||
}
|
||||
|
||||
.ag-header {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const ActiveTabIndicator = styled(motion.div)`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--main-fg);
|
||||
`;
|
||||
|
||||
const HeaderItemWrapper = styled.div`
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
`;
|
||||
|
||||
interface TransparentGridContainerProps {
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
const GridContainer = styled.div<TransparentGridContainerProps>`
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
/* stylelint-disable-next-line color-function-notation */
|
||||
background: rgb(var(--main-bg-transparent), ${({ opacity }) => opacity}%);
|
||||
border-radius: 5px;
|
||||
`;
|
||||
|
||||
export const FullScreenPlayerQueue = () => {
|
||||
const { t } = useTranslation();
|
||||
const { activeTab, opacity } = useFullScreenPlayerStore();
|
||||
|
|
@ -77,19 +33,16 @@ export const FullScreenPlayerQueue = () => {
|
|||
const items = [
|
||||
{
|
||||
active: activeTab === 'queue',
|
||||
icon: <RiFileMusicLine size="1.5rem" />,
|
||||
label: t('page.fullscreenPlayer.upNext'),
|
||||
onClick: () => setStore({ activeTab: 'queue' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'related',
|
||||
icon: <HiOutlineQueueList size="1.5rem" />,
|
||||
label: t('page.fullscreenPlayer.related'),
|
||||
onClick: () => setStore({ activeTab: 'related' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'lyrics',
|
||||
icon: <RiFileTextLine size="1.5rem" />,
|
||||
label: t('page.fullscreenPlayer.lyrics'),
|
||||
onClick: () => setStore({ activeTab: 'lyrics' }),
|
||||
},
|
||||
|
|
@ -98,7 +51,6 @@ export const FullScreenPlayerQueue = () => {
|
|||
if (type === PlaybackType.WEB && webAudio) {
|
||||
items.push({
|
||||
active: activeTab === 'visualizer',
|
||||
icon: <RiFileTextLine size="1.5rem" />,
|
||||
label: t('page.fullscreenPlayer.visualizer', { postProcess: 'titleCase' }),
|
||||
onClick: () => setStore({ activeTab: 'visualizer' }),
|
||||
});
|
||||
|
|
@ -108,48 +60,54 @@ export const FullScreenPlayerQueue = () => {
|
|||
}, [activeTab, setStore, t, type, webAudio]);
|
||||
|
||||
return (
|
||||
<GridContainer
|
||||
className="full-screen-player-queue-container"
|
||||
opacity={opacity}
|
||||
<div
|
||||
className={clsx(styles.gridContainer, 'full-screen-player-queue-container')}
|
||||
style={
|
||||
{
|
||||
'--opacity': opacity / 100,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<Group
|
||||
align="center"
|
||||
className="full-screen-player-queue-header"
|
||||
gap={0}
|
||||
grow
|
||||
position="center"
|
||||
justify="center"
|
||||
>
|
||||
{headerItems.map((item) => (
|
||||
<HeaderItemWrapper key={`tab-${item.label}`}>
|
||||
<div
|
||||
className={styles.headerItemWrapper}
|
||||
key={`tab-${item.label}`}
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
flex={1}
|
||||
fw="600"
|
||||
onClick={item.onClick}
|
||||
pos="relative"
|
||||
size="lg"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
color: item.active
|
||||
? 'var(--main-fg) !important'
|
||||
: 'var(--main-fg-secondary) !important',
|
||||
letterSpacing: '1px',
|
||||
}}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
{item.active ? <ActiveTabIndicator layoutId="underline" /> : null}
|
||||
</HeaderItemWrapper>
|
||||
{item.active ? (
|
||||
<motion.div
|
||||
className={styles.activeTabIndicator}
|
||||
layoutId="underline"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</Group>
|
||||
{activeTab === 'queue' ? (
|
||||
<QueueContainer>
|
||||
<div className={styles.queueContainer}>
|
||||
<PlayQueue type="fullScreen" />
|
||||
</QueueContainer>
|
||||
</div>
|
||||
) : activeTab === 'related' ? (
|
||||
<QueueContainer>
|
||||
<div className={styles.queueContainer}>
|
||||
<FullScreenSimilarSongs />
|
||||
</QueueContainer>
|
||||
</div>
|
||||
) : activeTab === 'lyrics' ? (
|
||||
<Lyrics />
|
||||
) : activeTab === 'visualizer' && type === PlaybackType.WEB && webAudio ? (
|
||||
|
|
@ -157,6 +115,6 @@ export const FullScreenPlayerQueue = () => {
|
|||
<Visualizer />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</GridContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
.container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
|
||||
@media screen and (orientation: portrait) {
|
||||
padding: 2rem 2rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.responsive-container {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
max-width: 2560px;
|
||||
margin-top: 5rem;
|
||||
|
||||
@media screen and (orientation: portrait) {
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.background-image-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--theme-overlay-header);
|
||||
backdrop-filter: blur(var(--image-blur));
|
||||
}
|
||||
|
|
@ -1,21 +1,11 @@
|
|||
import { Divider, Group } from '@mantine/core';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { motion, Variants } from 'framer-motion';
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { motion, Variants } from 'motion/react';
|
||||
import { CSSProperties, useLayoutEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri';
|
||||
import { useLocation } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import {
|
||||
Button,
|
||||
NumberInput,
|
||||
Option,
|
||||
Popover,
|
||||
Select,
|
||||
Slider,
|
||||
Switch,
|
||||
} from '/@/renderer/components';
|
||||
import styles from './full-screen-player.module.css';
|
||||
|
||||
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
|
||||
import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
|
||||
import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue';
|
||||
|
|
@ -29,54 +19,18 @@ import {
|
|||
useSettingsStoreActions,
|
||||
useWindowSettings,
|
||||
} from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Option } from '/@/shared/components/option/option';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Slider } from '/@/shared/components/slider/slider';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
const Container = styled(motion.div)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
|
||||
@media screen and (orientation: portrait) {
|
||||
padding: 2rem 2rem 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResponsiveContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 2rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 2560px;
|
||||
margin-top: 5rem;
|
||||
|
||||
@media screen and (orientation: portrait) {
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
interface BackgroundImageOverlayProps {
|
||||
$blur: number;
|
||||
}
|
||||
|
||||
const BackgroundImageOverlay = styled.div<BackgroundImageOverlayProps>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-header-overlay);
|
||||
backdrop-filter: blur(${({ $blur }) => $blur}rem);
|
||||
`;
|
||||
|
||||
const mainBackground = 'var(--main-bg)';
|
||||
const mainBackground = 'var(--theme-colors-background)';
|
||||
|
||||
const Controls = () => {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -109,34 +63,30 @@ const Controls = () => {
|
|||
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
p="1rem"
|
||||
pos="absolute"
|
||||
spacing="sm"
|
||||
sx={{
|
||||
background: `rgb(var(--main-bg-transparent), ${opacity}%)`,
|
||||
style={{
|
||||
background: `rgb(var(--theme-colors-background-transparent), ${opacity}%)`,
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
<ActionIcon
|
||||
icon="arrowDownS"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
size="sm"
|
||||
tooltip={{ label: t('common.minimize', { postProcess: 'titleCase' }) }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiArrowDownSLine size="2rem" />
|
||||
</Button>
|
||||
/>
|
||||
<Popover position="bottom-start">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
<ActionIcon
|
||||
icon="settings"
|
||||
iconProps={{ size: 'lg' }}
|
||||
tooltip={{ label: t('common.configure', { postProcess: 'titleCase' }) }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Line size="1.5rem" />
|
||||
</Button>
|
||||
/>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Option>
|
||||
|
|
@ -285,8 +235,8 @@ const Controls = () => {
|
|||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Group
|
||||
noWrap
|
||||
w="100%"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Slider
|
||||
defaultValue={lyricConfig.fontSize}
|
||||
|
|
@ -325,8 +275,8 @@ const Controls = () => {
|
|||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Group
|
||||
noWrap
|
||||
w="100%"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Slider
|
||||
defaultValue={lyricConfig.gap}
|
||||
|
|
@ -485,8 +435,9 @@ export const FullScreenPlayer = () => {
|
|||
: mainBackground;
|
||||
|
||||
return (
|
||||
<Container
|
||||
<motion.div
|
||||
animate="open"
|
||||
className={styles.container}
|
||||
custom={{ background, backgroundImage, dynamicBackground, windowBarStyle }}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
|
|
@ -494,11 +445,20 @@ export const FullScreenPlayer = () => {
|
|||
variants={containerVariants}
|
||||
>
|
||||
<Controls />
|
||||
{dynamicBackground && <BackgroundImageOverlay $blur={dynamicImageBlur} />}
|
||||
<ResponsiveContainer>
|
||||
{dynamicBackground && (
|
||||
<div
|
||||
className={styles.backgroundImageOverlay}
|
||||
style={
|
||||
{
|
||||
'--image-blur': `${dynamicImageBlur}`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.responsiveContainer}>
|
||||
<FullScreenPlayerImage />
|
||||
<FullScreenPlayerQueue />
|
||||
</ResponsiveContainer>
|
||||
</Container>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
.image-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md) 0;
|
||||
}
|
||||
|
||||
.metadata-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-xs);
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
cursor: pointer;
|
||||
animation: fadein 0.2s ease-in-out;
|
||||
|
||||
button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.playerbar-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: var(--theme-image-fit);
|
||||
}
|
||||
|
||||
.line-item {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
max-width: 20vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-item.secondary {
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
|
||||
a {
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.left-controls-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-left: 1rem;
|
||||
|
||||
@media (width < 640px) {
|
||||
.image-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
import { Center, Group } from '@mantine/core';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, LayoutGroup, motion } from 'motion/react';
|
||||
import React, { MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Button, Text, Tooltip } from '/@/renderer/components';
|
||||
import { Separator } from '/@/renderer/components/separator';
|
||||
import styles from './left-controls.module.css';
|
||||
|
||||
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';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
|
|
@ -20,80 +18,14 @@ import {
|
|||
useSetFullScreenPlayerStore,
|
||||
useSidebarStore,
|
||||
} from '/@/renderer/store';
|
||||
import { fadeIn } from '/@/renderer/styles';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { Separator } from '/@/shared/components/separator/separator';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem 1rem 1rem 0;
|
||||
`;
|
||||
|
||||
const MetadataStack = styled(motion.div)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Image = styled(motion.div)`
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
cursor: pointer;
|
||||
background-color: var(--placeholder-bg);
|
||||
filter: drop-shadow(0 5px 6px rgb(0 0 0 / 50%));
|
||||
|
||||
${fadeIn};
|
||||
animation: fadein 0.2s ease-in-out;
|
||||
|
||||
button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover button {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const PlayerbarImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: var(--image-fit);
|
||||
`;
|
||||
|
||||
const LineItem = styled.div<{ $secondary?: boolean }>`
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
max-width: 20vw;
|
||||
overflow: hidden;
|
||||
line-height: 1.3;
|
||||
color: ${(props) => props.$secondary && 'var(--main-fg-secondary)'};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
a {
|
||||
color: ${(props) => props.$secondary && 'var(--text-secondary)'};
|
||||
}
|
||||
`;
|
||||
|
||||
const LeftControlsContainer = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-left: 1rem;
|
||||
|
||||
@media (width <= 640px) {
|
||||
${ImageWrapper} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LeftControls = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
|
|
@ -135,16 +67,17 @@ export const LeftControls = () => {
|
|||
]);
|
||||
|
||||
return (
|
||||
<LeftControlsContainer>
|
||||
<div className={styles.leftControlsContainer}>
|
||||
<LayoutGroup>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{!hideImage && (
|
||||
<ImageWrapper>
|
||||
<Image
|
||||
<div className={styles.imageWrapper}>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
className={styles.image}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
key="playerbar-image"
|
||||
|
|
@ -158,34 +91,21 @@ export const LeftControls = () => {
|
|||
})}
|
||||
openDelay={500}
|
||||
>
|
||||
{currentSong?.imageUrl ? (
|
||||
<PlayerbarImage
|
||||
loading="eager"
|
||||
src={currentSong?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<RiDiscLine
|
||||
color="var(--placeholder-fg)"
|
||||
size={50}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<Image
|
||||
className={styles.playerbarImage}
|
||||
loading="eager"
|
||||
src={currentSong?.imageUrl ?? ''}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{!collapsed && (
|
||||
<Button
|
||||
compact
|
||||
<ActionIcon
|
||||
icon="arrowUpS"
|
||||
iconProps={{ size: 'xl' }}
|
||||
onClick={handleToggleSidebarImage}
|
||||
opacity={0.8}
|
||||
radius={50}
|
||||
size="md"
|
||||
sx={{
|
||||
radius="md"
|
||||
size="xs"
|
||||
style={{
|
||||
cursor: 'default',
|
||||
position: 'absolute',
|
||||
right: 2,
|
||||
|
|
@ -197,56 +117,60 @@ export const LeftControls = () => {
|
|||
}),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
<RiArrowUpSLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
/>
|
||||
)}
|
||||
</Image>
|
||||
</ImageWrapper>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<MetadataStack layout="position">
|
||||
<LineItem onClick={stopPropagation}>
|
||||
<motion.div
|
||||
className={styles.metadataStack}
|
||||
layout="position"
|
||||
>
|
||||
<div
|
||||
className={styles.lineItem}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<Group
|
||||
align="flex-start"
|
||||
noWrap
|
||||
spacing="xs"
|
||||
align="center"
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw={500}
|
||||
isLink
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={AppRoute.NOW_PLAYING}
|
||||
weight={500}
|
||||
>
|
||||
{title || '—'}
|
||||
</Text>
|
||||
{isSongDefined && (
|
||||
<Button
|
||||
compact
|
||||
<ActionIcon
|
||||
icon="ellipsisVertical"
|
||||
onClick={(e) => handleGeneralContextMenu(e, [currentSong!])}
|
||||
size="xs"
|
||||
styles={{
|
||||
root: {
|
||||
'--ai-size-xs': '1.15rem',
|
||||
},
|
||||
}}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMore2Fill size="1.2rem" />
|
||||
</Button>
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</LineItem>
|
||||
<LineItem
|
||||
$secondary
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.lineItem, styles.secondary)}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{artists?.map((artist, index) => (
|
||||
<React.Fragment key={`bar-${artist.id}`}>
|
||||
{index > 0 && <Separator />}
|
||||
<Text
|
||||
$link={artist.id !== ''}
|
||||
component={artist.id ? Link : undefined}
|
||||
fw={500}
|
||||
isLink={artist.id !== ''}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={
|
||||
|
|
@ -256,20 +180,20 @@ export const LeftControls = () => {
|
|||
})
|
||||
: undefined
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{artist.name || '—'}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</LineItem>
|
||||
<LineItem
|
||||
$secondary
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.lineItem, styles.secondary)}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
fw={500}
|
||||
isLink
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={
|
||||
|
|
@ -279,13 +203,12 @@ export const LeftControls = () => {
|
|||
})
|
||||
: ''
|
||||
}
|
||||
weight={500}
|
||||
>
|
||||
{currentSong?.album || '—'}
|
||||
</Text>
|
||||
</LineItem>
|
||||
</MetadataStack>
|
||||
</div>
|
||||
</motion.div>
|
||||
</LayoutGroup>
|
||||
</LeftControlsContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
.motion-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.motion-wrapper.main {
|
||||
display: flex;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.player-button {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
overflow: visible;
|
||||
cursor: default;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px var(--theme-colors-primary-filled) solid;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.player-button.active {
|
||||
svg {
|
||||
fill: var(--theme-colors-primary-filled);
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
background: var(--theme-colors-foreground);
|
||||
border-radius: 50%;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
color: var(--theme-colors-background);
|
||||
fill: var(--theme-colors-background);
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
color: var(--theme-colors-foreground);
|
||||
|
||||
svg {
|
||||
color: var(--theme-colors-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.tertiary {
|
||||
svg {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,167 +1,72 @@
|
|||
import type { TooltipProps, UnstyledButtonProps } from '@mantine/core';
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'motion/react';
|
||||
import { forwardRef, ReactNode } from 'react';
|
||||
|
||||
import { UnstyledButton } from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
import styles from './player-button.module.css';
|
||||
|
||||
import { Tooltip } from '/@/renderer/components';
|
||||
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Tooltip, TooltipProps } from '/@/shared/components/tooltip/tooltip';
|
||||
|
||||
type MantineButtonProps = ComponentPropsWithoutRef<'button'> & UnstyledButtonProps;
|
||||
interface PlayerButtonProps extends MantineButtonProps {
|
||||
$isActive?: boolean;
|
||||
interface PlayerButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {
|
||||
icon: ReactNode;
|
||||
isActive?: boolean;
|
||||
tooltip?: Omit<TooltipProps, 'children'>;
|
||||
variant: 'main' | 'secondary' | 'tertiary';
|
||||
}
|
||||
|
||||
const WrapperMainVariant = css`
|
||||
margin: 0 0.5rem;
|
||||
`;
|
||||
|
||||
type MotionWrapperProps = { variant: PlayerButtonProps['variant'] };
|
||||
|
||||
const MotionWrapper = styled(motion.div)<MotionWrapperProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
${({ variant }) => variant === 'main' && WrapperMainVariant};
|
||||
`;
|
||||
|
||||
const ButtonMainVariant = css`
|
||||
padding: 0.5rem;
|
||||
background: var(--playerbar-btn-main-bg);
|
||||
border-radius: 50%;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
fill: var(--playerbar-btn-main-fg);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: var(--playerbar-btn-main-bg-hover);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--playerbar-btn-main-bg-hover);
|
||||
|
||||
svg {
|
||||
fill: var(--playerbar-btn-main-fg-hover);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonSecondaryVariant = css`
|
||||
padding: 0.5rem;
|
||||
`;
|
||||
|
||||
const ButtonTertiaryVariant = css`
|
||||
padding: 0.5rem;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
svg {
|
||||
fill: var(--playerbar-btn-fg-hover);
|
||||
stroke: var(--playerbar-btn-fg-hover);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type StyledPlayerButtonProps = Omit<PlayerButtonProps, 'icon'>;
|
||||
|
||||
const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
overflow: visible;
|
||||
cursor: default;
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
outline: 1px var(--primary-color) solid;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
fill: ${({ $isActive }) =>
|
||||
$isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'};
|
||||
stroke: var(--playerbar-btn-fg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--playerbar-btn-fg-hover);
|
||||
background: var(--playerbar-btn-bg-hover);
|
||||
|
||||
svg {
|
||||
fill: ${({ $isActive }) =>
|
||||
$isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg-hover)'};
|
||||
}
|
||||
}
|
||||
|
||||
${({ variant }) =>
|
||||
variant === 'main'
|
||||
? ButtonMainVariant
|
||||
: variant === 'secondary'
|
||||
? ButtonSecondaryVariant
|
||||
: ButtonTertiaryVariant};
|
||||
`;
|
||||
|
||||
export const PlayerButton = forwardRef<HTMLDivElement, PlayerButtonProps>(
|
||||
({ icon, tooltip, variant, ...rest }: PlayerButtonProps, ref) => {
|
||||
({ icon, isActive, tooltip, variant, ...rest }: PlayerButtonProps, ref) => {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip {...tooltip}>
|
||||
<MotionWrapper
|
||||
<motion.div
|
||||
className={clsx({
|
||||
[styles.main]: variant === 'main',
|
||||
[styles.motionWrapper]: true,
|
||||
})}
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
>
|
||||
<StyledPlayerButton
|
||||
variant={variant}
|
||||
<ActionIcon
|
||||
className={clsx(styles.playerButton, styles[variant], {
|
||||
[styles.active]: isActive,
|
||||
})}
|
||||
{...rest}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
rest.onClick?.(e);
|
||||
}}
|
||||
variant="transparent"
|
||||
>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
</ActionIcon>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionWrapper
|
||||
<motion.div
|
||||
className={clsx({
|
||||
[styles.main]: variant === 'main',
|
||||
[styles.motionWrapper]: true,
|
||||
})}
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
>
|
||||
<StyledPlayerButton
|
||||
variant={variant}
|
||||
<ActionIcon
|
||||
className={clsx(styles.playerButton, styles[variant], {
|
||||
[styles.active]: isActive,
|
||||
})}
|
||||
{...rest}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
rest.onClick?.(e);
|
||||
}}
|
||||
size="compact-md"
|
||||
variant="transparent"
|
||||
>
|
||||
{icon}
|
||||
</StyledPlayerButton>
|
||||
</MotionWrapper>
|
||||
</ActionIcon>
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
.bar {
|
||||
background-color: var(--theme-colors-foreground);
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.label {
|
||||
max-width: 200px;
|
||||
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
|
||||
font-size: var(--theme-font-size-md);
|
||||
font-weight: 550;
|
||||
color: var(--theme-colors-surface-foreground);
|
||||
background: var(--theme-colors-surface);
|
||||
box-shadow: 4px 4px 10px 0 rgb(0 0 0 / 20%);
|
||||
}
|
||||
|
||||
.root {
|
||||
&:hover {
|
||||
.bar {
|
||||
background-color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
.bar {
|
||||
background-color: var(--theme-colors-primary-filled);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-color: var(--theme-colors-primary-filled);
|
||||
border-width: 1px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.track {
|
||||
&::before {
|
||||
right: calc(0.1rem * -1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +1,16 @@
|
|||
import { rem, Slider, SliderProps } from '@mantine/core';
|
||||
import styles from './playerbar-slider.module.css';
|
||||
|
||||
import { Slider, SliderProps } from '/@/shared/components/slider/slider';
|
||||
|
||||
export const PlayerbarSlider = ({ ...props }: SliderProps) => {
|
||||
return (
|
||||
<Slider
|
||||
styles={{
|
||||
bar: {
|
||||
backgroundColor: 'var(--playerbar-slider-track-progress-bg)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
},
|
||||
label: {
|
||||
backgroundColor: 'var(--tooltip-bg)',
|
||||
color: 'var(--tooltip-fg)',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
padding: '0 1rem',
|
||||
},
|
||||
root: {
|
||||
'&:hover': {
|
||||
'& .mantine-Slider-bar': {
|
||||
backgroundColor: 'var(--primary-color)',
|
||||
},
|
||||
'& .mantine-Slider-thumb': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
thumb: {
|
||||
backgroundColor: 'var(--slider-thumb-bg)',
|
||||
borderColor: 'var(--primary-color)',
|
||||
borderWidth: rem(1),
|
||||
height: '1rem',
|
||||
opacity: 0,
|
||||
width: '1rem',
|
||||
},
|
||||
track: {
|
||||
'&::before': {
|
||||
backgroundColor: 'var(--playerbar-slider-track-bg)',
|
||||
right: 'calc(0.1rem * -1)',
|
||||
},
|
||||
},
|
||||
classNames={{
|
||||
bar: styles.bar,
|
||||
label: styles.label,
|
||||
root: styles.root,
|
||||
thumb: styles.thumb,
|
||||
track: styles.track,
|
||||
}}
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
40
src/renderer/features/player/components/playerbar.module.css
Normal file
40
src/renderer/features/player/components/playerbar.module.css
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
.container {
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
border-top: var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.controls-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
|
||||
@media (width < 768px) {
|
||||
grid-template-columns: minmax(0, 0.5fr) minmax(0, 1fr) minmax(0, 0.5fr);
|
||||
}
|
||||
}
|
||||
|
||||
.right-grid-item {
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-grid-item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.center-grid-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { MouseEvent, useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import styles from './playerbar.module.css';
|
||||
|
||||
import { AudioPlayer } from '/@/renderer/components';
|
||||
import { CenterControls } from '/@/renderer/features/player/components/center-controls';
|
||||
|
|
@ -25,47 +26,6 @@ import {
|
|||
} from '/@/renderer/store/settings.store';
|
||||
import { PlaybackType } from '/@/shared/types/types';
|
||||
|
||||
const PlayerbarContainer = styled.div`
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
border-top: var(--playerbar-border-top);
|
||||
`;
|
||||
|
||||
const PlayerbarControlsGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
|
||||
@media (width <= 768px) {
|
||||
grid-template-columns: minmax(0, 0.5fr) minmax(0, 1fr) minmax(0, 0.5fr);
|
||||
}
|
||||
`;
|
||||
|
||||
const RightGridItem = styled.div`
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const LeftGridItem = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const CenterGridItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const Playerbar = () => {
|
||||
const playersRef = PlayersRef;
|
||||
const settings = useSettingsStore((state) => state.playback);
|
||||
|
|
@ -92,20 +52,21 @@ export const Playerbar = () => {
|
|||
}, [autoNext]);
|
||||
|
||||
return (
|
||||
<PlayerbarContainer
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={playerbarOpenDrawer ? handleToggleFullScreenPlayer : undefined}
|
||||
>
|
||||
<PlayerbarControlsGrid>
|
||||
<LeftGridItem>
|
||||
<div className={styles.controlsGrid}>
|
||||
<div className={styles.leftGridItem}>
|
||||
<LeftControls />
|
||||
</LeftGridItem>
|
||||
<CenterGridItem>
|
||||
</div>
|
||||
<div className={styles.centerGridItem}>
|
||||
<CenterControls playersRef={playersRef} />
|
||||
</CenterGridItem>
|
||||
<RightGridItem>
|
||||
</div>
|
||||
<div className={styles.rightGridItem}>
|
||||
<RightControls />
|
||||
</RightGridItem>
|
||||
</PlayerbarControlsGrid>
|
||||
</div>
|
||||
</div>
|
||||
{playbackType === PlaybackType.WEB && (
|
||||
<AudioPlayer
|
||||
autoNext={autoNextFn}
|
||||
|
|
@ -122,6 +83,6 @@ export const Playerbar = () => {
|
|||
volume={(volume / 100) ** 2}
|
||||
/>
|
||||
)}
|
||||
</PlayerbarContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,20 +1,8 @@
|
|||
import { Flex, Group } from '@mantine/core';
|
||||
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
|
||||
import isElectron from 'is-electron';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HiOutlineQueueList } from 'react-icons/hi2';
|
||||
import {
|
||||
RiHeartFill,
|
||||
RiHeartLine,
|
||||
RiVolumeDownFill,
|
||||
RiVolumeMuteFill,
|
||||
RiVolumeUpFill,
|
||||
} from 'react-icons/ri';
|
||||
|
||||
import { DropdownMenu, Rating } from '/@/renderer/components';
|
||||
import { Slider } from '/@/renderer/components/slider';
|
||||
import { PlayerButton } from '/@/renderer/features/player/components/player-button';
|
||||
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||
import { useRightControls } from '/@/renderer/features/player/hooks/use-right-controls';
|
||||
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
|
||||
|
|
@ -30,6 +18,12 @@ import {
|
|||
useSpeed,
|
||||
useVolume,
|
||||
} from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { Slider } from '/@/shared/components/slider/slider';
|
||||
import { LibraryItem, QueueSong, ServerType, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
|
|
@ -210,15 +204,15 @@ export const RightControls = () => {
|
|||
{showRating && (
|
||||
<Rating
|
||||
onChange={handleUpdateRating}
|
||||
size="sm"
|
||||
size="xs"
|
||||
value={currentSong?.userRating || 0}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
align="center"
|
||||
noWrap
|
||||
spacing="xs"
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<DropdownMenu
|
||||
arrowOffset={12}
|
||||
|
|
@ -228,13 +222,17 @@ export const RightControls = () => {
|
|||
withArrow
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<PlayerButton
|
||||
icon={<>{speed} x</>}
|
||||
<ActionIcon
|
||||
icon="mediaSpeed"
|
||||
iconProps={{
|
||||
size: 'lg',
|
||||
}}
|
||||
size="sm"
|
||||
tooltip={{
|
||||
label: t('player.playbackSpeed', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="secondary"
|
||||
variant="transparent"
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
|
|
@ -264,78 +262,61 @@ export const RightControls = () => {
|
|||
/>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<PlayerButton
|
||||
icon={
|
||||
currentSong?.userFavorite ? (
|
||||
<RiHeartFill
|
||||
color="var(--primary-color)"
|
||||
size="1.1rem"
|
||||
/>
|
||||
) : (
|
||||
<RiHeartLine size="1.1rem" />
|
||||
)
|
||||
}
|
||||
onClick={() => handleToggleFavorite(currentSong)}
|
||||
sx={{
|
||||
svg: {
|
||||
fill: !currentSong?.userFavorite
|
||||
? undefined
|
||||
: 'var(--primary-color) !important',
|
||||
},
|
||||
<ActionIcon
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: currentSong?.userFavorite ? 'primary' : undefined,
|
||||
size: 'lg',
|
||||
}}
|
||||
onClick={() => handleToggleFavorite(currentSong)}
|
||||
size="sm"
|
||||
tooltip={{
|
||||
label: currentSong?.userFavorite
|
||||
? t('player.unfavorite', { postProcess: 'titleCase' })
|
||||
: t('player.favorite', { postProcess: 'titleCase' }),
|
||||
openDelay: 500,
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="secondary"
|
||||
variant="transparent"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon={isQueueExpanded ? 'panelRightClose' : 'panelRightOpen'}
|
||||
iconProps={{
|
||||
size: 'lg',
|
||||
}}
|
||||
onClick={handleToggleQueue}
|
||||
size="sm"
|
||||
tooltip={{
|
||||
label: t('player.viewQueue', { postProcess: 'titleCase' }),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="transparent"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon={muted ? 'volumeMute' : volume > 50 ? 'volumeMax' : 'volumeNormal'}
|
||||
iconProps={{
|
||||
color: muted ? 'muted' : undefined,
|
||||
size: 'xl',
|
||||
}}
|
||||
onClick={handleMute}
|
||||
onWheel={handleVolumeWheel}
|
||||
size="sm"
|
||||
tooltip={{
|
||||
label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume,
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="transparent"
|
||||
/>
|
||||
{!isMinWidth ? (
|
||||
<PlayerButton
|
||||
icon={<HiOutlineQueueList size="1.1rem" />}
|
||||
onClick={handleToggleQueue}
|
||||
tooltip={{
|
||||
label: t('player.viewQueue', { postProcess: 'titleCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
<PlayerbarSlider
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={handleVolumeSlider}
|
||||
onWheel={handleVolumeWheel}
|
||||
size={6}
|
||||
value={volume}
|
||||
w={volumeWidth}
|
||||
/>
|
||||
) : null}
|
||||
<Group
|
||||
noWrap
|
||||
spacing="xs"
|
||||
>
|
||||
<PlayerButton
|
||||
icon={
|
||||
muted ? (
|
||||
<RiVolumeMuteFill size="1.2rem" />
|
||||
) : volume > 50 ? (
|
||||
<RiVolumeUpFill size="1.2rem" />
|
||||
) : (
|
||||
<RiVolumeDownFill size="1.2rem" />
|
||||
)
|
||||
}
|
||||
onClick={handleMute}
|
||||
onWheel={handleVolumeWheel}
|
||||
tooltip={{
|
||||
label: muted ? t('player.muted', { postProcess: 'titleCase' }) : volume,
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="secondary"
|
||||
/>
|
||||
{!isMinWidth ? (
|
||||
<PlayerbarSlider
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={handleVolumeSlider}
|
||||
onWheel={handleVolumeWheel}
|
||||
size={6}
|
||||
value={volume}
|
||||
w={volumeWidth}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
<Group h="calc(100% / 3)" />
|
||||
</Flex>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
import { Divider, Group, SelectItem, Stack } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import merge from 'lodash/merge';
|
||||
import { useMemo } from 'react';
|
||||
import { RiAddBoxFill, RiAddCircleFill, RiPlayFill } from 'react-icons/ri';
|
||||
import { create } from 'zustand';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Button, Checkbox, NumberInput, Select } from '/@/renderer/components';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import {
|
||||
GenreListResponse,
|
||||
GenreListSort,
|
||||
|
|
@ -33,7 +39,7 @@ interface ShuffleAllSlice extends RandomSongListQuery {
|
|||
enableMinYear: boolean;
|
||||
}
|
||||
|
||||
const useShuffleAllStore = create<ShuffleAllSlice>()(
|
||||
const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
|
||||
persist(
|
||||
immer((set, get) => ({
|
||||
actions: {
|
||||
|
|
@ -58,7 +64,7 @@ const useShuffleAllStore = create<ShuffleAllSlice>()(
|
|||
),
|
||||
);
|
||||
|
||||
const PLAYED_DATA: SelectItem[] = [
|
||||
const PLAYED_DATA: { label: string; value: Played }[] = [
|
||||
{ label: 'all tracks', value: Played.All },
|
||||
{ label: 'only unplayed tracks', value: Played.Never },
|
||||
{ label: 'only played tracks', value: Played.Played },
|
||||
|
|
@ -81,6 +87,7 @@ export const ShuffleAllModal = ({
|
|||
queryClient,
|
||||
server,
|
||||
}: ShuffleAllModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { enableMaxYear, enableMinYear, genre, limit, maxYear, minYear, musicFolderId, played } =
|
||||
useShuffleAllStore();
|
||||
const { setStore } = useShuffleAllStoreActions();
|
||||
|
|
@ -139,7 +146,7 @@ export const ShuffleAllModal = ({
|
|||
}, [musicFolders]);
|
||||
|
||||
return (
|
||||
<Stack spacing="md">
|
||||
<Stack gap="md">
|
||||
<NumberInput
|
||||
label="How many tracks?"
|
||||
max={500}
|
||||
|
|
@ -157,8 +164,8 @@ export const ShuffleAllModal = ({
|
|||
rightSection={
|
||||
<Checkbox
|
||||
checked={enableMinYear}
|
||||
mr="0.5rem"
|
||||
onChange={(e) => setStore({ enableMinYear: e.currentTarget.checked })}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
/>
|
||||
}
|
||||
value={minYear}
|
||||
|
|
@ -172,8 +179,8 @@ export const ShuffleAllModal = ({
|
|||
rightSection={
|
||||
<Checkbox
|
||||
checked={enableMaxYear}
|
||||
mr="0.5rem"
|
||||
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
/>
|
||||
}
|
||||
value={maxYear}
|
||||
|
|
@ -210,31 +217,31 @@ export const ShuffleAllModal = ({
|
|||
<Group grow>
|
||||
<Button
|
||||
disabled={!limit}
|
||||
leftIcon={<RiAddBoxFill size="1rem" />}
|
||||
leftSection={<Icon icon="mediaPlayLast" />}
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
type="submit"
|
||||
variant="default"
|
||||
>
|
||||
Add
|
||||
{t('player.addLast', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!limit}
|
||||
leftIcon={<RiAddCircleFill size="1rem" />}
|
||||
leftSection={<Icon icon="mediaPlayNext" />}
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
type="submit"
|
||||
variant="default"
|
||||
>
|
||||
Add next
|
||||
{t('player.addNext', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
<Button
|
||||
disabled={!limit}
|
||||
leftIcon={<RiPlayFill size="1rem" />}
|
||||
leftSection={<Icon icon="mediaPlay" />}
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Play
|
||||
{t('player.play', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
.container {
|
||||
max-width: 100%;
|
||||
margin: auto;
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,11 @@
|
|||
import AudioMotionAnalyzer from 'audiomotion-analyzer';
|
||||
import { createRef, useCallback, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import styles from './visualizer.module.css';
|
||||
|
||||
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
|
||||
import { useSettingsStore } from '/@/renderer/store';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
max-width: 100%;
|
||||
margin: auto;
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Visualizer = () => {
|
||||
const { webAudio } = useWebAudio();
|
||||
const canvasRef = createRef<HTMLDivElement>();
|
||||
|
|
@ -67,7 +58,8 @@ export const Visualizer = () => {
|
|||
}, [resize]);
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
<div
|
||||
className={styles.container}
|
||||
ref={canvasRef}
|
||||
style={{ height: length, width: length }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import debounce from 'lodash/debounce';
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
|
||||
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||
import {
|
||||
|
|
@ -18,6 +17,7 @@ import {
|
|||
} from '/@/renderer/store';
|
||||
import { usePlaybackType } from '/@/renderer/store/settings.store';
|
||||
import { setAutoNext, setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { PlaybackType, PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useCallback, useRef } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
||||
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||
import {
|
||||
|
|
@ -20,6 +19,7 @@ import {
|
|||
import { useCurrentServer, usePlayerControls, usePlayerStore } from '/@/renderer/store';
|
||||
import { useGeneralSettings, usePlaybackType } from '/@/renderer/store/settings.store';
|
||||
import { setQueue, setQueueNext } from '/@/renderer/utils/set-transcoded-queue-data';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import {
|
||||
instanceOfCancellationError,
|
||||
LibraryItem,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { closeModal, ContextModalProps } from '@mantine/modals';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
|
@ -6,12 +5,17 @@ import { useTranslation } from 'react-i18next';
|
|||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Button, MultiSelect, Switch, toast } from '/@/renderer/components';
|
||||
import { getGenreSongsById } from '/@/renderer/features/player';
|
||||
import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';
|
||||
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { MultiSelect } from '/@/shared/components/multi-select/multi-select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import {
|
||||
PlaylistListSort,
|
||||
SongListQuery,
|
||||
|
|
@ -218,7 +222,7 @@ export const AddToPlaylistContextModal = ({
|
|||
});
|
||||
|
||||
return (
|
||||
<Box p="1rem">
|
||||
<div style={{ padding: '1rem' }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
|
|
@ -240,29 +244,27 @@ export const AddToPlaylistContextModal = ({
|
|||
})}
|
||||
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Group>
|
||||
<Button
|
||||
disabled={addToPlaylistMutation.isLoading}
|
||||
onClick={() => closeModal(id)}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isLoading}
|
||||
size="md"
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
{t('common.add', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
disabled={addToPlaylistMutation.isLoading}
|
||||
onClick={() => closeModal(id)}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isLoading}
|
||||
size="md"
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
{t('common.add', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Switch, Text, TextInput, toast } from '/@/renderer/components';
|
||||
import {
|
||||
PlaylistQueryBuilder,
|
||||
PlaylistQueryBuilderRef,
|
||||
|
|
@ -12,6 +10,14 @@ import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/crea
|
|||
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Textarea } from '/@/shared/components/textarea/textarea';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { CreatePlaylistBody, ServerType, SongListSort } from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
|
||||
|
|
@ -104,11 +110,13 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||
{...form.getInputProps('name')}
|
||||
/>
|
||||
{server?.type === ServerType.NAVIDROME && (
|
||||
<TextInput
|
||||
<Textarea
|
||||
autosize
|
||||
label={t('form.createPlaylist.input', {
|
||||
context: 'description',
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
minRows={5}
|
||||
{...form.getInputProps('comment')}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -146,7 +154,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||
</Stack>
|
||||
)}
|
||||
|
||||
<Group position="right">
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="subtle"
|
||||
|
|
|
|||
|
|
@ -10,14 +10,13 @@ import type {
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||
import { getColumnDefs, TablePagination, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
|
||||
|
|
@ -40,6 +39,7 @@ import {
|
|||
useSetPlaylistDetailTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { PersistedTableColumn, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import {
|
||||
LibraryItem,
|
||||
PlaylistSongListQuery,
|
||||
|
|
|
|||
|
|
@ -1,45 +1,28 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiAddBoxFill,
|
||||
RiAddCircleFill,
|
||||
RiDeleteBinFill,
|
||||
RiEditFill,
|
||||
RiMoreFill,
|
||||
RiPlayFill,
|
||||
RiRefreshLine,
|
||||
RiSettings3Fill,
|
||||
} from 'react-icons/ri';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
Button,
|
||||
ConfirmModal,
|
||||
DropdownMenu,
|
||||
MultiSelect,
|
||||
Slider,
|
||||
Switch,
|
||||
Text,
|
||||
toast,
|
||||
} from '/@/renderer/components';
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { OrderToggleButton } from '/@/renderer/features/shared';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
PersistedTableColumn,
|
||||
SongListFilter,
|
||||
useCurrentServer,
|
||||
usePlaylistDetailStore,
|
||||
|
|
@ -48,6 +31,15 @@ import {
|
|||
useSetPlaylistStore,
|
||||
useSetPlaylistTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import {
|
||||
LibraryItem,
|
||||
PlaylistSongListQuery,
|
||||
|
|
@ -55,7 +47,7 @@ import {
|
|||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ListDisplayType, Play, TableColumn } from '/@/shared/types/types';
|
||||
import { ListDisplayType, Play } from '/@/shared/types/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
|
|
@ -303,6 +295,8 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||
setTable({ rowHeight: e });
|
||||
};
|
||||
|
||||
const debouncedHandleItemSize = debounce(handleItemSize, 20);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: SongListFilter) => {
|
||||
if (server?.type !== ServerType.SUBSONIC) {
|
||||
|
|
@ -392,14 +386,13 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||
}, [filters.sortOrder, handleFilterChange, playlistId, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage({ detail: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
(displayType: ListDisplayType) => {
|
||||
setPage({ detail: { ...page, display: displayType } });
|
||||
},
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const handleTableColumns = (values: string[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
|
|
@ -410,7 +403,10 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
const newColumn = {
|
||||
column: values[values.length - 1],
|
||||
width: 100,
|
||||
} as PersistedTableColumn;
|
||||
|
||||
setTable({ columns: [...existingColumns, newColumn] });
|
||||
} else {
|
||||
|
|
@ -424,10 +420,10 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
const handleAutoFitColumns = (autoFitColumns: boolean) => {
|
||||
setTable({ autoFit: autoFitColumns });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
if (autoFitColumns) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
|
@ -474,16 +470,13 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
gap="sm"
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
tooltip={{
|
||||
label: t('page.playlist.reorder', { postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
|
|
@ -495,7 +488,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
$isActive={filter.value === filters.sortBy}
|
||||
isSelected={filter.value === filters.sortBy}
|
||||
key={`filter-${filter.name}`}
|
||||
onClick={handleSetSortBy}
|
||||
value={filter.value}
|
||||
|
|
@ -511,40 +504,32 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||
onToggle={handleToggleSortOrder}
|
||||
sortOrder={filters.sortOrder || SortOrder.ASC}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size="1.3rem" />
|
||||
</Button>
|
||||
<MoreButton />
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiPlayFill />}
|
||||
leftSection={<Icon icon="mediaPlay" />}
|
||||
onClick={() => handlePlay(Play.NOW)}
|
||||
>
|
||||
{t('player.play', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddBoxFill />}
|
||||
leftSection={<Icon icon="mediaPlayLast" />}
|
||||
onClick={() => handlePlay(Play.LAST)}
|
||||
>
|
||||
{t('player.addLast', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiAddCircleFill />}
|
||||
leftSection={<Icon icon="mediaPlayNext" />}
|
||||
onClick={() => handlePlay(Play.NEXT)}
|
||||
>
|
||||
{t('player.addNext', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
icon={<RiEditFill />}
|
||||
leftSection={<Icon icon="edit" />}
|
||||
onClick={() =>
|
||||
openUpdatePlaylistModal({
|
||||
playlist: detailQuery.data!,
|
||||
|
|
@ -555,14 +540,14 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||
{t('action.editPlaylist', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiDeleteBinFill />}
|
||||
leftSection={<Icon icon="delete" />}
|
||||
onClick={openDeletePlaylistModal}
|
||||
>
|
||||
{t('action.deletePlaylist', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
leftSection={<Icon icon="refresh" />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{t('action.refresh', { postProcess: 'sentenceCase' })}
|
||||
|
|
@ -571,7 +556,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||
<>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
isDanger
|
||||
onClick={handleToggleShowQueryBuilder}
|
||||
>
|
||||
{t('action.toggleSmartPlaylistEditor', {
|
||||
|
|
@ -584,82 +569,18 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
|||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group>
|
||||
<DropdownMenu
|
||||
position="bottom-end"
|
||||
width={425}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.TABLE}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
{/* <DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item> */}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={page.table.rowHeight}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{(page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map(
|
||||
(column) => column.column,
|
||||
)}
|
||||
onChange={handleTableColumns}
|
||||
width={300}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<ListConfigMenu
|
||||
autoFitColumns={page.table.autoFit}
|
||||
disabledViewTypes={[ListDisplayType.GRID, ListDisplayType.LIST]}
|
||||
displayType={page.display}
|
||||
itemSize={page.table.rowHeight}
|
||||
onChangeAutoFitColumns={handleAutoFitColumns}
|
||||
onChangeDisplayType={handleSetViewType}
|
||||
onChangeItemSize={debouncedHandleItemSize}
|
||||
onChangeTableColumns={handleTableColumns}
|
||||
tableColumns={page.table.columns.map((column) => column.column)}
|
||||
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { Stack } from '@mantine/core';
|
||||
import { MutableRefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Badge, PageHeader, Paper, SpinnerIcon } from '/@/renderer/components';
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { Badge } from '/@/shared/components/badge/badge';
|
||||
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
|
|
@ -45,32 +47,27 @@ export const PlaylistDetailSongListHeader = ({
|
|||
const isSmartPlaylist = detailQuery?.data?.rules;
|
||||
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<Stack gap={0}>
|
||||
<PageHeader>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
<Badge>
|
||||
{itemCount === null || itemCount === undefined ? (
|
||||
<SpinnerIcon />
|
||||
) : (
|
||||
itemCount
|
||||
)}
|
||||
</Paper>
|
||||
</Badge>
|
||||
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
|
||||
</LibraryHeaderBar>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
<FilterBar>
|
||||
<PlaylistDetailSongListHeaderFilters
|
||||
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</Paper>
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
|||
|
||||
import { lazy, MutableRefObject, Suspense } from 'react';
|
||||
|
||||
import { Spinner } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { useListStoreByKey } from '/@/renderer/store/list.store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const PlaylistListTableView = lazy(() =>
|
||||
|
|
@ -32,7 +32,7 @@ export const PlaylistListContent = ({ gridRef, itemCount, tableRef }: PlaylistLi
|
|||
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.GRID ? (
|
||||
<PlaylistListGridView
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ListOnScrollProps } from 'react-window';
|
|||
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { PLAYLIST_CARD_ROWS } from '/@/renderer/components';
|
||||
import { PLAYLIST_CARD_ROWS } from '/@/renderer/components/card/card-rows';
|
||||
import {
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
|
|
|
|||
|
|
@ -1,30 +1,45 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { IDatasource } from '@ag-grid-community/core';
|
||||
import { Divider, Flex, Group, Stack } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form';
|
||||
import { OrderToggleButton } from '/@/renderer/features/shared';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
|
||||
import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { PlaylistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
|
||||
import {
|
||||
PersistedTableColumn,
|
||||
PlaylistListFilter,
|
||||
useCurrentServer,
|
||||
useListStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { useListStoreByKey } from '/@/renderer/store/list.store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import {
|
||||
LibraryItem,
|
||||
PlaylistListQuery,
|
||||
PlaylistListSort,
|
||||
ServerType,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ListDisplayType, TableColumn } from '/@/shared/types/types';
|
||||
import { ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
|
|
@ -128,7 +143,7 @@ export const PlaylistListHeaderFilters = ({
|
|||
const { display, filter, grid, table } = useListStoreByKey<PlaylistListQuery>({ key: pageKey });
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
|
|
@ -267,14 +282,13 @@ export const PlaylistListHeaderFilters = ({
|
|||
}, [filter.sortOrder, handleFilterChange, pageKey, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey });
|
||||
(displayType: ListDisplayType) => {
|
||||
setDisplayType({ data: displayType, key: pageKey });
|
||||
},
|
||||
[pageKey, setDisplayType],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const handleTableColumns = (values: string[]) => {
|
||||
const existingColumns = table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
|
|
@ -286,7 +300,10 @@ export const PlaylistListHeaderFilters = ({
|
|||
|
||||
// If adding a column
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
const newColumn = {
|
||||
column: values[values.length - 1],
|
||||
width: 100,
|
||||
} as PersistedTableColumn;
|
||||
|
||||
return setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
|
||||
}
|
||||
|
|
@ -298,10 +315,10 @@ export const PlaylistListHeaderFilters = ({
|
|||
return setTable({ data: { columns: newColumns }, key: pageKey });
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
|
||||
const handleAutoFitColumns = (autoFitColumns: boolean) => {
|
||||
setTable({ data: { autoFit: autoFitColumns }, key: pageKey });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
if (autoFitColumns) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
|
@ -314,6 +331,8 @@ export const PlaylistListHeaderFilters = ({
|
|||
}
|
||||
};
|
||||
|
||||
const debouncedHandleItemSize = debounce(handleItemSize, 20);
|
||||
|
||||
const handleItemGap = (e: number) => {
|
||||
setGrid({ data: { itemGap: e }, key: pageKey });
|
||||
};
|
||||
|
|
@ -323,28 +342,32 @@ export const PlaylistListHeaderFilters = ({
|
|||
handleFilterChange(filter);
|
||||
};
|
||||
|
||||
const handleCreatePlaylistModal = () => {
|
||||
openModal({
|
||||
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
|
||||
onClose: () => {
|
||||
tableRef?.current?.api?.purgeInfiniteCache();
|
||||
},
|
||||
size: server?.type === ServerType?.NAVIDROME ? 'lg' : 'sm',
|
||||
title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
gap="sm"
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
<Button variant="subtle">{sortByLabel}</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
|
||||
<DropdownMenu.Item
|
||||
$isActive={f.value === filter.sortBy}
|
||||
isSelected={f.value === filter.sortBy}
|
||||
key={`filter-${f.name}`}
|
||||
onClick={handleSetSortBy}
|
||||
value={f.value}
|
||||
|
|
@ -359,31 +382,14 @@ export const PlaylistListHeaderFilters = ({
|
|||
onToggle={handleToggleSortOrder}
|
||||
sortOrder={filter.sortOrder}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
compact
|
||||
onClick={handleRefresh}
|
||||
size="md"
|
||||
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiRefreshLine size="1.3rem" />
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
<RefreshButton onClick={handleRefresh} />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size="1.3rem" />
|
||||
</Button>
|
||||
<MoreButton />
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
leftSection={<Icon icon="refresh" />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{t('common.refresh', { postProcess: 'titleCase' })}
|
||||
|
|
@ -391,120 +397,29 @@ export const PlaylistListHeaderFilters = ({
|
|||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group>
|
||||
<DropdownMenu
|
||||
position="bottom-end"
|
||||
width={425}
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Button
|
||||
onClick={handleCreatePlaylistModal}
|
||||
variant="subtle"
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.CARD}
|
||||
>
|
||||
{t('table.config.view.card', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.POSTER}
|
||||
>
|
||||
{t('table.config.view.poster', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
value={ListDisplayType.TABLE}
|
||||
>
|
||||
{t('table.config.view.table', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
{/* <DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item> */}
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
max={isGrid ? 300 : 100}
|
||||
min={isGrid ? 100 : 25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.general.itemGap', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={grid?.itemGap || 0}
|
||||
max={30}
|
||||
min={0}
|
||||
onChangeEnd={handleItemGap}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
{!isGrid && (
|
||||
<>
|
||||
<DropdownMenu.Label>
|
||||
{t('table.config.generaltableColumns', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={PLAYLIST_TABLE_COLUMNS}
|
||||
defaultValue={table?.columns.map(
|
||||
(column) => column.column,
|
||||
)}
|
||||
onChange={handleTableColumns}
|
||||
width={300}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>
|
||||
{t('table.config.general.autoFitColumns', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</Text>
|
||||
<Switch
|
||||
defaultChecked={table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
{t('action.createPlaylist', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
<ListConfigMenu
|
||||
autoFitColumns={table.autoFit}
|
||||
displayType={display}
|
||||
itemGap={grid?.itemGap || 0}
|
||||
itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
onChangeAutoFitColumns={handleAutoFitColumns}
|
||||
onChangeDisplayType={handleSetViewType}
|
||||
onChangeItemGap={handleItemGap}
|
||||
onChangeItemSize={debouncedHandleItemSize}
|
||||
onChangeTableColumns={handleTableColumns}
|
||||
tableColumns={table?.columns.map((column) => column.column)}
|
||||
tableColumnsData={PLAYLIST_TABLE_COLUMNS}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { Flex, Group, Stack } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, MutableRefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiFileAddFill } from 'react-icons/ri';
|
||||
|
||||
import { Button, PageHeader, Paper, SearchInput, SpinnerIcon } from '/@/renderer/components';
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form';
|
||||
import { PlaylistListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-list-header-filters';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
import { PlaylistListFilter, useCurrentServer } from '/@/renderer/store';
|
||||
import { LibraryItem, PlaylistListQuery, ServerType } from '/@/shared/types/domain-types';
|
||||
import { Badge } from '/@/shared/components/badge/badge';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { LibraryItem, PlaylistListQuery } from '/@/shared/types/domain-types';
|
||||
|
||||
interface PlaylistListHeaderProps {
|
||||
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>;
|
||||
|
|
@ -28,17 +30,6 @@ export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistLis
|
|||
const cq = useContainerQuery();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const handleCreatePlaylistModal = () => {
|
||||
openModal({
|
||||
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
|
||||
onClose: () => {
|
||||
tableRef?.current?.api?.purgeInfiniteCache();
|
||||
},
|
||||
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
|
||||
title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }),
|
||||
});
|
||||
};
|
||||
|
||||
const { filter, refresh, search } = useDisplayRefresh<PlaylistListQuery>({
|
||||
gridRef,
|
||||
itemCount,
|
||||
|
|
@ -54,10 +45,10 @@ export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistLis
|
|||
|
||||
return (
|
||||
<Stack
|
||||
gap={0}
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<PageHeader>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
|
|
@ -67,44 +58,28 @@ export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistLis
|
|||
<LibraryHeaderBar.Title>
|
||||
{t('page.playlistList.title', { postProcess: 'titleCase' })}
|
||||
</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
<Badge>
|
||||
{itemCount === null || itemCount === undefined ? (
|
||||
<SpinnerIcon />
|
||||
) : (
|
||||
itemCount
|
||||
)}
|
||||
</Paper>
|
||||
<Button
|
||||
onClick={handleCreatePlaylistModal}
|
||||
tooltip={{
|
||||
label: t('action.createPlaylist', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 500,
|
||||
}}
|
||||
variant="filled"
|
||||
>
|
||||
<RiFileAddFill />
|
||||
</Button>
|
||||
</Badge>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<SearchInput
|
||||
defaultValue={filter.searchTerm}
|
||||
onChange={handleSearch}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
<FilterBar>
|
||||
<PlaylistListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</Paper>
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Group } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { openModal } from '@mantine/modals';
|
||||
import clone from 'lodash/clone';
|
||||
|
|
@ -7,17 +6,8 @@ import setWith from 'lodash/setWith';
|
|||
import { nanoid } from 'nanoid';
|
||||
import { forwardRef, Ref, useImperativeHandle, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiMore2Fill, RiSaveLine } from 'react-icons/ri';
|
||||
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
MotionFlex,
|
||||
NumberInput,
|
||||
QueryBuilder,
|
||||
ScrollArea,
|
||||
Select,
|
||||
} from '/@/renderer/components';
|
||||
import { QueryBuilder } from '/@/renderer/components/query-builder';
|
||||
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
|
||||
import {
|
||||
convertNDQueryToQueryGroup,
|
||||
|
|
@ -33,6 +23,15 @@ import {
|
|||
NDSongQueryPlaylistOperators,
|
||||
NDSongQueryStringOperators,
|
||||
} from '/@/shared/api/navidrome.types';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { PlaylistListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types';
|
||||
|
||||
|
|
@ -411,15 +410,12 @@ export const PlaylistQueryBuilder = forwardRef(
|
|||
];
|
||||
|
||||
return (
|
||||
<MotionFlex
|
||||
<Flex
|
||||
direction="column"
|
||||
h="calc(100% - 3.5rem)"
|
||||
h="calc(100% - 2rem)"
|
||||
justify="space-between"
|
||||
>
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
p="1rem"
|
||||
>
|
||||
<ScrollArea>
|
||||
<QueryBuilder
|
||||
data={filters}
|
||||
filters={NDSongQueryFields}
|
||||
|
|
@ -448,14 +444,14 @@ export const PlaylistQueryBuilder = forwardRef(
|
|||
</ScrollArea>
|
||||
<Group
|
||||
align="flex-end"
|
||||
justify="space-between"
|
||||
m="1rem"
|
||||
noWrap
|
||||
position="apart"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Group
|
||||
noWrap
|
||||
spacing="sm"
|
||||
gap="sm"
|
||||
w="100%"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Select
|
||||
data={sortOptions}
|
||||
|
|
@ -490,37 +486,38 @@ export const PlaylistQueryBuilder = forwardRef(
|
|||
</Group>
|
||||
{onSave && onSaveAs && (
|
||||
<Group
|
||||
noWrap
|
||||
spacing="sm"
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Button
|
||||
loading={isSaving}
|
||||
onClick={handleSaveAs}
|
||||
variant="filled"
|
||||
>
|
||||
{t('common.saveAs', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={openPreviewModal}
|
||||
p="0.5em"
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.preview', { postProcess: 'titleCase' })}
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
<ActionIcon
|
||||
disabled={isSaving}
|
||||
p="0.5em"
|
||||
variant="default"
|
||||
>
|
||||
<RiMore2Fill size={15} />
|
||||
</Button>
|
||||
icon="ellipsisHorizontal"
|
||||
variant="subtle"
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
$danger
|
||||
icon={<RiSaveLine color="var(--danger-color)" />}
|
||||
isDanger
|
||||
leftSection={
|
||||
<Icon
|
||||
color="error"
|
||||
icon="save"
|
||||
/>
|
||||
}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('common.saveAndReplace', { postProcess: 'titleCase' })}
|
||||
|
|
@ -530,7 +527,7 @@ export const PlaylistQueryBuilder = forwardRef(
|
|||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</MotionFlex>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Switch, TextInput, toast } from '/@/renderer/components';
|
||||
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import {
|
||||
CreatePlaylistBody,
|
||||
CreatePlaylistResponse,
|
||||
|
|
@ -98,7 +102,7 @@ export const SaveAsPlaylistForm = ({
|
|||
{...form.getInputProps('public', { type: 'checkbox' })}
|
||||
/>
|
||||
)}
|
||||
<Group position="right">
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="subtle"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -6,11 +5,17 @@ import { useTranslation } from 'react-i18next';
|
|||
import i18n from '/@/i18n/i18n';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Button, Select, Switch, TextInput, toast } from '/@/renderer/components';
|
||||
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import {
|
||||
PlaylistDetailResponse,
|
||||
ServerListItem,
|
||||
|
|
@ -134,7 +139,7 @@ export const UpdatePlaylistForm = ({ body, onCancel, query, users }: UpdatePlayl
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
<Group position="right">
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="subtle"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { Box, Group } from '@mantine/core';
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { motion } from 'motion/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { Button, Paper, Text, toast } from '/@/renderer/components';
|
||||
import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
|
||||
import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header';
|
||||
import { PlaylistQueryBuilder } from '/@/renderer/features/playlists/components/playlist-query-builder';
|
||||
|
|
@ -19,6 +17,11 @@ import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/play
|
|||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, usePlaylistDetailStore } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Box } from '/@/shared/components/box/box';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import {
|
||||
PlaylistSongListQuery,
|
||||
ServerType,
|
||||
|
|
@ -171,25 +174,23 @@ const PlaylistDetailSongListRoute = () => {
|
|||
/>
|
||||
|
||||
{(isSmartPlaylist || showQueryBuilder) && (
|
||||
<Box>
|
||||
<Paper
|
||||
<motion.div>
|
||||
<Box
|
||||
h="100%"
|
||||
mah="35vh"
|
||||
p="md"
|
||||
w="100%"
|
||||
>
|
||||
<Group p="1rem">
|
||||
<Button
|
||||
compact
|
||||
<Group pb="md">
|
||||
<ActionIcon
|
||||
icon={isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'}
|
||||
iconProps={{
|
||||
size: 'md',
|
||||
}}
|
||||
onClick={handleToggleExpand}
|
||||
variant="default"
|
||||
>
|
||||
{isQueryBuilderExpanded ? (
|
||||
<RiArrowUpSLine size={20} />
|
||||
) : (
|
||||
<RiArrowDownSLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Text>Query Editor</Text>
|
||||
size="xs"
|
||||
/>
|
||||
<Text>{t('form.queryEditor.title', { postProcess: 'titleCase' })}</Text>
|
||||
</Group>
|
||||
{isQueryBuilderExpanded && (
|
||||
<PlaylistQueryBuilder
|
||||
|
|
@ -204,8 +205,8 @@ const PlaylistDetailSongListRoute = () => {
|
|||
sortOrder={detailQuery?.data?.rules?.order || 'asc'}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
<PlaylistDetailSongListContent
|
||||
songs={
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import { ActionIcon, Group, Kbd, ScrollArea } from '@mantine/core';
|
||||
import { useDebouncedValue, useDisclosure } from '@mantine/hooks';
|
||||
import { Fragment, useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiCloseFill, RiSearchLine } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Button, Modal, Paper, Spinner, TextInput } from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
|
||||
import { GoToCommands } from '/@/renderer/features/search/components/go-to-commands';
|
||||
|
|
@ -16,18 +12,21 @@ import { ServerCommands } from '/@/renderer/features/search/components/server-co
|
|||
import { useSearch } from '/@/renderer/features/search/queries/search-query';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Box } from '/@/shared/components/box/box';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Kbd } from '/@/shared/components/kbd/kbd';
|
||||
import { Modal } from '/@/shared/components/modal/modal';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
interface CommandPaletteProps {
|
||||
modalProps: (typeof useDisclosure)['arguments'];
|
||||
}
|
||||
|
||||
const CustomModal = styled(Modal)`
|
||||
& .mantine-Modal-header {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
|
|
@ -69,7 +68,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
return (
|
||||
<CustomModal
|
||||
<Modal
|
||||
{...modalProps}
|
||||
centered
|
||||
handlers={{
|
||||
|
|
@ -91,19 +90,21 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||
}
|
||||
},
|
||||
}}
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
size="lg"
|
||||
styles={{
|
||||
header: { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
mb="1rem"
|
||||
spacing="sm"
|
||||
>
|
||||
{pages.map((page, index) => (
|
||||
<Fragment key={page}>
|
||||
{index > 0 && ' > '}
|
||||
<Button
|
||||
compact
|
||||
disabled
|
||||
size="compact-md"
|
||||
variant="default"
|
||||
>
|
||||
{page?.toLocaleUpperCase()}
|
||||
|
|
@ -123,20 +124,23 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||
>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
icon={<RiSearchLine />}
|
||||
leftSection={<Icon icon="search" />}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
ref={searchInputRef}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<RiCloseFill />
|
||||
</ActionIcon>
|
||||
query && (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
variant="transparent"
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
size="lg"
|
||||
size="sm"
|
||||
value={query}
|
||||
/>
|
||||
<Command.Separator />
|
||||
|
|
@ -263,22 +267,22 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => {
|
|||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
<Paper
|
||||
<Box
|
||||
mt="0.5rem"
|
||||
p="0.5rem"
|
||||
>
|
||||
<Group position="apart">
|
||||
<Group justify="space-between">
|
||||
<Command.Loading>
|
||||
{isHome && isLoading && query !== '' && <Spinner />}
|
||||
</Command.Loading>
|
||||
<Group spacing="sm">
|
||||
<Group gap="sm">
|
||||
<Kbd size="md">ESC</Kbd>
|
||||
<Kbd size="md">↑</Kbd>
|
||||
<Kbd size="md">↓</Kbd>
|
||||
<Kbd size="md">⏎</Kbd>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</CustomModal>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
44
src/renderer/features/search/components/command.css
Normal file
44
src/renderer/features/search/components/command.css
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
input[cmdk-input] {
|
||||
width: 100%;
|
||||
font-size: var(--theme-font-size-md);
|
||||
border: none;
|
||||
border-radius: var(--theme-radius-sm);
|
||||
}
|
||||
|
||||
[cmdk-group-heading] {
|
||||
margin: var(--theme-spacing-md) 0;
|
||||
font-size: var(--theme-font-size-sm);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
[cmdk-group-items] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-xs);
|
||||
}
|
||||
|
||||
[cmdk-item] {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-sm);
|
||||
align-items: center;
|
||||
padding: var(--theme-spacing-sm);
|
||||
font-size: var(--theme-font-size-md);
|
||||
color: var(--theme-colors-foreground);
|
||||
cursor: pointer;
|
||||
border-radius: var(--theme-radius-sm);
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
&[data-selected] {
|
||||
background: var(--theme-colors-surface);
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-separator] {
|
||||
height: 1px;
|
||||
margin: 0 0 var(--theme-spacing-sm);
|
||||
background: var(--theme-colors-border);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Command as Cmdk } from 'cmdk';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import './command.css';
|
||||
|
||||
export enum CommandPalettePages {
|
||||
GO_TO = 'go',
|
||||
|
|
@ -7,64 +8,4 @@ export enum CommandPalettePages {
|
|||
MANAGE_SERVERS = 'servers',
|
||||
}
|
||||
|
||||
export const Command = styled(Cmdk)`
|
||||
[cmdk-root] {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
input[cmdk-input] {
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
padding: 1.3rem 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-family: var(--content-font-family);
|
||||
color: var(--input-fg);
|
||||
background: var(--input-bg);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--input-placeholder-fg);
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-group-heading] {
|
||||
margin: 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
[cmdk-group-items] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
[cmdk-item] {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
font-family: var(--content-font-family);
|
||||
color: var(--btn-default-fg);
|
||||
cursor: pointer;
|
||||
background: var(--btn-default-bg);
|
||||
border-radius: 5px;
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
&[data-selected] {
|
||||
color: var(--btn-default-fg-hover);
|
||||
background: var(--btn-default-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-separator] {
|
||||
height: 1px;
|
||||
margin: 0 0 0.5rem;
|
||||
background: var(--generic-border-color);
|
||||
}
|
||||
`;
|
||||
export const Command = Cmdk as typeof Cmdk;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue