Migrate to Mantine v8 and Design Changes (#961)

* mantine v8 migration

* various design changes and improvements
This commit is contained in:
Jeff 2025-06-24 00:04:36 -07:00 committed by GitHub
parent bea55d48a8
commit c1330d92b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
473 changed files with 12469 additions and 11607 deletions

View file

@ -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>

View file

@ -0,0 +1,3 @@
.container {
background: var(--theme-colors-background);
}

View file

@ -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>
);
};

View file

@ -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);
};

View file

@ -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>
);
};

View file

@ -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();

View file

@ -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>
);
})}
</>
);
};
}

View file

@ -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"
>

View file

@ -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>

View file

@ -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;
}

View file

@ -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>
);
};

View file

@ -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,

View file

@ -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}

View file

@ -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,

View file

@ -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>
);

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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';

View file

@ -0,0 +1,7 @@
.detail-container {
display: flex;
flex-direction: column;
gap: 2rem;
padding: 1rem 2rem 5rem;
overflow: hidden;
}

View file

@ -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>
);
};

View file

@ -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;
}
}

View file

@ -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>
);
};

View file

@ -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={

View file

@ -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>
);

View file

@ -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 />}>

View file

@ -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,

View file

@ -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>
);

View file

@ -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>

View file

@ -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 />}>

View file

@ -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,

View file

@ -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>
);

View file

@ -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>

View file

@ -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';

View file

@ -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,

View file

@ -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;

View file

@ -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}

View file

@ -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,

View file

@ -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>
);

View file

@ -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>

View file

@ -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>
),

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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);
}
}

View file

@ -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}

View 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;
}
}

View file

@ -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;
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View 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%
);
}

View file

@ -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>
);
};

View file

@ -0,0 +1,3 @@
.active {
opacity: 1;
}

View 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;
}
}

View file

@ -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>
);
};

View file

@ -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;
}
}

View file

@ -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>
);
};

View file

@ -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"
>

View file

@ -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>

View file

@ -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} />

View file

@ -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"

View file

@ -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"

View file

@ -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;
}
}
}

View file

@ -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>
</>
);
};

View file

@ -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;
}
}
}

View file

@ -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>
);
};

View file

@ -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);
}
}

View file

@ -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>
);
};

View file

@ -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));
}

View file

@ -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>
);
};

View file

@ -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;
}
}
}

View file

@ -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>
);
};

View file

@ -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;
}
}

View file

@ -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>
);
},
);

View file

@ -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);
}
}

View file

@ -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) => {

View 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;
}

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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>
);

View file

@ -0,0 +1,9 @@
.container {
max-width: 100%;
margin: auto;
canvas {
width: 100%;
margin: auto;
}
}

View file

@ -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 }}
/>

View file

@ -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;

View file

@ -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,

View file

@ -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>
);
};

View file

@ -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"

View file

@ -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,

View file

@ -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>
);

View file

@ -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>
);
};

View file

@ -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}

View file

@ -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,

View file

@ -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>
);

View file

@ -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>
);
};

View file

@ -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>
);
},
);

View file

@ -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"

View file

@ -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"

View file

@ -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={

View file

@ -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>
);
};

View 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);
}

View file

@ -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