Lint all files

This commit is contained in:
jeffvli 2023-07-01 19:10:05 -07:00
parent 22af76b4d6
commit 30e52ebb54
334 changed files with 76519 additions and 75932 deletions

View file

@ -3,24 +3,24 @@ import { RiAlertFill } from 'react-icons/ri';
import { Text } from '/@/renderer/components';
interface ActionRequiredContainerProps {
children: React.ReactNode;
title: string;
children: React.ReactNode;
title: string;
}
export const ActionRequiredContainer = ({ title, children }: ActionRequiredContainerProps) => (
<Stack sx={{ cursor: 'default', maxWidth: '700px' }}>
<Group>
<RiAlertFill
color="var(--warning-color)"
size={30}
/>
<Text
size="xl"
sx={{ textTransform: 'uppercase' }}
>
{title}
</Text>
</Group>
<Stack>{children}</Stack>
</Stack>
<Stack sx={{ cursor: 'default', maxWidth: '700px' }}>
<Group>
<RiAlertFill
color="var(--warning-color)"
size={30}
/>
<Text
size="xl"
sx={{ textTransform: 'uppercase' }}
>
{title}
</Text>
</Group>
<Stack>{children}</Stack>
</Stack>
);

View file

@ -6,32 +6,32 @@ import styled from 'styled-components';
import { Button, Text } from '/@/renderer/components';
const Container = styled(Box)`
background: var(--main-bg);
background: var(--main-bg);
`;
export const ErrorFallback = ({ resetErrorBoundary }: FallbackProps) => {
const error = useRouteError() as any;
const error = useRouteError() as any;
return (
<Container>
<Center sx={{ height: '100vh' }}>
<Stack sx={{ maxWidth: '50%' }}>
<Group spacing="xs">
<RiErrorWarningLine
color="var(--danger-color)"
size={30}
/>
<Text size="lg">Something went wrong</Text>
</Group>
<Text>{error.message}</Text>
<Button
variant="filled"
onClick={resetErrorBoundary}
>
Reload
</Button>
</Stack>
</Center>
</Container>
);
return (
<Container>
<Center sx={{ height: '100vh' }}>
<Stack sx={{ maxWidth: '50%' }}>
<Group spacing="xs">
<RiErrorWarningLine
color="var(--danger-color)"
size={30}
/>
<Text size="lg">Something went wrong</Text>
</Group>
<Text>{error.message}</Text>
<Button
variant="filled"
onClick={resetErrorBoundary}
>
Reload
</Button>
</Stack>
</Center>
</Container>
);
};

View file

@ -5,39 +5,39 @@ import { FileInput, Text, Button } from '/@/renderer/components';
const localSettings = isElectron() ? window.electron.localSettings : null;
export const MpvRequired = () => {
const [mpvPath, setMpvPath] = useState('');
const handleSetMpvPath = (e: File) => {
localSettings.set('mpv_path', e.path);
};
useEffect(() => {
const getMpvPath = async () => {
if (!isElectron()) return setMpvPath('');
const mpvPath = localSettings.get('mpv_path') as string;
return setMpvPath(mpvPath);
const [mpvPath, setMpvPath] = useState('');
const handleSetMpvPath = (e: File) => {
localSettings.set('mpv_path', e.path);
};
getMpvPath();
}, []);
useEffect(() => {
const getMpvPath = async () => {
if (!isElectron()) return setMpvPath('');
const mpvPath = localSettings.get('mpv_path') as string;
return setMpvPath(mpvPath);
};
return (
<>
<Text>Set your MPV executable location below and restart the application.</Text>
<Text>
MPV is available at the following:{' '}
<a
href="https://mpv.io/installation/"
rel="noreferrer"
target="_blank"
>
https://mpv.io/
</a>
</Text>
<FileInput
placeholder={mpvPath}
onChange={handleSetMpvPath}
/>
<Button onClick={() => localSettings.restart()}>Restart</Button>
</>
);
getMpvPath();
}, []);
return (
<>
<Text>Set your MPV executable location below and restart the application.</Text>
<Text>
MPV is available at the following:{' '}
<a
href="https://mpv.io/installation/"
rel="noreferrer"
target="_blank"
>
https://mpv.io/
</a>
</Text>
<FileInput
placeholder={mpvPath}
onChange={handleSetMpvPath}
/>
<Button onClick={() => localSettings.restart()}>Restart</Button>
</>
);
};

View file

@ -6,84 +6,84 @@ import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
import { AppRoute } from '/@/renderer/router/routes';
const RouteErrorBoundary = () => {
const navigate = useNavigate();
const error = useRouteError() as any;
console.log('error', error);
const navigate = useNavigate();
const error = useRouteError() as any;
console.log('error', error);
const handleReload = () => {
navigate(0);
};
const handleReload = () => {
navigate(0);
};
const handleReturn = () => {
navigate(-1);
};
const handleReturn = () => {
navigate(-1);
};
const handleHome = () => {
navigate(AppRoute.HOME);
};
const handleHome = () => {
navigate(AppRoute.HOME);
};
return (
<Box bg="var(--main-bg)">
<Center sx={{ height: '100vh' }}>
<Stack sx={{ maxWidth: '50%' }}>
<Group>
<Button
px={10}
variant="subtle"
onClick={handleReturn}
>
<RiArrowLeftSLine size={20} />
</Button>
<RiErrorWarningLine
color="var(--danger-color)"
size={30}
/>
<Text size="lg">Something went wrong</Text>
</Group>
<Divider my={5} />
<Text size="sm">{error?.message}</Text>
<Group
grow
spacing="sm"
>
<Button
leftIcon={<RiHome4Line />}
size="md"
sx={{ flex: 0.5 }}
variant="default"
onClick={handleHome}
>
Go home
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
leftIcon={<RiMenuFill />}
size="md"
sx={{ flex: 0.5 }}
variant="default"
>
Menu
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<AppMenu />
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group grow>
<Button
size="md"
variant="filled"
onClick={handleReload}
>
Reload
</Button>
</Group>
</Stack>
</Center>
</Box>
);
return (
<Box bg="var(--main-bg)">
<Center sx={{ height: '100vh' }}>
<Stack sx={{ maxWidth: '50%' }}>
<Group>
<Button
px={10}
variant="subtle"
onClick={handleReturn}
>
<RiArrowLeftSLine size={20} />
</Button>
<RiErrorWarningLine
color="var(--danger-color)"
size={30}
/>
<Text size="lg">Something went wrong</Text>
</Group>
<Divider my={5} />
<Text size="sm">{error?.message}</Text>
<Group
grow
spacing="sm"
>
<Button
leftIcon={<RiHome4Line />}
size="md"
sx={{ flex: 0.5 }}
variant="default"
onClick={handleHome}
>
Go home
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
leftIcon={<RiMenuFill />}
size="md"
sx={{ flex: 0.5 }}
variant="default"
>
Menu
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<AppMenu />
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group grow>
<Button
size="md"
variant="filled"
onClick={handleReload}
>
Reload
</Button>
</Group>
</Stack>
</Center>
</Box>
);
};
export default RouteErrorBoundary;

View file

@ -2,17 +2,18 @@ import { Text } from '/@/renderer/components';
import { useCurrentServer } from '/@/renderer/store';
export const ServerCredentialRequired = () => {
const currentServer = useCurrentServer();
const currentServer = useCurrentServer();
return (
<>
<Text>
The selected server &apos;{currentServer?.name}&apos; requires an additional login to
access.
</Text>
<Text>
Add your credentials in the &apos;manage servers&apos; menu or switch to a different server.
</Text>
</>
);
return (
<>
<Text>
The selected server &apos;{currentServer?.name}&apos; requires an additional login
to access.
</Text>
<Text>
Add your credentials in the &apos;manage servers&apos; menu or switch to a different
server.
</Text>
</>
);
};

View file

@ -3,22 +3,22 @@ import { Button, DropdownMenu, Text } from '/@/renderer/components';
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
export const ServerRequired = () => {
return (
<>
<Text>No server selected.</Text>
<DropdownMenu>
<DropdownMenu.Target>
<Button
leftIcon={<RiMenuFill />}
variant="filled"
>
Open menu
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<AppMenu />
</DropdownMenu.Dropdown>
</DropdownMenu>
</>
);
return (
<>
<Text>No server selected.</Text>
<DropdownMenu>
<DropdownMenu.Target>
<Button
leftIcon={<RiMenuFill />}
variant="filled"
>
Open menu
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<AppMenu />
</DropdownMenu.Dropdown>
</DropdownMenu>
</>
);
};

View file

@ -15,91 +15,91 @@ import { useCurrentServer } from '/@/renderer/store';
const localSettings = isElectron() ? window.electron.localSettings : null;
const ActionRequiredRoute = () => {
const currentServer = useCurrentServer();
const [isMpvRequired, setIsMpvRequired] = useState(false);
const isServerRequired = !currentServer;
const isCredentialRequired = false;
const currentServer = useCurrentServer();
const [isMpvRequired, setIsMpvRequired] = useState(false);
const isServerRequired = !currentServer;
const isCredentialRequired = false;
useEffect(() => {
const getMpvPath = async () => {
if (!isElectron()) return setIsMpvRequired(false);
const mpvPath = await localSettings.get('mpv_path');
useEffect(() => {
const getMpvPath = async () => {
if (!isElectron()) return setIsMpvRequired(false);
const mpvPath = await localSettings.get('mpv_path');
if (mpvPath) {
return setIsMpvRequired(false);
}
if (mpvPath) {
return setIsMpvRequired(false);
}
return setIsMpvRequired(true);
};
return setIsMpvRequired(true);
};
getMpvPath();
}, []);
getMpvPath();
}, []);
const checks = [
{
component: <MpvRequired />,
title: 'MPV required',
valid: !isMpvRequired,
},
{
component: <ServerCredentialRequired />,
title: 'Credentials required',
valid: !isCredentialRequired,
},
{
component: <ServerRequired />,
title: 'Server required',
valid: !isServerRequired,
},
];
const checks = [
{
component: <MpvRequired />,
title: 'MPV required',
valid: !isMpvRequired,
},
{
component: <ServerCredentialRequired />,
title: 'Credentials required',
valid: !isCredentialRequired,
},
{
component: <ServerRequired />,
title: 'Server required',
valid: !isServerRequired,
},
];
const canReturnHome = checks.every((c) => c.valid);
const displayedCheck = checks.find((c) => !c.valid);
const canReturnHome = checks.every((c) => c.valid);
const displayedCheck = checks.find((c) => !c.valid);
return (
<AnimatedPage>
<PageHeader />
<Center sx={{ height: '100%', width: '100vw' }}>
<Stack
spacing="xl"
sx={{ maxWidth: '50%' }}
>
<Group noWrap>
{displayedCheck && (
<ActionRequiredContainer title={displayedCheck.title}>
{displayedCheck?.component}
</ActionRequiredContainer>
)}
</Group>
<Stack mt="2rem">
{canReturnHome && (
<>
<Navigate to={AppRoute.HOME} />
<Group
noWrap
position="center"
return (
<AnimatedPage>
<PageHeader />
<Center sx={{ height: '100%', width: '100vw' }}>
<Stack
spacing="xl"
sx={{ maxWidth: '50%' }}
>
<RiCheckFill
color="var(--success-color)"
size={30}
/>
<Text size="xl">No issues found</Text>
</Group>
<Button
component={Link}
disabled={!canReturnHome}
to={AppRoute.HOME}
variant="filled"
>
Go back
</Button>
</>
)}
</Stack>
</Stack>
</Center>
</AnimatedPage>
);
<Group noWrap>
{displayedCheck && (
<ActionRequiredContainer title={displayedCheck.title}>
{displayedCheck?.component}
</ActionRequiredContainer>
)}
</Group>
<Stack mt="2rem">
{canReturnHome && (
<>
<Navigate to={AppRoute.HOME} />
<Group
noWrap
position="center"
>
<RiCheckFill
color="var(--success-color)"
size={30}
/>
<Text size="xl">No issues found</Text>
</Group>
<Button
component={Link}
disabled={!canReturnHome}
to={AppRoute.HOME}
variant="filled"
>
Go back
</Button>
</>
)}
</Stack>
</Stack>
</Center>
</AnimatedPage>
);
};
export default ActionRequiredRoute;

View file

@ -5,34 +5,34 @@ import { Button, Text } from '/@/renderer/components';
import { AnimatedPage } from '/@/renderer/features/shared';
const InvalidRoute = () => {
const navigate = useNavigate();
const location = useLocation();
const navigate = useNavigate();
const location = useLocation();
return (
<AnimatedPage>
<Center sx={{ height: '100%', width: '100%' }}>
<Stack>
<Group
noWrap
position="center"
>
<RiQuestionLine
color="var(--warning-color)"
size={30}
/>
<Text size="xl">Page not found</Text>
</Group>
<Text>{location.pathname}</Text>
<Button
variant="filled"
onClick={() => navigate(-1)}
>
Go back
</Button>
</Stack>
</Center>
</AnimatedPage>
);
return (
<AnimatedPage>
<Center sx={{ height: '100%', width: '100%' }}>
<Stack>
<Group
noWrap
position="center"
>
<RiQuestionLine
color="var(--warning-color)"
size={30}
/>
<Text size="xl">Page not found</Text>
</Group>
<Text>{location.pathname}</Text>
<Button
variant="filled"
onClick={() => navigate(-1)}
>
Go back
</Button>
</Stack>
</Center>
</AnimatedPage>
);
};
export default InvalidRoute;

View file

@ -13,13 +13,13 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useContainerQuery } from '/@/renderer/hooks';
import { PersistedTableColumn, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import {
useHandleGeneralContextMenu,
useHandleTableContextMenu,
useHandleGeneralContextMenu,
useHandleTableContextMenu,
} from '/@/renderer/features/context-menu';
import { Play, ServerType, TableColumn } from '/@/renderer/types';
import {
ALBUM_CONTEXT_MENU_ITEMS,
SONG_CONTEXT_MENU_ITEMS,
ALBUM_CONTEXT_MENU_ITEMS,
SONG_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
@ -27,377 +27,393 @@ import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/ap
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useCurrentServer } from '/@/renderer/store';
import {
getColumnDefs,
useFixedTableHeader,
VirtualTable,
getColumnDefs,
useFixedTableHeader,
VirtualTable,
} from '/@/renderer/components/virtual-table';
import { SwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import { FullWidthDiscCell } from '/@/renderer/components/virtual-table/cells/full-width-disc-cell';
const isFullWidthRow = (node: RowNode) => {
return node.id?.startsWith('disc-');
return node.id?.startsWith('disc-');
};
const ContentContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
padding: 1rem 2rem 5rem;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
padding: 1rem 2rem 5rem;
overflow: hidden;
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
}
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
}
.ag-header {
margin-bottom: 0.5rem;
}
.ag-header {
margin-bottom: 0.5rem;
}
`;
interface AlbumDetailContentProps {
tableRef: MutableRefObject<AgGridReactType | null>;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
// TODO: Make this customizable
const columnDefs: ColDef[] = useMemo(() => {
const userRatingColumn =
detailQuery?.data?.serverType !== ServerType.JELLYFIN
? [
// TODO: Make this customizable
const columnDefs: ColDef[] = useMemo(() => {
const userRatingColumn =
detailQuery?.data?.serverType !== ServerType.JELLYFIN
? [
{
column: TableColumn.USER_RATING,
width: 0,
},
]
: [];
const cols: PersistedTableColumn[] = [
{
column: TableColumn.USER_RATING,
width: 0,
column: TableColumn.TRACK_NUMBER,
width: 0,
},
{
column: TableColumn.TITLE_COMBINED,
width: 0,
},
]
: [];
const cols: PersistedTableColumn[] = [
{
column: TableColumn.TRACK_NUMBER,
width: 0,
},
{
column: TableColumn.TITLE_COMBINED,
width: 0,
},
{
column: TableColumn.DURATION,
width: 0,
},
{
column: TableColumn.BIT_RATE,
width: 0,
},
{
column: TableColumn.PLAY_COUNT,
width: 0,
},
{
column: TableColumn.LAST_PLAYED,
width: 0,
},
...userRatingColumn,
{
column: TableColumn.USER_FAVORITE,
width: 0,
},
];
return getColumnDefs(cols).filter((c) => c.colId !== 'album' && c.colId !== 'artist');
}, [detailQuery?.data?.serverType]);
{
column: TableColumn.DURATION,
width: 0,
},
{
column: TableColumn.BIT_RATE,
width: 0,
},
{
column: TableColumn.PLAY_COUNT,
width: 0,
},
{
column: TableColumn.LAST_PLAYED,
width: 0,
},
...userRatingColumn,
{
column: TableColumn.USER_FAVORITE,
width: 0,
},
const getRowHeight = useCallback((params: RowHeightParams) => {
if (isFullWidthRow(params.node)) {
return 45;
}
return 60;
}, []);
const songsRowData = useMemo(() => {
if (!detailQuery.data?.songs) {
return [];
}
const uniqueDiscNumbers = new Set(detailQuery.data?.songs.map((s) => s.discNumber));
const rowData: (QueueSong | { id: string; name: string })[] = [];
for (const discNumber of uniqueDiscNumbers.values()) {
const songsByDiscNumber = detailQuery.data?.songs.filter(
(s) => s.discNumber === discNumber,
);
rowData.push({
id: `disc-${discNumber}`,
name: `Disc ${discNumber}`.toLocaleUpperCase(),
});
rowData.push(...songsByDiscNumber);
}
return rowData;
}, [detailQuery.data?.songs]);
const [pagination, setPagination] = useSetState({
artist: 0,
});
const handleNextPage = useCallback(
(key: 'artist') => {
setPagination({
[key]: pagination[key as keyof typeof pagination] + 1,
});
},
[pagination, setPagination],
);
const handlePreviousPage = useCallback(
(key: 'artist') => {
setPagination({
[key]: pagination[key as keyof typeof pagination] - 1,
});
},
[pagination, setPagination],
);
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
const artistQuery = useAlbumList({
options: {
cacheTime: 1000 * 60,
enabled: detailQuery?.data?.albumArtists[0]?.id !== undefined,
keepPreviousData: true,
staleTime: 1000 * 60,
},
query: {
_custom: {
jellyfin: {
AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
ExcludeItemIds: detailQuery?.data?.id,
},
navidrome: {
artist_id: detailQuery?.data?.albumArtists[0]?.id,
},
},
limit: 10,
sortBy: AlbumListSort.YEAR,
sortOrder: SortOrder.DESC,
startIndex: pagination.artist * itemsPerPage,
},
serverId: server?.id,
});
const carousels = [
{
data: artistQuery?.data?.items,
loading: artistQuery?.isLoading || artistQuery.isFetching,
pagination: {
handleNextPage: () => handleNextPage('artist'),
handlePreviousPage: () => handlePreviousPage('artist'),
hasPreviousPage: pagination.artist > 0,
itemsPerPage,
},
title: 'More from this artist',
uniqueId: 'mostPlayed',
},
];
return getColumnDefs(cols).filter((c) => c.colId !== 'album' && c.colId !== 'artist');
}, [detailQuery?.data?.serverType]);
const getRowHeight = useCallback((params: RowHeightParams) => {
if (isFullWidthRow(params.node)) {
return 45;
}
const playButtonBehavior = usePlayButtonBehavior();
return 60;
}, []);
const handlePlay = async (playType?: Play) => {
handlePlayQueueAdd?.({
byData: detailQuery?.data?.songs,
playType: playType || playButtonBehavior,
});
};
const songsRowData = useMemo(() => {
if (!detailQuery.data?.songs) {
return [];
}
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const uniqueDiscNumbers = new Set(detailQuery.data?.songs.map((s) => s.discNumber));
const rowData: (QueueSong | { id: string; name: string })[] = [];
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data || e.node.isFullWidthCell()) return;
for (const discNumber of uniqueDiscNumbers.values()) {
const songsByDiscNumber = detailQuery.data?.songs.filter((s) => s.discNumber === discNumber);
rowData.push({ id: `disc-${discNumber}`, name: `Disc ${discNumber}`.toLocaleUpperCase() });
rowData.push(...songsByDiscNumber);
}
const rowData: QueueSong[] = [];
e.api.forEachNode((node) => {
if (!node.data || node.isFullWidthCell()) return;
rowData.push(node.data);
});
return rowData;
}, [detailQuery.data?.songs]);
handlePlayQueueAdd?.({
byData: rowData,
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
const [pagination, setPagination] = useSetState({
artist: 0,
});
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleNextPage = useCallback(
(key: 'artist') => {
setPagination({
[key]: pagination[key as keyof typeof pagination] + 1,
});
},
[pagination, setPagination],
);
const handleFavorite = () => {
if (!detailQuery?.data) return;
const handlePreviousPage = useCallback(
(key: 'artist') => {
setPagination({
[key]: pagination[key as keyof typeof pagination] - 1,
});
},
[pagination, setPagination],
);
if (detailQuery.data.userFavorite) {
deleteFavoriteMutation.mutate({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM,
},
serverId: detailQuery.data.serverId,
});
} else {
createFavoriteMutation.mutate({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM,
},
serverId: detailQuery.data.serverId,
});
}
};
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const artistQuery = useAlbumList({
options: {
cacheTime: 1000 * 60,
enabled: detailQuery?.data?.albumArtists[0]?.id !== undefined,
keepPreviousData: true,
staleTime: 1000 * 60,
},
query: {
_custom: {
jellyfin: {
AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
ExcludeItemIds: detailQuery?.data?.id,
},
navidrome: {
artist_id: detailQuery?.data?.albumArtists[0]?.id,
},
},
limit: 10,
sortBy: AlbumListSort.YEAR,
sortOrder: SortOrder.DESC,
startIndex: pagination.artist * itemsPerPage,
},
serverId: server?.id,
});
const { intersectRef, tableContainerRef } = useFixedTableHeader();
const carousels = [
{
data: artistQuery?.data?.items,
loading: artistQuery?.isLoading || artistQuery.isFetching,
pagination: {
handleNextPage: () => handleNextPage('artist'),
handlePreviousPage: () => handlePreviousPage('artist'),
hasPreviousPage: pagination.artist > 0,
itemsPerPage,
},
title: 'More from this artist',
uniqueId: 'mostPlayed',
},
];
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.ALBUM,
ALBUM_CONTEXT_MENU_ITEMS,
);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (playType?: Play) => {
handlePlayQueueAdd?.({
byData: detailQuery?.data?.songs,
playType: playType || playButtonBehavior,
});
};
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data || e.node.isFullWidthCell()) return;
const rowData: QueueSong[] = [];
e.api.forEachNode((node) => {
if (!node.data || node.isFullWidthCell()) return;
rowData.push(node.data);
});
handlePlayQueueAdd?.({
byData: rowData,
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = () => {
if (!detailQuery?.data) return;
if (detailQuery.data.userFavorite) {
deleteFavoriteMutation.mutate({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM,
},
serverId: detailQuery.data.serverId,
});
} else {
createFavoriteMutation.mutate({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM,
},
serverId: detailQuery.data.serverId,
});
}
};
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const { intersectRef, tableContainerRef } = useFixedTableHeader();
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.ALBUM,
ALBUM_CONTEXT_MENU_ITEMS,
);
return (
<ContentContainer>
<Box component="section">
<Group
ref={showGenres ? null : intersectRef}
className="test"
py="1rem"
spacing="md"
>
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
compact
loading={createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading}
variant="subtle"
onClick={handleFavorite}
return (
<ContentContainer>
<Box component="section">
<Group
ref={showGenres ? null : intersectRef}
className="test"
py="1rem"
spacing="md"
>
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
compact
loading={
createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading
}
variant="subtle"
onClick={handleFavorite}
>
{detailQuery?.data?.userFavorite ? (
<RiHeartFill
color="red"
size={20}
/>
) : (
<RiHeartLine size={20} />
)}
</Button>
<Button
compact
variant="subtle"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
>
<RiMoreFill size={20} />
</Button>
</Group>
</Group>
</Box>
{showGenres && (
<Box
ref={showGenres ? intersectRef : null}
component="section"
py="1rem"
>
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
key={`genre-${genre.id}`}
compact
component={Link}
radius={0}
size="md"
to={generatePath(`${AppRoute.LIBRARY_ALBUMS}?genre=${genre.id}`, {
albumId,
})}
variant="outline"
>
{genre.name}
</Button>
))}
</Group>
</Box>
)}
<Box
ref={tableContainerRef}
style={{ minHeight: '300px' }}
>
{detailQuery?.data?.userFavorite ? (
<RiHeartFill
color="red"
size={20}
<VirtualTable
ref={tableRef}
autoFitColumns
autoHeight
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={columnDefs}
enableCellChangeFlash={false}
fullWidthCellRenderer={FullWidthDiscCell}
getRowHeight={getRowHeight}
getRowId={(data) => data.data.id}
isFullWidthRow={(data) => {
return isFullWidthRow(data.rowNode) || false;
}}
isRowSelectable={(data) => {
if (isFullWidthRow(data.data)) return false;
return true;
}}
rowData={songsRowData}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
) : (
<RiHeartLine size={20} />
)}
</Button>
<Button
compact
variant="subtle"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
</Box>
<Stack
ref={cq.ref}
mt="5rem"
>
<RiMoreFill size={20} />
</Button>
</Group>
</Group>
</Box>
{showGenres && (
<Box
ref={showGenres ? intersectRef : null}
component="section"
py="1rem"
>
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
key={`genre-${genre.id}`}
compact
component={Link}
radius={0}
size="md"
to={generatePath(`${AppRoute.LIBRARY_ALBUMS}?genre=${genre.id}`, { albumId })}
variant="outline"
>
{genre.name}
</Button>
))}
</Group>
</Box>
)}
<Box
ref={tableContainerRef}
style={{ minHeight: '300px' }}
>
<VirtualTable
ref={tableRef}
autoFitColumns
autoHeight
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={columnDefs}
enableCellChangeFlash={false}
fullWidthCellRenderer={FullWidthDiscCell}
getRowHeight={getRowHeight}
getRowId={(data) => data.data.id}
isFullWidthRow={(data) => {
return isFullWidthRow(data.rowNode) || false;
}}
isRowSelectable={(data) => {
if (isFullWidthRow(data.data)) return false;
return true;
}}
rowData={songsRowData}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
<Stack
ref={cq.ref}
mt="5rem"
>
<>
{cq.height || cq.width ? (
<>
{carousels.map((carousel, index) => (
<SwiperGridCarousel
key={`carousel-${carousel.uniqueId}-${index}`}
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
]}
data={carousel.data}
isLoading={carousel.loading}
itemType={LibraryItem.ALBUM}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
title={{
label: carousel.title,
}}
uniqueId={carousel.uniqueId}
/>
))}
</>
) : null}
</>
</Stack>
</ContentContainer>
);
<>
{cq.height || cq.width ? (
<>
{carousels.map((carousel, index) => (
<SwiperGridCarousel
key={`carousel-${carousel.uniqueId}-${index}`}
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [
{ idProperty: 'id', slugProperty: 'albumId' },
],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [
{
idProperty: 'id',
slugProperty: 'albumArtistId',
},
],
},
},
]}
data={carousel.data}
isLoading={carousel.loading}
itemType={LibraryItem.ALBUM}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
title={{
label: carousel.title,
}}
uniqueId={carousel.uniqueId}
/>
))}
</>
) : null}
</>
</Stack>
</ContentContainer>
);
};

View file

@ -12,118 +12,122 @@ import { useCurrentServer } from '/@/renderer/store';
import { formatDurationString } from '/@/renderer/utils';
interface AlbumDetailHeaderProps {
background: string;
background: string;
}
export const AlbumDetailHeader = forwardRef(
({ background }: AlbumDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const cq = useContainerQuery();
({ background }: AlbumDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const cq = useContainerQuery();
const metadataItems = [
{
id: 'releaseYear',
secondary: false,
value: detailQuery?.data?.releaseYear,
},
{
id: 'songCount',
secondary: false,
value: `${detailQuery?.data?.songCount} songs`,
},
{
id: 'duration',
secondary: true,
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
const metadataItems = [
{
id: 'releaseYear',
secondary: false,
value: detailQuery?.data?.releaseYear,
},
{
id: 'songCount',
secondary: false,
value: `${detailQuery?.data?.songCount} songs`,
},
{
id: 'duration',
secondary: true,
value:
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
const updateRatingMutation = useSetRating({});
const updateRatingMutation = useSetRating({});
const handleUpdateRating = (rating: number) => {
if (!detailQuery?.data) return;
const handleUpdateRating = (rating: number) => {
if (!detailQuery?.data) return;
updateRatingMutation.mutate({
query: {
item: [detailQuery.data],
rating,
},
serverId: detailQuery.data.serverId,
});
};
updateRatingMutation.mutate({
query: {
item: [detailQuery.data],
rating,
},
serverId: detailQuery.data.serverId,
});
};
const handleClearRating = () => {
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
const handleClearRating = () => {
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
updateRatingMutation.mutate({
query: {
item: [detailQuery.data],
rating: 0,
},
serverId: detailQuery.data.serverId,
});
};
updateRatingMutation.mutate({
query: {
item: [detailQuery.data],
rating: 0,
},
serverId: detailQuery.data.serverId,
});
};
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
return (
<Stack ref={cq.ref}>
<LibraryHeader
ref={ref}
background={background}
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
title={detailQuery?.data?.name || ''}
>
<Stack spacing="sm">
<Group spacing="sm">
{metadataItems.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
</Fragment>
))}
{showRating && (
<>
<Text $noSelect></Text>
<Rating
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
value={detailQuery?.data?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</>
)}
</Group>
<Group
spacing="md"
sx={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
}}
>
{detailQuery?.data?.albumArtists.map((artist) => (
<Text
key={`artist-${artist.id}`}
$link
component={Link}
fw={600}
size="md"
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
variant="subtle"
return (
<Stack ref={cq.ref}>
<LibraryHeader
ref={ref}
background={background}
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
title={detailQuery?.data?.name || ''}
>
{artist.name}
</Text>
))}
</Group>
</Stack>
</LibraryHeader>
</Stack>
);
},
<Stack spacing="sm">
<Group spacing="sm">
{metadataItems.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
</Fragment>
))}
{showRating && (
<>
<Text $noSelect></Text>
<Rating
readOnly={
detailQuery?.isFetching ||
updateRatingMutation.isLoading
}
value={detailQuery?.data?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</>
)}
</Group>
<Group
spacing="md"
sx={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
}}
>
{detailQuery?.data?.albumArtists.map((artist) => (
<Text
key={`artist-${artist.id}`}
$link
component={Link}
fw={600}
size="md"
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
variant="subtle"
>
{artist.name}
</Text>
))}
</Group>
</Stack>
</LibraryHeader>
</Stack>
);
},
);

View file

@ -7,40 +7,40 @@ import { useAlbumListStore } from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
const AlbumListGridView = lazy(() =>
import('/@/renderer/features/albums/components/album-list-grid-view').then((module) => ({
default: module.AlbumListGridView,
})),
import('/@/renderer/features/albums/components/album-list-grid-view').then((module) => ({
default: module.AlbumListGridView,
})),
);
const AlbumListTableView = lazy(() =>
import('/@/renderer/features/albums/components/album-list-table-view').then((module) => ({
default: module.AlbumListTableView,
})),
import('/@/renderer/features/albums/components/album-list-table-view').then((module) => ({
default: module.AlbumListTableView,
})),
);
interface AlbumListContentProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListContentProps) => {
const { id, pageKey } = useAlbumListContext();
const { display } = useAlbumListStore({ id, key: pageKey });
const { id, pageKey } = useAlbumListContext();
const { display } = useAlbumListStore({ id, key: pageKey });
return (
<Suspense fallback={<Spinner container />}>
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
<AlbumListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
) : (
<AlbumListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
)}
</Suspense>
);
return (
<Suspense fallback={<Spinner container />}>
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
<AlbumListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
) : (
<AlbumListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
)}
</Suspense>
);
};

View file

@ -7,194 +7,194 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { Album, AlbumListQuery, AlbumListSort, LibraryItem } from '/@/renderer/api/types';
import { ALBUM_CARD_ROWS } from '/@/renderer/components';
import {
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,
} from '/@/renderer/components/virtual-grid';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AppRoute } from '/@/renderer/router/routes';
import {
useAlbumListFilter,
useAlbumListStore,
useCurrentServer,
useListStoreActions,
useAlbumListFilter,
useAlbumListStore,
useCurrentServer,
useListStoreActions,
} from '/@/renderer/store';
import { CardRow, ListDisplayType } from '/@/renderer/types';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd();
const { id, pageKey } = useAlbumListContext();
const { grid, display } = useAlbumListStore({ id, key: pageKey });
const { setGrid } = useListStoreActions();
const filter = useAlbumListFilter({ id, key: pageKey });
const queryClient = useQueryClient();
const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd();
const { id, pageKey } = useAlbumListContext();
const { grid, display } = useAlbumListStore({ id, key: pageKey });
const { setGrid } = useListStoreActions();
const filter = useAlbumListFilter({ id, key: pageKey });
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = (options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
}) => {
const { id, itemType, isFavorite } = options;
if (isFavorite) {
deleteFavoriteMutation.mutate({
query: {
id,
type: itemType,
const handleFavorite = (options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
}) => {
const { id, itemType, isFavorite } = options;
if (isFavorite) {
deleteFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId: server?.id,
});
} else {
createFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId: server?.id,
});
}
};
const cardRows = useMemo(() => {
const rows: CardRow<Album>[] = [ALBUM_CARD_ROWS.name];
switch (filter.sortBy) {
case AlbumListSort.ALBUM_ARTIST:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.ARTIST:
rows.push(ALBUM_CARD_ROWS.artists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.COMMUNITY_RATING:
rows.push(ALBUM_CARD_ROWS.albumArtists);
break;
case AlbumListSort.DURATION:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.duration);
break;
case AlbumListSort.FAVORITED:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.NAME:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.PLAY_COUNT:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.playCount);
break;
case AlbumListSort.RANDOM:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.RATING:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.rating);
break;
case AlbumListSort.RECENTLY_ADDED:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.createdAt);
break;
case AlbumListSort.RECENTLY_PLAYED:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.lastPlayedAt);
break;
case AlbumListSort.SONG_COUNT:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.songCount);
break;
case AlbumListSort.YEAR:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.RELEASE_DATE:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseDate);
}
return rows;
}, [filter.sortBy]);
const handleGridScroll = useCallback(
(e: ListOnScrollProps) => {
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
},
serverId: server?.id,
});
} else {
createFavoriteMutation.mutate({
query: {
id,
type: itemType,
[pageKey, setGrid],
);
const fetch = useCallback(
async ({ skip, take }: { skip: number; take: number }) => {
if (!server) {
return [];
}
const query: AlbumListQuery = {
limit: take,
startIndex: skip,
...filter,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
}),
);
return albums;
},
serverId: server?.id,
});
}
};
[filter, queryClient, server],
);
const cardRows = useMemo(() => {
const rows: CardRow<Album>[] = [ALBUM_CARD_ROWS.name];
switch (filter.sortBy) {
case AlbumListSort.ALBUM_ARTIST:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.ARTIST:
rows.push(ALBUM_CARD_ROWS.artists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.COMMUNITY_RATING:
rows.push(ALBUM_CARD_ROWS.albumArtists);
break;
case AlbumListSort.DURATION:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.duration);
break;
case AlbumListSort.FAVORITED:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.NAME:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.PLAY_COUNT:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.playCount);
break;
case AlbumListSort.RANDOM:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.RATING:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.rating);
break;
case AlbumListSort.RECENTLY_ADDED:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.createdAt);
break;
case AlbumListSort.RECENTLY_PLAYED:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.lastPlayedAt);
break;
case AlbumListSort.SONG_COUNT:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.songCount);
break;
case AlbumListSort.YEAR:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
break;
case AlbumListSort.RELEASE_DATE:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseDate);
}
return rows;
}, [filter.sortBy]);
const handleGridScroll = useCallback(
(e: ListOnScrollProps) => {
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
},
[pageKey, setGrid],
);
const fetch = useCallback(
async ({ skip, take }: { skip: number; take: number }) => {
if (!server) {
return [];
}
const query: AlbumListQuery = {
limit: take,
startIndex: skip,
...filter,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
}),
);
return albums;
},
[filter, queryClient, server],
);
return (
<VirtualGridAutoSizerContainer>
<AutoSizer>
{({ height, width }) => (
<VirtualInfiniteGrid
key={`album-list-${server?.id}-${display}`}
ref={gridRef}
cardRows={cardRows}
display={display || ListDisplayType.CARD}
fetchFn={fetch}
handleFavorite={handleFavorite}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={grid?.scrollOffset || 0}
itemCount={itemCount || 0}
itemGap={20}
itemSize={grid?.itemsPerRow || 5}
itemType={LibraryItem.ALBUM}
loading={itemCount === undefined || itemCount === null}
minimumBatchSize={40}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
width={width}
onScroll={handleGridScroll}
/>
)}
</AutoSizer>
</VirtualGridAutoSizerContainer>
);
return (
<VirtualGridAutoSizerContainer>
<AutoSizer>
{({ height, width }) => (
<VirtualInfiniteGrid
key={`album-list-${server?.id}-${display}`}
ref={gridRef}
cardRows={cardRows}
display={display || ListDisplayType.CARD}
fetchFn={fetch}
handleFavorite={handleFavorite}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={grid?.scrollOffset || 0}
itemCount={itemCount || 0}
itemGap={20}
itemSize={grid?.itemsPerRow || 5}
itemType={LibraryItem.ALBUM}
loading={itemCount === undefined || itemCount === null}
minimumBatchSize={40}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
width={width}
onScroll={handleGridScroll}
/>
)}
</AutoSizer>
</VirtualGridAutoSizerContainer>
);
};

View file

@ -13,11 +13,11 @@ import { PageHeader, SearchInput } from '/@/renderer/components';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import {
AlbumListFilter,
useAlbumListFilter,
useAlbumListStore,
useCurrentServer,
useListStoreActions,
AlbumListFilter,
useAlbumListFilter,
useAlbumListStore,
useCurrentServer,
useListStoreActions,
} from '/@/renderer/store';
import { ListDisplayType, Play } from '/@/renderer/types';
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
@ -27,219 +27,226 @@ import { useAlbumListContext } from '/@/renderer/features/albums/context/album-l
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
interface AlbumListHeaderProps {
customFilters?: Partial<AlbumListFilter>;
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
title?: string;
customFilters?: Partial<AlbumListFilter>;
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
title?: string;
}
export const AlbumListHeader = ({
itemCount,
gridRef,
tableRef,
title,
customFilters,
itemCount,
gridRef,
tableRef,
title,
customFilters,
}: AlbumListHeaderProps) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const { setFilter, setTablePagination } = useListStoreActions();
const cq = useContainerQuery();
const { id, pageKey } = useAlbumListContext();
const { display } = useAlbumListStore({ id, key: pageKey });
const filter = useAlbumListFilter({ id, key: pageKey });
const fetch = useCallback(
async (skip: number, take: number, filters: AlbumListFilter) => {
const query: AlbumListQuery = {
limit: take,
startIndex: skip,
...filters,
...customFilters,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albums = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return albums;
},
[customFilters, queryClient, server],
);
const handleFilterChange = useCallback(
async (filters: AlbumListFilter) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryClient = useQueryClient();
const server = useCurrentServer();
const { setFilter, setTablePagination } = useListStoreActions();
const cq = useContainerQuery();
const { id, pageKey } = useAlbumListContext();
const { display } = useAlbumListStore({ id, key: pageKey });
const filter = useAlbumListFilter({ id, key: pageKey });
const fetch = useCallback(
async (skip: number, take: number, filters: AlbumListFilter) => {
const query: AlbumListQuery = {
limit,
startIndex,
...filters,
...customFilters,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
limit: take,
startIndex: skip,
...filters,
...customFilters,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
const albums = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(albumsRes?.items || [], albumsRes?.totalRecordCount || 0);
},
rowCount: undefined,
return albums;
},
[customFilters, queryClient, server],
);
const handleFilterChange = useCallback(
async (filters: AlbumListFilter) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: AlbumListQuery = {
limit,
startIndex,
...filters,
...customFilters,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
albumsRes?.items || [],
albumsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
if (display === ListDisplayType.TABLE_PAGINATED) {
setTablePagination({ data: { currentPage: 0 }, key: 'album' });
}
} else {
gridRef.current?.scrollTo(0);
gridRef.current?.resetLoadMoreItemsCache();
// Refetching within the virtualized grid may be inconsistent due to it refetching
// using an outdated set of filters. To avoid this, we fetch using the updated filters
// and then set the grid's data here.
const data = await fetch(0, 200, filters);
if (!data?.items) return;
gridRef.current?.setItemData(data.items);
}
},
[display, tableRef, customFilters, server, queryClient, setTablePagination, gridRef, fetch],
);
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const previousSearchTerm = filter.searchTerm;
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.ALBUM,
key: 'album',
}) as AlbumListFilter;
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
}, 500);
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (playType: Play) => {
if (!itemCount || itemCount === 0) return;
const query = {
startIndex: 0,
...filter,
...customFilters,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
const queryKey = queryKeys.albums.list(server?.id || '', query);
if (display === ListDisplayType.TABLE_PAGINATED) {
setTablePagination({ data: { currentPage: 0 }, key: 'album' });
}
} else {
gridRef.current?.scrollTo(0);
gridRef.current?.resetLoadMoreItemsCache();
const albumListRes = await queryClient.fetchQuery({
queryFn: ({ signal }) =>
api.controller.getAlbumList({ apiClientProps: { server, signal }, query }),
queryKey,
});
// Refetching within the virtualized grid may be inconsistent due to it refetching
// using an outdated set of filters. To avoid this, we fetch using the updated filters
// and then set the grid's data here.
const data = await fetch(0, 200, filters);
const albumIds = albumListRes?.items?.map((item) => item.id) || [];
if (!data?.items) return;
gridRef.current?.setItemData(data.items);
}
},
[display, tableRef, customFilters, server, queryClient, setTablePagination, gridRef, fetch],
);
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const previousSearchTerm = filter.searchTerm;
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.ALBUM,
key: 'album',
}) as AlbumListFilter;
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
}, 500);
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (playType: Play) => {
if (!itemCount || itemCount === 0) return;
const query = {
startIndex: 0,
...filter,
...customFilters,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
handlePlayQueueAdd?.({
byItemType: {
id: albumIds,
type: LibraryItem.ALBUM,
},
playType,
});
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumListRes = await queryClient.fetchQuery({
queryFn: ({ signal }) =>
api.controller.getAlbumList({ apiClientProps: { server, signal }, query }),
queryKey,
});
const albumIds = albumListRes?.items?.map((item) => item.id) || [];
handlePlayQueueAdd?.({
byItemType: {
id: albumIds,
type: LibraryItem.ALBUM,
},
playType,
});
};
return (
<Stack
ref={cq.ref}
spacing={0}
>
<PageHeader backgroundColor="var(--titlebar-bg)">
<Flex
justify="space-between"
w="100%"
return (
<Stack
ref={cq.ref}
spacing={0}
>
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<LibraryHeaderBar.Title>{title || 'Albums'}</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge isLoading={itemCount === null || itemCount === undefined}>
{itemCount}
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
onChange={handleSearch}
/>
</Group>
</Flex>
</PageHeader>
<FilterBar>
<AlbumListHeaderFilters
customFilters={customFilters}
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</FilterBar>
</Stack>
);
<PageHeader backgroundColor="var(--titlebar-bg)">
<Flex
justify="space-between"
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton
onClick={() => handlePlay(playButtonBehavior)}
/>
<LibraryHeaderBar.Title>{title || 'Albums'}</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>
{itemCount}
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
onChange={handleSearch}
/>
</Group>
</Flex>
</PageHeader>
<FilterBar>
<AlbumListHeaderFilters
customFilters={customFilters}
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</FilterBar>
</Stack>
);
};

View file

@ -1,11 +1,11 @@
import { useMemo, useCallback } from 'react';
import {
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
BodyScrollEvent,
RowDoubleClickedEvent,
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
BodyScrollEvent,
RowDoubleClickedEvent,
} from '@ag-grid-community/core';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
@ -13,10 +13,10 @@ import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
import { getColumnDefs, TablePagination, VirtualTable } from '/@/renderer/components/virtual-table';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
import {
useCurrentServer,
useAlbumListFilter,
useListStoreActions,
useAlbumListStore,
useCurrentServer,
useAlbumListFilter,
useListStoreActions,
useAlbumListStore,
} from '/@/renderer/store';
import { useQueryClient } from '@tanstack/react-query';
import { AnimatePresence } from 'framer-motion';
@ -29,179 +29,188 @@ import { AppRoute } from '/@/renderer/router/routes';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
export const AlbumListTableView = ({ tableRef, itemCount }: any) => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const server = useCurrentServer();
const { id, pageKey } = useAlbumListContext();
const filter = useAlbumListFilter({ id, key: pageKey });
const { setTable, setTablePagination } = useListStoreActions();
const { table, display } = useAlbumListStore({ id, key: pageKey });
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
const queryClient = useQueryClient();
const navigate = useNavigate();
const server = useCurrentServer();
const { id, pageKey } = useAlbumListContext();
const filter = useAlbumListFilter({ id, key: pageKey });
const { setTable, setTablePagination } = useListStoreActions();
const { table, display } = useAlbumListStore({ id, key: pageKey });
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
const onTableReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const onTableReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: AlbumListQuery = {
limit,
startIndex,
...filter,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
},
},
};
const query: AlbumListQuery = {
limit,
startIndex,
...filter,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const queryKey = queryKeys.albums.list(server?.id || '', query);
if (!server) {
return params.failCallback();
}
if (!server) {
return params.failCallback();
}
const albumsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
apiClientProps: {
server,
signal,
const albumsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return params.successCallback(
albumsRes?.items || [],
albumsRes?.totalRecordCount || 0,
);
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return params.successCallback(albumsRes?.items || [], albumsRes?.totalRecordCount || 0);
rowCount: undefined,
};
params.api.setDatasource(dataSource);
params.api.ensureIndexVisible(table.scrollOffset || 0, 'top');
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
params.api.ensureIndexVisible(table.scrollOffset || 0, 'top');
},
[filter, queryClient, server, table.scrollOffset],
);
[filter, queryClient, server, table.scrollOffset],
);
const onTablePaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
const onTablePaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
try {
// Scroll to top of page on pagination change
const currentPageStartIndex = table.pagination.currentPage * table.pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) {
console.log(err);
}
try {
// Scroll to top of page on pagination change
const currentPageStartIndex =
table.pagination.currentPage * table.pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) {
console.log(err);
}
setTablePagination({
data: {
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
setTablePagination({
data: {
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
},
key: pageKey,
});
},
key: pageKey,
});
},
[
isPaginationEnabled,
setTablePagination,
pageKey,
table.pagination.currentPage,
table.pagination.itemsPerPage,
],
);
[
isPaginationEnabled,
setTablePagination,
pageKey,
table.pagination.currentPage,
table.pagination.itemsPerPage,
],
);
const handleTableColumnChange = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
const handleTableColumnChange = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
if (!columnsOrder) return;
const columnsInSettings = table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
const columnsInSettings = table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find(
(c) => c.column === column.getColDef().colId,
);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!table.autoFit && {
width: column.getColDef().width,
}),
});
}
}
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!table.autoFit && {
width: column.getColDef().width,
}),
});
}
}
setTable({ data: { columns: updatedColumns }, key: pageKey });
}, [tableRef, table.columns, table.autoFit, setTable, pageKey]);
setTable({ data: { columns: updatedColumns }, key: pageKey });
}, [tableRef, table.columns, table.autoFit, setTable, pageKey]);
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
const handleTableScroll = (e: BodyScrollEvent) => {
const scrollOffset = Number((e.top / table.rowHeight).toFixed(0));
setTable({ data: { scrollOffset }, key: pageKey });
};
const handleTableScroll = (e: BodyScrollEvent) => {
const scrollOffset = Number((e.top / table.rowHeight).toFixed(0));
setTable({ data: { scrollOffset }, key: pageKey });
};
const handleContextMenu = useHandleTableContextMenu(LibraryItem.ALBUM, ALBUM_CONTEXT_MENU_ITEMS);
const handleContextMenu = useHandleTableContextMenu(
LibraryItem.ALBUM,
ALBUM_CONTEXT_MENU_ITEMS,
);
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: e.data.id }));
};
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: e.data.id }));
};
return (
<>
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${display}-${table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
suppressRowDrag
autoFitColumns={table.autoFit}
blockLoadDebounceMillis={200}
columnDefs={columnDefs}
getRowId={(data) => data.data.id}
infiniteInitialRowCount={itemCount || 100}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={table.pagination.itemsPerPage || 100}
rowBuffer={20}
rowHeight={table.rowHeight || 40}
rowModelType="infinite"
onBodyScrollEnd={handleTableScroll}
onCellContextMenu={handleContextMenu}
onColumnMoved={handleTableColumnChange}
onColumnResized={debouncedTableColumnChange}
onGridReady={onTableReady}
onPaginationChanged={onTablePaginationChanged}
onRowDoubleClicked={handleRowDoubleClick}
/>
</VirtualGridAutoSizerContainer>
{isPaginationEnabled && (
<AnimatePresence
presenceAffectsLayout
initial={false}
mode="wait"
>
{display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pageKey={pageKey}
pagination={table.pagination}
setPagination={setTablePagination}
tableRef={tableRef}
/>
)}
</AnimatePresence>
)}
</>
);
return (
<>
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${display}-${table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
suppressRowDrag
autoFitColumns={table.autoFit}
blockLoadDebounceMillis={200}
columnDefs={columnDefs}
getRowId={(data) => data.data.id}
infiniteInitialRowCount={itemCount || 100}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={table.pagination.itemsPerPage || 100}
rowBuffer={20}
rowHeight={table.rowHeight || 40}
rowModelType="infinite"
onBodyScrollEnd={handleTableScroll}
onCellContextMenu={handleContextMenu}
onColumnMoved={handleTableColumnChange}
onColumnResized={debouncedTableColumnChange}
onGridReady={onTableReady}
onPaginationChanged={onTablePaginationChanged}
onRowDoubleClicked={handleRowDoubleClick}
/>
</VirtualGridAutoSizerContainer>
{isPaginationEnabled && (
<AnimatePresence
presenceAffectsLayout
initial={false}
mode="wait"
>
{display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pageKey={pageKey}
pagination={table.pagination}
setPagination={setTablePagination}
tableRef={tableRef}
/>
)}
</AnimatePresence>
)}
</>
);
};

View file

@ -8,220 +8,220 @@ import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/typ
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
interface JellyfinAlbumFiltersProps {
disableArtistFilter?: boolean;
handleFilterChange: (filters: AlbumListFilter) => void;
id?: string;
pageKey: string;
serverId?: string;
disableArtistFilter?: boolean;
handleFilterChange: (filters: AlbumListFilter) => void;
id?: string;
pageKey: string;
serverId?: string;
}
export const JellyfinAlbumFilters = ({
disableArtistFilter,
handleFilterChange,
pageKey,
id,
serverId,
disableArtistFilter,
handleFilterChange,
pageKey,
id,
serverId,
}: JellyfinAlbumFiltersProps) => {
const filter = useAlbumListFilter({ id, key: pageKey });
const { setFilter } = useListStoreActions();
const filter = useAlbumListFilter({ id, key: pageKey });
const { setFilter } = useListStoreActions();
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
const genreListQuery = useGenreList({ query: null, serverId });
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
const genreListQuery = useGenreList({ query: null, serverId });
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const selectedGenres = useMemo(() => {
return filter._custom?.jellyfin?.GenreIds?.split(',');
}, [filter._custom?.jellyfin?.GenreIds]);
const selectedGenres = useMemo(() => {
return filter._custom?.jellyfin?.GenreIds?.split(',');
}, [filter._custom?.jellyfin?.GenreIds]);
const toggleFilters = [
{
label: 'Is favorited',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
IsFavorite: e.currentTarget.checked ? true : undefined,
},
const toggleFilters = [
{
label: 'Is favorited',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
IsFavorite: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
value: filter._custom?.jellyfin?.IsFavorite,
},
];
const handleMinYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
minYear: e === '' ? undefined : (e as number),
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter._custom?.jellyfin?.IsFavorite,
},
];
}, 500);
const handleMinYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
minYear: e === '' ? undefined : (e as number),
},
const handleMaxYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
maxYear: e === '' ? undefined : (e as number),
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 500);
const handleGenresFilter = debounce((e: string[] | undefined) => {
const genreFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
GenreIds: genreFilterString,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 250);
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
const albumArtistListQuery = useAlbumArtistList({
options: {
cacheTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 500);
const handleMaxYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
maxYear: e === '' ? undefined : (e as number),
},
query: {
sortBy: AlbumArtistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 500);
serverId,
});
const handleGenresFilter = debounce((e: string[] | undefined) => {
const genreFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
GenreIds: genreFilterString,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 250);
const selectableAlbumArtists = useMemo(() => {
if (!albumArtistListQuery?.data?.items) return [];
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
return albumArtistListQuery?.data?.items?.map((artist) => ({
label: artist.name,
value: artist.id,
}));
}, [albumArtistListQuery?.data?.items]);
const albumArtistListQuery = useAlbumArtistList({
options: {
cacheTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
query: {
sortBy: AlbumArtistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const handleAlbumArtistFilter = (e: string[] | null) => {
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
AlbumArtistIds: albumArtistFilterString,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
};
const selectableAlbumArtists = useMemo(() => {
if (!albumArtistListQuery?.data?.items) return [];
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`nd-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
size="xs"
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter._custom?.jellyfin?.minYear}
hideControls={false}
label="From year"
max={2300}
min={1700}
required={!!filter._custom?.jellyfin?.maxYear}
onChange={(e) => handleMinYearFilter(e)}
/>
<NumberInput
defaultValue={filter._custom?.jellyfin?.maxYear}
hideControls={false}
label="To year"
max={2300}
min={1700}
required={!!filter._custom?.jellyfin?.minYear}
onChange={(e) => handleMaxYearFilter(e)}
/>
</Group>
<Group grow>
<MultiSelect
clearable
searchable
data={genreList}
defaultValue={selectedGenres}
label="Genres"
onChange={handleGenresFilter}
/>
</Group>
return albumArtistListQuery?.data?.items?.map((artist) => ({
label: artist.name,
value: artist.id,
}));
}, [albumArtistListQuery?.data?.items]);
const handleAlbumArtistFilter = (e: string[] | null) => {
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
AlbumArtistIds: albumArtistFilterString,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
};
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`nd-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
size="xs"
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter._custom?.jellyfin?.minYear}
hideControls={false}
label="From year"
max={2300}
min={1700}
required={!!filter._custom?.jellyfin?.maxYear}
onChange={(e) => handleMinYearFilter(e)}
/>
<NumberInput
defaultValue={filter._custom?.jellyfin?.maxYear}
hideControls={false}
label="To year"
max={2300}
min={1700}
required={!!filter._custom?.jellyfin?.minYear}
onChange={(e) => handleMaxYearFilter(e)}
/>
</Group>
<Group grow>
<MultiSelect
clearable
searchable
data={genreList}
defaultValue={selectedGenres}
label="Genres"
onChange={handleGenresFilter}
/>
</Group>
<Group grow>
<MultiSelect
clearable
searchable
data={selectableAlbumArtists}
defaultValue={filter._custom?.jellyfin?.AlbumArtistIds?.split(',')}
disabled={disableArtistFilter}
label="Artist"
limit={300}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchValue={albumArtistSearchTerm}
onChange={handleAlbumArtistFilter}
onSearchChange={setAlbumArtistSearchTerm}
/>
</Group>
</Stack>
);
<Group grow>
<MultiSelect
clearable
searchable
data={selectableAlbumArtists}
defaultValue={filter._custom?.jellyfin?.AlbumArtistIds?.split(',')}
disabled={disableArtistFilter}
label="Artist"
limit={300}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchValue={albumArtistSearchTerm}
onChange={handleAlbumArtistFilter}
onSearchChange={setAlbumArtistSearchTerm}
/>
</Group>
</Stack>
);
};

View file

@ -8,241 +8,241 @@ import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-a
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
interface NavidromeAlbumFiltersProps {
disableArtistFilter?: boolean;
handleFilterChange: (filters: AlbumListFilter) => void;
id?: string;
pageKey: string;
serverId?: string;
disableArtistFilter?: boolean;
handleFilterChange: (filters: AlbumListFilter) => void;
id?: string;
pageKey: string;
serverId?: string;
}
export const NavidromeAlbumFilters = ({
handleFilterChange,
disableArtistFilter,
pageKey,
id,
serverId,
}: NavidromeAlbumFiltersProps) => {
const filter = useAlbumListFilter({ id, key: pageKey });
const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList({ query: null, serverId });
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: string | null) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
genre_id: e || undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: 'album',
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
{
label: 'Is rated',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
has_rating: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.has_rating,
},
{
label: 'Is favorited',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
starred: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.starred,
},
{
label: 'Is compilation',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
compilation: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.compilation,
},
{
label: 'Is recently played',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
recently_played: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.recently_played,
},
];
const handleYearFilter = debounce((e: number | string) => {
const updatedFilters = setFilter({
data: {
_custom: {
navidrome: {
...filter._custom?.navidrome,
year: e === '' ? undefined : (e as number),
},
...filter._custom,
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 500);
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
const albumArtistListQuery = useAlbumArtistList({
options: {
cacheTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
query: {
// searchTerm: debouncedSearchTerm,
sortBy: AlbumArtistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
handleFilterChange,
disableArtistFilter,
pageKey,
id,
serverId,
});
}: NavidromeAlbumFiltersProps) => {
const filter = useAlbumListFilter({ id, key: pageKey });
const { setFilter } = useListStoreActions();
const selectableAlbumArtists = useMemo(() => {
if (!albumArtistListQuery?.data?.items) return [];
const genreListQuery = useGenreList({ query: null, serverId });
return albumArtistListQuery?.data?.items?.map((artist) => ({
label: artist.name,
value: artist.id,
}));
}, [albumArtistListQuery?.data?.items]);
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const handleAlbumArtistFilter = (e: string | null) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
artist_id: e || undefined,
},
const handleGenresFilter = debounce((e: string | null) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
genre_id: e || undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: 'album',
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
{
label: 'Is rated',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
has_rating: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.has_rating,
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
};
{
label: 'Is favorited',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
starred: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.starred,
},
{
label: 'Is compilation',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
compilation: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.compilation,
},
{
label: 'Is recently played',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
recently_played: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.recently_played,
},
];
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`nd-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter._custom?.navidrome?.year}
hideControls={false}
label="Year"
max={5000}
min={0}
onChange={(e) => handleYearFilter(e)}
/>
<Select
clearable
searchable
data={genreList}
defaultValue={filter._custom?.navidrome?.genre_id}
label="Genre"
onChange={handleGenresFilter}
/>
</Group>
<Group grow>
<Select
clearable
searchable
data={selectableAlbumArtists}
defaultValue={filter._custom?.navidrome?.artist_id}
disabled={disableArtistFilter}
label="Artist"
limit={300}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchValue={albumArtistSearchTerm}
onChange={handleAlbumArtistFilter}
onSearchChange={setAlbumArtistSearchTerm}
/>
</Group>
</Stack>
);
const handleYearFilter = debounce((e: number | string) => {
const updatedFilters = setFilter({
data: {
_custom: {
navidrome: {
...filter._custom?.navidrome,
year: e === '' ? undefined : (e as number),
},
...filter._custom,
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 500);
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
const albumArtistListQuery = useAlbumArtistList({
options: {
cacheTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
query: {
// searchTerm: debouncedSearchTerm,
sortBy: AlbumArtistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const selectableAlbumArtists = useMemo(() => {
if (!albumArtistListQuery?.data?.items) return [];
return albumArtistListQuery?.data?.items?.map((artist) => ({
label: artist.name,
value: artist.id,
}));
}, [albumArtistListQuery?.data?.items]);
const handleAlbumArtistFilter = (e: string | null) => {
const updatedFilters = setFilter({
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
artist_id: e || undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
};
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`nd-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter._custom?.navidrome?.year}
hideControls={false}
label="Year"
max={5000}
min={0}
onChange={(e) => handleYearFilter(e)}
/>
<Select
clearable
searchable
data={genreList}
defaultValue={filter._custom?.navidrome?.genre_id}
label="Genre"
onChange={handleGenresFilter}
/>
</Group>
<Group grow>
<Select
clearable
searchable
data={selectableAlbumArtists}
defaultValue={filter._custom?.navidrome?.artist_id}
disabled={disableArtistFilter}
label="Artist"
limit={300}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchValue={albumArtistSearchTerm}
onChange={handleAlbumArtistFilter}
onSearchChange={setAlbumArtistSearchTerm}
/>
</Group>
</Stack>
);
};

View file

@ -2,10 +2,10 @@ import { createContext, useContext } from 'react';
import { ListKey } from '/@/renderer/store';
export const AlbumListContext = createContext<{ id?: string; pageKey: ListKey }>({
pageKey: 'album',
pageKey: 'album',
});
export const useAlbumListContext = () => {
const ctxValue = useContext(AlbumListContext);
return ctxValue;
const ctxValue = useContext(AlbumListContext);
return ctxValue;
};

View file

@ -6,15 +6,15 @@ import type { AlbumDetailQuery } from '/@/renderer/api/types';
import { controller } from '/@/renderer/api/controller';
export const useAlbumDetail = (args: QueryHookArgs<AlbumDetailQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return controller.getAlbumDetail({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albums.detail(server?.id || '', query),
...options,
});
return useQuery({
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return controller.getAlbumDetail({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albums.detail(server?.id || '', query),
...options,
});
};

View file

@ -7,55 +7,55 @@ import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useAlbumList = (args: QueryHookArgs<AlbumListQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
enabled: !!serverId,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return controller.getAlbumList({
apiClientProps: {
server,
signal,
return useQuery({
enabled: !!serverId,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
});
},
query,
});
},
queryKey: queryKeys.albums.list(serverId || '', query),
...options,
});
queryKey: queryKeys.albums.list(serverId || '', query),
...options,
});
};
export const useAlbumListInfinite = (args: QueryHookArgs<AlbumListQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useInfiniteQuery({
enabled: !!serverId,
getNextPageParam: (lastPage: AlbumListResponse | undefined, pages) => {
if (!lastPage?.items) return undefined;
if (lastPage?.items?.length >= (query?.limit || 50)) {
return pages?.length;
}
return useInfiniteQuery({
enabled: !!serverId,
getNextPageParam: (lastPage: AlbumListResponse | undefined, pages) => {
if (!lastPage?.items) return undefined;
if (lastPage?.items?.length >= (query?.limit || 50)) {
return pages?.length;
}
return undefined;
},
queryFn: ({ pageParam = 0, signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumList({
apiClientProps: {
server,
signal,
return undefined;
},
query: {
...query,
limit: query.limit || 50,
startIndex: pageParam * (query.limit || 50),
queryFn: ({ pageParam = 0, signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query: {
...query,
limit: query.limit || 50,
startIndex: pageParam * (query.limit || 50),
},
});
},
});
},
queryKey: queryKeys.albums.list(server?.id || '', query),
...options,
});
queryKey: queryKeys.albums.list(server?.id || '', query),
...options,
});
};

View file

@ -13,52 +13,54 @@ import { LibraryItem } from '/@/renderer/api/types';
import { useCurrentServer } from '/@/renderer/store';
const AlbumDetailRoute = () => {
const tableRef = useRef<AgGridReactType | null>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumId],
type: LibraryItem.ALBUM,
},
playType: playButtonBehavior,
});
};
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumId],
type: LibraryItem.ALBUM,
},
playType: playButtonBehavior,
});
};
if (!background) return null;
if (!background) return null;
return (
<AnimatedPage key={`album-detail-${albumId}`}>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: background,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
target: headerRef,
}}
>
<AlbumDetailHeader
ref={headerRef}
background={background}
/>
<AlbumDetailContent tableRef={tableRef} />
</NativeScrollArea>
</AnimatedPage>
);
return (
<AnimatedPage key={`album-detail-${albumId}`}>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: background,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
<LibraryHeaderBar.Title>
{detailQuery?.data?.name}
</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
target: headerRef,
}}
>
<AlbumDetailHeader
ref={headerRef}
background={background}
/>
<AlbumDetailContent tableRef={tableRef} />
</NativeScrollArea>
</AnimatedPage>
);
};
export default AlbumDetailRoute;

View file

@ -10,55 +10,55 @@ import { useParams, useSearchParams } from 'react-router-dom';
import { AlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
const AlbumListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const server = useCurrentServer();
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const server = useCurrentServer();
const [searchParams] = useSearchParams();
const { albumArtistId } = useParams();
const [searchParams] = useSearchParams();
const { albumArtistId } = useParams();
const pageKey = generatePageKey(
'album',
albumArtistId ? `${albumArtistId}_${server?.id}` : undefined,
);
const pageKey = generatePageKey(
'album',
albumArtistId ? `${albumArtistId}_${server?.id}` : undefined,
);
const albumListFilter = useAlbumListFilter({ id: albumArtistId || undefined, key: pageKey });
const albumListFilter = useAlbumListFilter({ id: albumArtistId || undefined, key: pageKey });
const itemCountCheck = useAlbumList({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 1,
startIndex: 0,
...albumListFilter,
},
serverId: server?.id,
});
const itemCountCheck = useAlbumList({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 1,
startIndex: 0,
...albumListFilter,
},
serverId: server?.id,
});
const itemCount =
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const itemCount =
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
return (
<AnimatedPage>
<AlbumListContext.Provider value={{ id: albumArtistId || undefined, pageKey }}>
<AlbumListHeader
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
title={searchParams.get('artistName') || undefined}
/>
<AlbumListContent
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</AlbumListContext.Provider>
</AnimatedPage>
);
return (
<AnimatedPage>
<AlbumListContext.Provider value={{ id: albumArtistId || undefined, pageKey }}>
<AlbumListHeader
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
title={searchParams.get('artistName') || undefined}
/>
<AlbumListContent
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</AlbumListContext.Provider>
</AnimatedPage>
);
};
export default AlbumListRoute;

View file

@ -11,24 +11,24 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useContainerQuery } from '/@/renderer/hooks';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import {
useHandleGeneralContextMenu,
useHandleTableContextMenu,
useHandleGeneralContextMenu,
useHandleTableContextMenu,
} from '/@/renderer/features/context-menu';
import { CardRow, Play, TableColumn } from '/@/renderer/types';
import {
ARTIST_CONTEXT_MENU_ITEMS,
SONG_CONTEXT_MENU_ITEMS,
ARTIST_CONTEXT_MENU_ITEMS,
SONG_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import {
Album,
AlbumArtist,
AlbumListSort,
LibraryItem,
QueueSong,
ServerType,
SortOrder,
Album,
AlbumArtist,
AlbumListSort,
LibraryItem,
QueueSong,
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
@ -37,441 +37,455 @@ import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-tabl
import { SwiperGridCarousel } from '/@/renderer/components/grid-carousel';
const ContentContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
gap: 3rem;
padding: 1rem 2rem 5rem;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
gap: 3rem;
padding: 1rem 2rem 5rem;
overflow: hidden;
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
}
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
}
.ag-header {
margin-bottom: 0.5rem;
}
.ag-header {
margin-bottom: 0.5rem;
}
`;
export const AlbumArtistDetailContent = () => {
const { albumArtistId } = useParams() as { albumArtistId: string };
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
const server = useCurrentServer();
const { albumArtistId } = useParams() as { albumArtistId: string };
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
const server = useCurrentServer();
const detailQuery = useAlbumArtistDetail({ query: { id: albumArtistId }, serverId: server?.id });
const artistDiscographyLink = `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY, {
albumArtistId,
})}?${createSearchParams({
artistId: albumArtistId,
artistName: detailQuery?.data?.name || '',
})}`;
const artistSongsLink = `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS, {
albumArtistId,
})}?${createSearchParams({
artistId: albumArtistId,
artistName: detailQuery?.data?.name || '',
})}`;
const recentAlbumsQuery = useAlbumList({
query: {
_custom: {
jellyfin: {
...(server?.type === ServerType.JELLYFIN ? { ArtistIds: albumArtistId } : undefined),
},
navidrome: {
...(server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: false }
: undefined),
},
},
// limit: 10,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const compilationAlbumsQuery = useAlbumList({
query: {
_custom: {
jellyfin: {
...(server?.type === ServerType.JELLYFIN
? { ContributingArtistIds: albumArtistId }
: undefined),
},
navidrome: {
...(server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: true }
: undefined),
},
},
// limit: 10,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const topSongsQuery = useTopSongsList({
options: {
enabled: !!detailQuery?.data?.name,
},
query: {
artist: detailQuery?.data?.name || '',
artistId: albumArtistId,
},
serverId: server?.id,
});
const topSongsColumnDefs: ColDef[] = useMemo(
() =>
getColumnDefs([
{ column: TableColumn.ROW_INDEX, width: 0 },
{ column: TableColumn.TITLE_COMBINED, width: 0 },
{ column: TableColumn.DURATION, width: 0 },
{ column: TableColumn.ALBUM, width: 0 },
{ column: TableColumn.YEAR, width: 0 },
{ column: TableColumn.PLAY_COUNT, width: 0 },
{ column: TableColumn.USER_FAVORITE, width: 0 },
]),
[],
);
const cardRows: Record<string, CardRow<Album>[] | CardRow<AlbumArtist>[]> = {
album: [
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
],
albumArtist: [
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
],
};
const cardRoutes = {
album: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
albumArtist: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
};
const carousels = [
{
data: recentAlbumsQuery?.data?.items,
isHidden: !recentAlbumsQuery?.data?.items?.length,
itemType: LibraryItem.ALBUM,
loading: recentAlbumsQuery?.isLoading || recentAlbumsQuery.isFetching,
title: (
<Group align="flex-end">
<TextTitle
order={2}
weight={700}
>
Recent releases
</TextTitle>
<Button
compact
uppercase
component={Link}
to={artistDiscographyLink}
variant="subtle"
>
View discography
</Button>
</Group>
),
uniqueId: 'recentReleases',
},
{
data: compilationAlbumsQuery?.data?.items,
isHidden: !compilationAlbumsQuery?.data?.items?.length,
itemType: LibraryItem.ALBUM,
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
title: (
<TextTitle
order={2}
weight={700}
>
Appears on
</TextTitle>
),
uniqueId: 'compilationAlbums',
},
{
data: detailQuery?.data?.similarArtists || [],
isHidden: !detailQuery?.data?.similarArtists,
itemType: LibraryItem.ALBUM_ARTIST,
loading: detailQuery?.isLoading || detailQuery.isFetching,
title: (
<TextTitle
order={2}
weight={700}
>
Related artists
</TextTitle>
),
uniqueId: 'similarArtists',
},
];
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (playType?: Play) => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumArtistId],
type: LibraryItem.ALBUM_ARTIST,
},
playType: playType || playButtonBehavior,
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
serverId: server?.id,
});
};
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const artistDiscographyLink = `${generatePath(
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY,
{
albumArtistId,
},
)}?${createSearchParams({
artistId: albumArtistId,
artistName: detailQuery?.data?.name || '',
})}`;
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data || !topSongsQuery?.data) return;
const artistSongsLink = `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS, {
albumArtistId,
})}?${createSearchParams({
artistId: albumArtistId,
artistName: detailQuery?.data?.name || '',
})}`;
handlePlayQueueAdd?.({
byData: topSongsQuery?.data?.items || [],
initialSongId: e.data.id,
playType: playButtonBehavior,
const recentAlbumsQuery = useAlbumList({
query: {
_custom: {
jellyfin: {
...(server?.type === ServerType.JELLYFIN
? { ArtistIds: albumArtistId }
: undefined),
},
navidrome: {
...(server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: false }
: undefined),
},
},
// limit: 10,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
};
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = () => {
if (!detailQuery?.data) return;
if (detailQuery.data.userFavorite) {
deleteFavoriteMutation.mutate({
const compilationAlbumsQuery = useAlbumList({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM_ARTIST,
_custom: {
jellyfin: {
...(server?.type === ServerType.JELLYFIN
? { ContributingArtistIds: albumArtistId }
: undefined),
},
navidrome: {
...(server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: true }
: undefined),
},
},
// limit: 10,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const topSongsQuery = useTopSongsList({
options: {
enabled: !!detailQuery?.data?.name,
},
serverId: detailQuery.data.serverId,
});
} else {
createFavoriteMutation.mutate({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM_ARTIST,
artist: detailQuery?.data?.name || '',
artistId: albumArtistId,
},
serverId: detailQuery.data.serverId,
});
}
};
serverId: server?.id,
});
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.ALBUM_ARTIST,
ARTIST_CONTEXT_MENU_ITEMS,
);
const topSongsColumnDefs: ColDef[] = useMemo(
() =>
getColumnDefs([
{ column: TableColumn.ROW_INDEX, width: 0 },
{ column: TableColumn.TITLE_COMBINED, width: 0 },
{ column: TableColumn.DURATION, width: 0 },
{ column: TableColumn.ALBUM, width: 0 },
{ column: TableColumn.YEAR, width: 0 },
{ column: TableColumn.PLAY_COUNT, width: 0 },
{ column: TableColumn.USER_FAVORITE, width: 0 },
]),
[],
);
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
const cardRows: Record<string, CardRow<Album>[] | CardRow<AlbumArtist>[]> = {
album: [
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
],
albumArtist: [
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
],
};
const showBiography =
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
const showTopSongs = topSongsQuery?.data?.items?.length;
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const cardRoutes = {
album: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
albumArtist: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
};
const isLoading =
detailQuery?.isLoading || (server?.type === ServerType.NAVIDROME && topSongsQuery?.isLoading);
const carousels = [
{
data: recentAlbumsQuery?.data?.items,
isHidden: !recentAlbumsQuery?.data?.items?.length,
itemType: LibraryItem.ALBUM,
loading: recentAlbumsQuery?.isLoading || recentAlbumsQuery.isFetching,
title: (
<Group align="flex-end">
<TextTitle
order={2}
weight={700}
>
Recent releases
</TextTitle>
<Button
compact
uppercase
component={Link}
to={artistDiscographyLink}
variant="subtle"
>
View discography
</Button>
</Group>
),
uniqueId: 'recentReleases',
},
{
data: compilationAlbumsQuery?.data?.items,
isHidden: !compilationAlbumsQuery?.data?.items?.length,
itemType: LibraryItem.ALBUM,
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
title: (
<TextTitle
order={2}
weight={700}
>
Appears on
</TextTitle>
),
uniqueId: 'compilationAlbums',
},
{
data: detailQuery?.data?.similarArtists || [],
isHidden: !detailQuery?.data?.similarArtists,
itemType: LibraryItem.ALBUM_ARTIST,
loading: detailQuery?.isLoading || detailQuery.isFetching,
title: (
<TextTitle
order={2}
weight={700}
>
Related artists
</TextTitle>
),
uniqueId: 'similarArtists',
},
];
if (isLoading) return <ContentContainer ref={cq.ref} />;
const playButtonBehavior = usePlayButtonBehavior();
return (
<ContentContainer ref={cq.ref}>
<Box component="section">
<Group spacing="md">
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
compact
loading={createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading}
variant="subtle"
onClick={handleFavorite}
>
{detailQuery?.data?.userFavorite ? (
<RiHeartFill
color="red"
size={20}
/>
) : (
<RiHeartLine size={20} />
)}
</Button>
<Button
compact
variant="subtle"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
>
<RiMoreFill size={20} />
</Button>
<Button
compact
uppercase
component={Link}
to={artistDiscographyLink}
variant="subtle"
>
View discography
</Button>
<Button
compact
uppercase
component={Link}
to={artistSongsLink}
variant="subtle"
>
View all songs
</Button>
</Group>
</Group>
</Box>
{showGenres ? (
<Box component="section">
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
key={`genre-${genre.id}`}
compact
component={Link}
radius="md"
size="md"
to={generatePath(`${AppRoute.LIBRARY_ALBUM_ARTISTS}?genre=${genre.id}`, {
albumArtistId,
})}
variant="outline"
>
{genre.name}
</Button>
))}
</Group>
</Box>
) : null}
{showBiography ? (
<Box
component="section"
maw="1280px"
>
<TextTitle
order={2}
weight={700}
>
About {detailQuery?.data?.name}
</TextTitle>
<Text
$secondary
component="p"
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
sx={{ textAlign: 'justify' }}
/>
</Box>
) : null}
{showTopSongs ? (
<Box component="section">
<Group
noWrap
position="apart"
>
<Group
noWrap
align="flex-end"
>
<TextTitle
order={2}
weight={700}
>
Top Songs
</TextTitle>
<Button
compact
uppercase
component={Link}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS, {
albumArtistId,
})}
variant="subtle"
>
View all
</Button>
</Group>
</Group>
<VirtualTable
autoFitColumns
autoHeight
deselectOnClickOutside
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={topSongsColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
rowData={topSongs}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
) : null}
<Box component="section">
<Stack spacing="xl">
{carousels
.filter((c) => !c.isHidden)
.map((carousel) => (
<SwiperGridCarousel
key={`carousel-${carousel.uniqueId}`}
cardRows={cardRows[carousel.itemType as keyof typeof cardRows]}
data={carousel.data}
isLoading={carousel.loading}
itemType={carousel.itemType}
route={cardRoutes[carousel.itemType as keyof typeof cardRoutes]}
swiperProps={{
grid: {
rows: 2,
},
}}
title={{
label: carousel.title,
}}
uniqueId={carousel.uniqueId}
/>
))}
</Stack>
</Box>
</ContentContainer>
);
const handlePlay = async (playType?: Play) => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumArtistId],
type: LibraryItem.ALBUM_ARTIST,
},
playType: playType || playButtonBehavior,
});
};
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data || !topSongsQuery?.data) return;
handlePlayQueueAdd?.({
byData: topSongsQuery?.data?.items || [],
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = () => {
if (!detailQuery?.data) return;
if (detailQuery.data.userFavorite) {
deleteFavoriteMutation.mutate({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM_ARTIST,
},
serverId: detailQuery.data.serverId,
});
} else {
createFavoriteMutation.mutate({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM_ARTIST,
},
serverId: detailQuery.data.serverId,
});
}
};
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.ALBUM_ARTIST,
ARTIST_CONTEXT_MENU_ITEMS,
);
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
const showBiography =
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
const showTopSongs = topSongsQuery?.data?.items?.length;
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const isLoading =
detailQuery?.isLoading ||
(server?.type === ServerType.NAVIDROME && topSongsQuery?.isLoading);
if (isLoading) return <ContentContainer ref={cq.ref} />;
return (
<ContentContainer ref={cq.ref}>
<Box component="section">
<Group spacing="md">
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
compact
loading={
createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading
}
variant="subtle"
onClick={handleFavorite}
>
{detailQuery?.data?.userFavorite ? (
<RiHeartFill
color="red"
size={20}
/>
) : (
<RiHeartLine size={20} />
)}
</Button>
<Button
compact
variant="subtle"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
>
<RiMoreFill size={20} />
</Button>
<Button
compact
uppercase
component={Link}
to={artistDiscographyLink}
variant="subtle"
>
View discography
</Button>
<Button
compact
uppercase
component={Link}
to={artistSongsLink}
variant="subtle"
>
View all songs
</Button>
</Group>
</Group>
</Box>
{showGenres ? (
<Box component="section">
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
key={`genre-${genre.id}`}
compact
component={Link}
radius="md"
size="md"
to={generatePath(
`${AppRoute.LIBRARY_ALBUM_ARTISTS}?genre=${genre.id}`,
{
albumArtistId,
},
)}
variant="outline"
>
{genre.name}
</Button>
))}
</Group>
</Box>
) : null}
{showBiography ? (
<Box
component="section"
maw="1280px"
>
<TextTitle
order={2}
weight={700}
>
About {detailQuery?.data?.name}
</TextTitle>
<Text
$secondary
component="p"
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
sx={{ textAlign: 'justify' }}
/>
</Box>
) : null}
{showTopSongs ? (
<Box component="section">
<Group
noWrap
position="apart"
>
<Group
noWrap
align="flex-end"
>
<TextTitle
order={2}
weight={700}
>
Top Songs
</TextTitle>
<Button
compact
uppercase
component={Link}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS, {
albumArtistId,
})}
variant="subtle"
>
View all
</Button>
</Group>
</Group>
<VirtualTable
autoFitColumns
autoHeight
deselectOnClickOutside
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={topSongsColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
rowData={topSongs}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
) : null}
<Box component="section">
<Stack spacing="xl">
{carousels
.filter((c) => !c.isHidden)
.map((carousel) => (
<SwiperGridCarousel
key={`carousel-${carousel.uniqueId}`}
cardRows={cardRows[carousel.itemType as keyof typeof cardRows]}
data={carousel.data}
isLoading={carousel.loading}
itemType={carousel.itemType}
route={cardRoutes[carousel.itemType as keyof typeof cardRoutes]}
swiperProps={{
grid: {
rows: 2,
},
}}
title={{
label: carousel.title,
}}
uniqueId={carousel.uniqueId}
/>
))}
</Stack>
</Box>
</ContentContainer>
);
};

View file

@ -11,110 +11,114 @@ import { formatDurationString } from '/@/renderer/utils';
import { useCurrentServer } from '../../../store/auth.store';
interface AlbumArtistDetailHeaderProps {
background: string;
background: string;
}
export const AlbumArtistDetailHeader = forwardRef(
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
const { albumArtistId } = useParams() as { albumArtistId: string };
const server = useCurrentServer();
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
serverId: server?.id,
});
const cq = useContainerQuery();
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
const { albumArtistId } = useParams() as { albumArtistId: string };
const server = useCurrentServer();
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
serverId: server?.id,
});
const cq = useContainerQuery();
const metadataItems = [
{
id: 'albumCount',
secondary: false,
value: detailQuery?.data?.albumCount && `${detailQuery?.data?.albumCount} albums`,
},
{
id: 'songCount',
secondary: false,
value: detailQuery?.data?.songCount && `${detailQuery?.data?.songCount} songs`,
},
{
id: 'duration',
secondary: true,
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
const metadataItems = [
{
id: 'albumCount',
secondary: false,
value: detailQuery?.data?.albumCount && `${detailQuery?.data?.albumCount} albums`,
},
{
id: 'songCount',
secondary: false,
value: detailQuery?.data?.songCount && `${detailQuery?.data?.songCount} songs`,
},
{
id: 'duration',
secondary: true,
value:
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
const updateRatingMutation = useSetRating({});
const updateRatingMutation = useSetRating({});
const handleUpdateRating = (rating: number) => {
if (!detailQuery?.data) return;
const handleUpdateRating = (rating: number) => {
if (!detailQuery?.data) return;
updateRatingMutation.mutate({
query: {
item: [detailQuery.data],
rating,
},
serverId: detailQuery?.data.serverId,
});
};
updateRatingMutation.mutate({
query: {
item: [detailQuery.data],
rating,
},
serverId: detailQuery?.data.serverId,
});
};
const handleClearRating = (_e: MouseEvent<HTMLDivElement>, rating?: number) => {
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
const handleClearRating = (_e: MouseEvent<HTMLDivElement>, rating?: number) => {
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
const isSameRatingAsPrevious = rating === detailQuery.data.userRating;
if (!isSameRatingAsPrevious) return;
const isSameRatingAsPrevious = rating === detailQuery.data.userRating;
if (!isSameRatingAsPrevious) return;
updateRatingMutation.mutate({
query: {
item: [detailQuery.data],
rating: 0,
},
serverId: detailQuery.data.serverId,
});
};
updateRatingMutation.mutate({
query: {
item: [detailQuery.data],
rating: 0,
},
serverId: detailQuery.data.serverId,
});
};
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
return (
<Stack ref={cq.ref}>
<LibraryHeader
ref={ref}
background={background}
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
title={detailQuery?.data?.name || ''}
>
<Stack>
<Group>
{metadataItems
.filter((i) => i.value)
.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
</Fragment>
))}
{showRating && (
<>
<Text $noSelect></Text>
<Rating
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
value={detailQuery?.data?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</>
)}
</Group>
<Group
sx={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
display: '-webkit-box',
overflow: 'hidden',
}}
/>
</Stack>
</LibraryHeader>
</Stack>
);
},
return (
<Stack ref={cq.ref}>
<LibraryHeader
ref={ref}
background={background}
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST }}
title={detailQuery?.data?.name || ''}
>
<Stack>
<Group>
{metadataItems
.filter((i) => i.value)
.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
</Fragment>
))}
{showRating && (
<>
<Text $noSelect></Text>
<Rating
readOnly={
detailQuery?.isFetching ||
updateRatingMutation.isLoading
}
value={detailQuery?.data?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</>
)}
</Group>
<Group
sx={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
display: '-webkit-box',
overflow: 'hidden',
}}
/>
</Stack>
</LibraryHeader>
</Stack>
);
},
);

View file

@ -11,72 +11,72 @@ import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-gr
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
interface AlbumArtistSongListContentProps {
data: QueueSong[];
tableRef: MutableRefObject<AgGridReactType | null>;
data: QueueSong[];
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const AlbumArtistDetailTopSongsListContent = ({
tableRef,
data,
tableRef,
data,
}: AlbumArtistSongListContentProps) => {
const server = useCurrentServer();
const page = useSongListStore();
const server = useCurrentServer();
const page = useSongListStore();
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns),
[page.table.columns],
);
const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns),
[page.table.columns],
);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
const rowData: QueueSong[] = [];
e.api.forEachNode((node) => {
if (!node.data) return;
rowData.push(node.data);
});
const rowData: QueueSong[] = [];
e.api.forEachNode((node) => {
if (!node.data) return;
rowData.push(node.data);
});
handlePlayQueueAdd?.({
byData: rowData,
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
handlePlayQueueAdd?.({
byData: rowData,
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
return (
<>
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
animateRows
maintainColumnOrder
suppressCopyRowsToClipboard
suppressMoveWhenRowDragging
suppressPaginationPanel
suppressRowDrag
suppressScrollOnNewData
autoFitColumns={page.table.autoFit}
columnDefs={columnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
rowBuffer={20}
rowData={data}
rowHeight={page.table.rowHeight || 40}
rowModelType="clientSide"
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</VirtualGridAutoSizerContainer>
</>
);
return (
<>
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
animateRows
maintainColumnOrder
suppressCopyRowsToClipboard
suppressMoveWhenRowDragging
suppressPaginationPanel
suppressRowDrag
suppressScrollOnNewData
autoFitColumns={page.table.autoFit}
columnDefs={columnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
rowBuffer={20}
rowData={data}
rowHeight={page.table.rowHeight || 40}
rowModelType="clientSide"
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</VirtualGridAutoSizerContainer>
</>
);
};

View file

@ -7,71 +7,71 @@ import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
interface AlbumArtistDetailTopSongsListHeaderProps {
data: QueueSong[];
itemCount?: number;
title: string;
data: QueueSong[];
itemCount?: number;
title: string;
}
export const AlbumArtistDetailTopSongsListHeader = ({
title,
itemCount,
data,
title,
itemCount,
data,
}: AlbumArtistDetailTopSongsListHeaderProps) => {
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (playType: Play) => {
handlePlayQueueAdd?.({
byData: data,
playType,
});
};
const handlePlay = async (playType: Play) => {
handlePlayQueueAdd?.({
byData: data,
playType,
});
};
return (
<PageHeader p="1rem">
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<LibraryHeaderBar.Title>Top songs from {title}</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
py="0.3rem"
radius="sm"
>
{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)}
>
Play
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay(Play.LAST)}
>
Add to queue
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay(Play.NEXT)}
>
Add to queue next
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</LibraryHeaderBar>
</PageHeader>
);
return (
<PageHeader p="1rem">
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<LibraryHeaderBar.Title>Top songs from {title}</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
py="0.3rem"
radius="sm"
>
{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)}
>
Play
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay(Play.LAST)}
>
Add to queue
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay(Play.NEXT)}
>
Add to queue next
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</LibraryHeaderBar>
</PageHeader>
);
};

View file

@ -11,12 +11,12 @@ import { useQueryClient } from '@tanstack/react-query';
import { useCurrentServer, useAlbumArtistListStore } from '/@/renderer/store';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import {
BodyScrollEvent,
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
RowDoubleClickedEvent,
BodyScrollEvent,
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
RowDoubleClickedEvent,
} from '@ag-grid-community/core';
import { AnimatePresence } from 'framer-motion';
import debounce from 'lodash/debounce';
@ -28,311 +28,316 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useAlbumArtistListFilter, useListStoreActions } from '../../../store/list.store';
import { useAlbumArtistListContext } from '/@/renderer/features/artists/context/album-artist-list-context';
import {
VirtualInfiniteGridRef,
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,
VirtualInfiniteGridRef,
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,
} from '/@/renderer/components/virtual-grid';
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
interface AlbumArtistListContentProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
tableRef: MutableRefObject<AgGridReactType | null>;
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListContentProps) => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd();
const queryClient = useQueryClient();
const navigate = useNavigate();
const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd();
const { id, pageKey } = useAlbumArtistListContext();
const filter = useAlbumArtistListFilter({ id, key: pageKey });
const { table, grid, display } = useAlbumArtistListStore();
const { setTable, setTablePagination, setGrid } = useListStoreActions();
const { id, pageKey } = useAlbumArtistListContext();
const filter = useAlbumArtistListFilter({ id, key: pageKey });
const { table, grid, display } = useAlbumArtistListStore();
const { setTable, setTablePagination, setGrid } = useListStoreActions();
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
const checkAlbumArtistList = useAlbumArtistList({
options: {
cacheTime: Infinity,
staleTime: 60 * 1000 * 5,
},
query: {
limit: 1,
startIndex: 0,
...filter,
},
serverId: server?.id,
});
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
const onTableReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
limit,
startIndex,
const checkAlbumArtistList = useAlbumArtistList({
options: {
cacheTime: Infinity,
staleTime: 60 * 1000 * 5,
},
query: {
limit: 1,
startIndex: 0,
...filter,
});
const albumArtistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filter,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
albumArtistsRes?.items || [],
albumArtistsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
serverId: server?.id,
});
params.api.setDatasource(dataSource);
params.api.ensureIndexVisible(table.scrollOffset || 0, 'top');
},
[filter, table.scrollOffset, queryClient, server],
);
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
const onTablePaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
const onTableReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
try {
// Scroll to top of page on pagination change
const currentPageStartIndex = table.pagination.currentPage * table.pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) {
console.log(err);
}
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
limit,
startIndex,
...filter,
});
setTablePagination({
data: {
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
const albumArtistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filter,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
albumArtistsRes?.items || [],
albumArtistsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
params.api.ensureIndexVisible(table.scrollOffset || 0, 'top');
},
key: pageKey,
});
},
[
isPaginationEnabled,
pageKey,
setTablePagination,
table.pagination.currentPage,
table.pagination.itemsPerPage,
],
);
[filter, table.scrollOffset, queryClient, server],
);
const handleTableColumnChange = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
const onTablePaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
if (!columnsOrder) return;
try {
// Scroll to top of page on pagination change
const currentPageStartIndex =
table.pagination.currentPage * table.pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) {
console.log(err);
}
const columnsInSettings = table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
setTablePagination({
data: {
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
},
key: pageKey,
});
},
[
isPaginationEnabled,
pageKey,
setTablePagination,
table.pagination.currentPage,
table.pagination.itemsPerPage,
],
);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!table.autoFit && {
width: column.getColDef().width,
}),
});
}
}
const handleTableColumnChange = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
setTable({ data: { columns: updatedColumns }, key: pageKey });
}, [tableRef, table.columns, table.autoFit, setTable, pageKey]);
if (!columnsOrder) return;
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
const columnsInSettings = table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find(
(c) => c.column === column.getColDef().colId,
);
const handleTableScroll = (e: BodyScrollEvent) => {
const scrollOffset = Number((e.top / table.rowHeight).toFixed(0));
setTable({ data: { scrollOffset }, key: pageKey });
};
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!table.autoFit && {
width: column.getColDef().width,
}),
});
}
}
const fetch = useCallback(
async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => {
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
limit,
startIndex,
...filter,
});
setTable({ data: { columns: updatedColumns }, key: pageKey });
}, [tableRef, table.columns, table.autoFit, setTable, pageKey]);
const albumArtistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filter,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
return albumArtistsRes;
},
[filter, queryClient, server],
);
const handleTableScroll = (e: BodyScrollEvent) => {
const scrollOffset = Number((e.top / table.rowHeight).toFixed(0));
setTable({ data: { scrollOffset }, key: pageKey });
};
const handleGridScroll = useCallback(
(e: ListOnScrollProps) => {
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
},
[pageKey, setGrid],
);
const fetch = useCallback(
async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => {
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
limit,
startIndex,
...filter,
});
const handleGridSizeChange = () => {
if (table.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const albumArtistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filter,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
const cardRows = useMemo(() => {
const rows: CardRow<AlbumArtist>[] = [ALBUMARTIST_CARD_ROWS.name];
return albumArtistsRes;
},
[filter, queryClient, server],
);
switch (filter.sortBy) {
case AlbumArtistListSort.DURATION:
rows.push(ALBUMARTIST_CARD_ROWS.duration);
break;
case AlbumArtistListSort.FAVORITED:
break;
case AlbumArtistListSort.NAME:
break;
case AlbumArtistListSort.ALBUM_COUNT:
rows.push(ALBUMARTIST_CARD_ROWS.albumCount);
break;
case AlbumArtistListSort.PLAY_COUNT:
rows.push(ALBUMARTIST_CARD_ROWS.playCount);
break;
case AlbumArtistListSort.RANDOM:
break;
case AlbumArtistListSort.RATING:
rows.push(ALBUMARTIST_CARD_ROWS.rating);
break;
case AlbumArtistListSort.RECENTLY_ADDED:
break;
case AlbumArtistListSort.SONG_COUNT:
rows.push(ALBUMARTIST_CARD_ROWS.songCount);
break;
case AlbumArtistListSort.RELEASE_DATE:
break;
}
const handleGridScroll = useCallback(
(e: ListOnScrollProps) => {
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
},
[pageKey, setGrid],
);
return rows;
}, [filter.sortBy]);
const handleGridSizeChange = () => {
if (table.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const handleContextMenu = useHandleTableContextMenu(
LibraryItem.ALBUM_ARTIST,
ALBUM_CONTEXT_MENU_ITEMS,
);
const cardRows = useMemo(() => {
const rows: CardRow<AlbumArtist>[] = [ALBUMARTIST_CARD_ROWS.name];
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId: e.data.id }));
};
switch (filter.sortBy) {
case AlbumArtistListSort.DURATION:
rows.push(ALBUMARTIST_CARD_ROWS.duration);
break;
case AlbumArtistListSort.FAVORITED:
break;
case AlbumArtistListSort.NAME:
break;
case AlbumArtistListSort.ALBUM_COUNT:
rows.push(ALBUMARTIST_CARD_ROWS.albumCount);
break;
case AlbumArtistListSort.PLAY_COUNT:
rows.push(ALBUMARTIST_CARD_ROWS.playCount);
break;
case AlbumArtistListSort.RANDOM:
break;
case AlbumArtistListSort.RATING:
rows.push(ALBUMARTIST_CARD_ROWS.rating);
break;
case AlbumArtistListSort.RECENTLY_ADDED:
break;
case AlbumArtistListSort.SONG_COUNT:
rows.push(ALBUMARTIST_CARD_ROWS.songCount);
break;
case AlbumArtistListSort.RELEASE_DATE:
break;
}
return (
<>
<VirtualGridAutoSizerContainer>
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
<AutoSizer>
{({ height, width }) => (
<>
<VirtualInfiniteGrid
ref={gridRef}
cardRows={cardRows}
display={display || ListDisplayType.CARD}
fetchFn={fetch}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={grid?.scrollOffset || 0}
itemCount={checkAlbumArtistList?.data?.totalRecordCount || 0}
itemGap={20}
itemSize={grid?.itemsPerRow || 5}
itemType={LibraryItem.ALBUM_ARTIST}
loading={checkAlbumArtistList.isLoading}
minimumBatchSize={40}
route={{
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
}}
width={width}
onScroll={handleGridScroll}
/>
</>
return rows;
}, [filter.sortBy]);
const handleContextMenu = useHandleTableContextMenu(
LibraryItem.ALBUM_ARTIST,
ALBUM_CONTEXT_MENU_ITEMS,
);
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId: e.data.id }));
};
return (
<>
<VirtualGridAutoSizerContainer>
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
<AutoSizer>
{({ height, width }) => (
<>
<VirtualInfiniteGrid
ref={gridRef}
cardRows={cardRows}
display={display || ListDisplayType.CARD}
fetchFn={fetch}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={grid?.scrollOffset || 0}
itemCount={checkAlbumArtistList?.data?.totalRecordCount || 0}
itemGap={20}
itemSize={grid?.itemsPerRow || 5}
itemType={LibraryItem.ALBUM_ARTIST}
loading={checkAlbumArtistList.isLoading}
minimumBatchSize={40}
route={{
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [
{ idProperty: 'id', slugProperty: 'albumArtistId' },
],
}}
width={width}
onScroll={handleGridScroll}
/>
</>
)}
</AutoSizer>
) : (
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${display}-${table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
suppressRowDrag
autoFitColumns={table.autoFit}
columnDefs={columnDefs}
getRowId={(data) => data.data.id}
infiniteInitialRowCount={checkAlbumArtistList.data?.totalRecordCount || 1}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={table.pagination.itemsPerPage || 100}
rowHeight={table.rowHeight || 40}
rowModelType="infinite"
onBodyScrollEnd={handleTableScroll}
onCellContextMenu={handleContextMenu}
onColumnMoved={handleTableColumnChange}
onColumnResized={debouncedTableColumnChange}
onGridReady={onTableReady}
onGridSizeChanged={handleGridSizeChange}
onPaginationChanged={onTablePaginationChanged}
onRowDoubleClicked={handleRowDoubleClick}
/>
)}
</VirtualGridAutoSizerContainer>
{isPaginationEnabled && (
<AnimatePresence
presenceAffectsLayout
initial={false}
mode="wait"
>
{display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pageKey={pageKey}
pagination={table.pagination}
setPagination={setTablePagination}
tableRef={tableRef}
/>
)}
</AnimatePresence>
)}
</AutoSizer>
) : (
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${display}-${table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
suppressRowDrag
autoFitColumns={table.autoFit}
columnDefs={columnDefs}
getRowId={(data) => data.data.id}
infiniteInitialRowCount={checkAlbumArtistList.data?.totalRecordCount || 1}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={table.pagination.itemsPerPage || 100}
rowHeight={table.rowHeight || 40}
rowModelType="infinite"
onBodyScrollEnd={handleTableScroll}
onCellContextMenu={handleContextMenu}
onColumnMoved={handleTableColumnChange}
onColumnResized={debouncedTableColumnChange}
onGridReady={onTableReady}
onGridSizeChanged={handleGridSizeChange}
onPaginationChanged={onTablePaginationChanged}
onRowDoubleClicked={handleRowDoubleClick}
/>
)}
</VirtualGridAutoSizerContainer>
{isPaginationEnabled && (
<AnimatePresence
presenceAffectsLayout
initial={false}
mode="wait"
>
{display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pageKey={pageKey}
pagination={table.pagination}
setPagination={setTablePagination}
tableRef={tableRef}
/>
)}
</AnimatePresence>
)}
</>
);
</>
);
};

View file

@ -5,12 +5,12 @@ import { Group, Stack, Flex } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import {
RiSortAsc,
RiSortDesc,
RiFolder2Line,
RiMoreFill,
RiRefreshLine,
RiSettings3Fill,
RiSortAsc,
RiSortDesc,
RiFolder2Line,
RiMoreFill,
RiRefreshLine,
RiSettings3Fill,
} from 'react-icons/ri';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
@ -19,11 +19,11 @@ import { DropdownMenu, Text, Button, Slider, MultiSelect, Switch } from '/@/rend
import { useMusicFolders } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import {
useCurrentServer,
useAlbumArtistListStore,
AlbumArtistListFilter,
useListStoreActions,
useAlbumArtistListFilter,
useCurrentServer,
useAlbumArtistListStore,
AlbumArtistListFilter,
useListStoreActions,
useAlbumArtistListFilter,
} from '/@/renderer/store';
import { ListDisplayType, TableColumn, ServerType } from '/@/renderer/types';
import { useAlbumArtistListContext } from '../context/album-artist-list-context';
@ -31,455 +31,468 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
const FILTERS = {
jellyfin: [
{ defaultOrder: SortOrder.ASC, name: 'Album', value: AlbumArtistListSort.ALBUM },
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumArtistListSort.DURATION },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumArtistListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumArtistListSort.RANDOM },
{
defaultOrder: SortOrder.DESC,
name: 'Recently Added',
value: AlbumArtistListSort.RECENTLY_ADDED,
},
// { defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumArtistListSort.RELEASE_DATE },
],
navidrome: [
{ defaultOrder: SortOrder.DESC, name: 'Album Count', value: AlbumArtistListSort.ALBUM_COUNT },
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumArtistListSort.FAVORITED },
{ defaultOrder: SortOrder.DESC, name: 'Most Played', value: AlbumArtistListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumArtistListSort.NAME },
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumArtistListSort.RATING },
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumArtistListSort.SONG_COUNT },
],
jellyfin: [
{ defaultOrder: SortOrder.ASC, name: 'Album', value: AlbumArtistListSort.ALBUM },
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumArtistListSort.DURATION },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumArtistListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumArtistListSort.RANDOM },
{
defaultOrder: SortOrder.DESC,
name: 'Recently Added',
value: AlbumArtistListSort.RECENTLY_ADDED,
},
// { defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumArtistListSort.RELEASE_DATE },
],
navidrome: [
{
defaultOrder: SortOrder.DESC,
name: 'Album Count',
value: AlbumArtistListSort.ALBUM_COUNT,
},
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumArtistListSort.FAVORITED },
{
defaultOrder: SortOrder.DESC,
name: 'Most Played',
value: AlbumArtistListSort.PLAY_COUNT,
},
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumArtistListSort.NAME },
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumArtistListSort.RATING },
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumArtistListSort.SONG_COUNT },
],
};
const ORDER = [
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
];
interface AlbumArtistListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
tableRef: MutableRefObject<AgGridReactType | null>;
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const AlbumArtistListHeaderFilters = ({
gridRef,
tableRef,
gridRef,
tableRef,
}: AlbumArtistListHeaderFiltersProps) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const { pageKey } = useAlbumArtistListContext();
const { display, table, grid } = useAlbumArtistListStore();
const { setFilter, setTable, setTablePagination, setDisplayType, setGrid } =
useListStoreActions();
const filter = useAlbumArtistListFilter({ key: pageKey });
const cq = useContainerQuery();
const queryClient = useQueryClient();
const server = useCurrentServer();
const { pageKey } = useAlbumArtistListContext();
const { display, table, grid } = useAlbumArtistListStore();
const { setFilter, setTable, setTablePagination, setDisplayType, setGrid } =
useListStoreActions();
const filter = useAlbumArtistListFilter({ key: pageKey });
const cq = useContainerQuery();
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
const sortByLabel =
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy)?.name) ||
'Unknown';
const sortByLabel =
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy)
?.name) ||
'Unknown';
const sortOrderLabel = ORDER.find((o) => o.value === filter.sortOrder)?.name || 'Unknown';
const sortOrderLabel = ORDER.find((o) => o.value === filter.sortOrder)?.name || 'Unknown';
const handleItemSize = (e: number) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
setTable({ data: { rowHeight: e }, key: pageKey });
} else {
setGrid({ data: { itemsPerRow: e }, key: pageKey });
}
};
const handleItemSize = (e: number) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
setTable({ data: { rowHeight: e }, key: pageKey });
} else {
setGrid({ data: { itemsPerRow: e }, key: pageKey });
}
};
const debouncedHandleItemSize = debounce(handleItemSize, 20);
const fetch = useCallback(
async (startIndex: number, limit: number, filters: AlbumArtistListFilter) => {
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
limit,
startIndex,
...filters,
});
const albums = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
return albums;
},
[queryClient, server],
);
const handleFilterChange = useCallback(
async (filters: AlbumArtistListFilter) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const debouncedHandleItemSize = debounce(handleItemSize, 20);
const fetch = useCallback(
async (startIndex: number, limit: number, filters: AlbumArtistListFilter) => {
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
limit,
startIndex,
...filters,
limit,
startIndex,
...filters,
});
const albumArtistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
const albums = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
albumArtistsRes?.items || [],
albumArtistsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
return albums;
},
[queryClient, server],
);
if (display === ListDisplayType.TABLE_PAGINATED) {
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
const handleFilterChange = useCallback(
async (filters: AlbumArtistListFilter) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
limit,
startIndex,
...filters,
});
const albumArtistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
albumArtistsRes?.items || [],
albumArtistsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
if (display === ListDisplayType.TABLE_PAGINATED) {
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
}
} else {
gridRef.current?.scrollTo(0);
gridRef.current?.resetLoadMoreItemsCache();
// Refetching within the virtualized grid may be inconsistent due to it refetching
// using an outdated set of filters. To avoid this, we fetch using the updated filters
// and then set the grid's data here.
const data = await fetch(0, 200, filters);
if (!data?.items) return;
gridRef.current?.setItemData(data.items);
}
},
[display, tableRef, server, queryClient, setTablePagination, pageKey, gridRef, fetch],
);
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
const updatedFilters = setFilter({
data: {
sortBy: e.currentTarget.value as AlbumArtistListSort,
sortOrder: sortOrder || SortOrder.ASC,
},
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
handleFilterChange(updatedFilters);
},
[handleFilterChange, pageKey, server?.type, setFilter],
);
const handleSetMusicFolder = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
let updatedFilters = null;
if (e.currentTarget.value === String(filter.musicFolderId)) {
updatedFilters = setFilter({
data: { musicFolderId: undefined },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
} else {
updatedFilters = setFilter({
data: { musicFolderId: e.currentTarget.value },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
}
handleFilterChange(updatedFilters);
},
[filter.musicFolderId, handleFilterChange, setFilter, pageKey],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({
data: { sortOrder: newSortOrder },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
handleFilterChange(updatedFilters);
}, [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 });
},
[pageKey, setDisplayType],
);
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = table.columns;
if (values.length === 0) {
return setTable({
data: {
columns: [],
},
key: pageKey,
});
}
} else {
gridRef.current?.scrollTo(0);
gridRef.current?.resetLoadMoreItemsCache();
// Refetching within the virtualized grid may be inconsistent due to it refetching
// using an outdated set of filters. To avoid this, we fetch using the updated filters
// and then set the grid's data here.
const data = await fetch(0, 200, filters);
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
if (!data?.items) return;
gridRef.current?.setItemData(data.items);
}
},
[display, tableRef, server, queryClient, setTablePagination, pageKey, gridRef, fetch],
);
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
} else {
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
setTable({ data: { columns: newColumns }, key: pageKey });
}
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
return tableRef.current?.api.sizeColumnsToFit();
};
const updatedFilters = setFilter({
data: {
sortBy: e.currentTarget.value as AlbumArtistListSort,
sortOrder: sortOrder || SortOrder.ASC,
},
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
handleFilterChange(updatedFilters);
},
[handleFilterChange, pageKey, server?.type, setFilter],
);
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
const handleSetMusicFolder = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
const handleRefresh = useCallback(() => {
queryClient.invalidateQueries(queryKeys.albumArtists.list(server?.id || ''));
handleFilterChange(filter);
}, [filter, handleFilterChange, queryClient, server?.id]);
let updatedFilters = null;
if (e.currentTarget.value === String(filter.musicFolderId)) {
updatedFilters = setFilter({
data: { musicFolderId: undefined },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
} else {
updatedFilters = setFilter({
data: { musicFolderId: e.currentTarget.value },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
}
handleFilterChange(updatedFilters);
},
[filter.musicFolderId, handleFilterChange, setFilter, pageKey],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({
data: { sortOrder: newSortOrder },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
handleFilterChange(updatedFilters);
}, [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 });
},
[pageKey, setDisplayType],
);
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = table.columns;
if (values.length === 0) {
return setTable({
data: {
columns: [],
},
key: pageKey,
});
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
} else {
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
setTable({ data: { columns: newColumns }, key: pageKey });
}
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
const handleRefresh = useCallback(() => {
queryClient.invalidateQueries(queryKeys.albumArtists.list(server?.id || ''));
handleFilterChange(filter);
}, [filter, handleFilterChange, queryClient, server?.id]);
return (
<Flex justify="space-between">
<Group
ref={cq.ref}
spacing="sm"
w="100%"
>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
return (
<Flex justify="space-between">
<Group
ref={cq.ref}
spacing="sm"
w="100%"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
<DropdownMenu.Item
key={`filter-${f.name}`}
$isActive={f.value === filter.sortBy}
value={f.value}
onClick={handleSetSortBy}
>
{f.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
fw="600"
size="md"
variant="subtle"
onClick={handleToggleSortOrder}
>
{cq.isMd ? (
sortOrderLabel
) : (
<>
{filter.sortOrder === SortOrder.ASC ? (
<RiSortAsc size={15} />
) : (
<RiSortDesc size={15} />
)}
</>
)}
</Button>
{server?.type === ServerType.JELLYFIN && (
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.items.map((folder) => (
<DropdownMenu.Item
key={`musicFolder-${folder.id}`}
$isActive={filter.musicFolderId === folder.id}
value={folder.id}
onClick={handleSetMusicFolder}
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
<DropdownMenu.Item
key={`filter-${f.name}`}
$isActive={f.value === filter.sortBy}
value={f.value}
onClick={handleSetSortBy}
>
{f.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
fw="600"
size="md"
variant="subtle"
onClick={handleToggleSortOrder}
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
)}
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiMoreFill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE_PAGINATED}
value={ListDisplayType.TABLE_PAGINATED}
onClick={handleSetViewType}
>
Table (paginated)
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>Item size</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
<Slider
defaultValue={grid?.itemsPerRow}
label={null}
max={10}
min={2}
onChange={debouncedHandleItemSize}
/>
) : (
<Slider
defaultValue={table.rowHeight}
label={null}
max={100}
min={30}
onChange={debouncedHandleItemSize}
/>
)}
</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={ALBUMARTIST_TABLE_COLUMNS}
defaultValue={table?.columns.map((column) => column.column)}
width={300}
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Flex>
);
{cq.isMd ? (
sortOrderLabel
) : (
<>
{filter.sortOrder === SortOrder.ASC ? (
<RiSortAsc size={15} />
) : (
<RiSortDesc size={15} />
)}
</>
)}
</Button>
{server?.type === ServerType.JELLYFIN && (
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.items.map((folder) => (
<DropdownMenu.Item
key={`musicFolder-${folder.id}`}
$isActive={filter.musicFolderId === folder.id}
value={folder.id}
onClick={handleSetMusicFolder}
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
)}
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiMoreFill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE_PAGINATED}
value={ListDisplayType.TABLE_PAGINATED}
onClick={handleSetViewType}
>
Table (paginated)
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>Item size</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
{display === ListDisplayType.CARD ||
display === ListDisplayType.POSTER ? (
<Slider
defaultValue={grid?.itemsPerRow}
label={null}
max={10}
min={2}
onChange={debouncedHandleItemSize}
/>
) : (
<Slider
defaultValue={table.rowHeight}
label={null}
max={100}
min={30}
onChange={debouncedHandleItemSize}
/>
)}
</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={ALBUMARTIST_TABLE_COLUMNS}
defaultValue={table?.columns.map(
(column) => column.column,
)}
width={300}
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Flex>
);
};

View file

@ -11,10 +11,10 @@ import { PageHeader, SearchInput } from '/@/renderer/components';
import { LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import {
AlbumArtistListFilter,
useAlbumArtistListStore,
useCurrentServer,
useListStoreActions,
AlbumArtistListFilter,
useAlbumArtistListStore,
useCurrentServer,
useListStoreActions,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
@ -24,156 +24,158 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { LibraryItem } from '/@/renderer/api/types';
interface AlbumArtistListHeaderProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const AlbumArtistListHeader = ({
itemCount,
gridRef,
tableRef,
itemCount,
gridRef,
tableRef,
}: AlbumArtistListHeaderProps) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const { pageKey } = useAlbumArtistListContext();
const { display, filter } = useAlbumArtistListStore();
const { setFilter, setTablePagination } = useListStoreActions();
const cq = useContainerQuery();
const fetch = useCallback(
async (startIndex: number, limit: number, filters: AlbumArtistListFilter) => {
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
limit,
startIndex,
...filters,
});
const albums = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
return albums;
},
[queryClient, server],
);
const handleFilterChange = useCallback(
async (filters: AlbumArtistListFilter) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryClient = useQueryClient();
const server = useCurrentServer();
const { pageKey } = useAlbumArtistListContext();
const { display, filter } = useAlbumArtistListStore();
const { setFilter, setTablePagination } = useListStoreActions();
const cq = useContainerQuery();
const fetch = useCallback(
async (startIndex: number, limit: number, filters: AlbumArtistListFilter) => {
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
limit,
startIndex,
...filters,
limit,
startIndex,
...filters,
});
const albumArtistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
const albums = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
albumArtistsRes?.items || [],
albumArtistsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
return albums;
},
[queryClient, server],
);
if (display === ListDisplayType.TABLE_PAGINATED) {
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
}
} else {
gridRef.current?.scrollTo(0);
gridRef.current?.resetLoadMoreItemsCache();
const handleFilterChange = useCallback(
async (filters: AlbumArtistListFilter) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
// Refetching within the virtualized grid may be inconsistent due to it refetching
// using an outdated set of filters. To avoid this, we fetch using the updated filters
// and then set the grid's data here.
const data = await fetch(0, 200, filters);
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
limit,
startIndex,
...filters,
});
if (!data?.items) return;
gridRef.current?.setItemData(data.items);
}
},
[display, tableRef, server, queryClient, setTablePagination, pageKey, gridRef, fetch],
);
const albumArtistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const previousSearchTerm = filter.searchTerm;
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
}, 500);
params.successCallback(
albumArtistsRes?.items || [],
albumArtistsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
return (
<Stack
ref={cq.ref}
spacing={0}
>
<PageHeader backgroundColor="var(--titlebar-bg)">
<Flex
justify="space-between"
w="100%"
if (display === ListDisplayType.TABLE_PAGINATED) {
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
}
} else {
gridRef.current?.scrollTo(0);
gridRef.current?.resetLoadMoreItemsCache();
// Refetching within the virtualized grid may be inconsistent due to it refetching
// using an outdated set of filters. To avoid this, we fetch using the updated filters
// and then set the grid's data here.
const data = await fetch(0, 200, filters);
if (!data?.items) return;
gridRef.current?.setItemData(data.items);
}
},
[display, tableRef, server, queryClient, setTablePagination, pageKey, gridRef, fetch],
);
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const previousSearchTerm = filter.searchTerm;
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
}, 500);
return (
<Stack
ref={cq.ref}
spacing={0}
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Album Artists</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge isLoading={itemCount === null || itemCount === undefined}>
{itemCount}
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
onChange={handleSearch}
/>
</Group>
</Flex>
</PageHeader>
<FilterBar>
<AlbumArtistListHeaderFilters
gridRef={gridRef}
tableRef={tableRef}
/>
</FilterBar>
</Stack>
);
<PageHeader backgroundColor="var(--titlebar-bg)">
<Flex
justify="space-between"
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Album Artists</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>
{itemCount}
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
onChange={handleSearch}
/>
</Group>
</Flex>
</PageHeader>
<FilterBar>
<AlbumArtistListHeaderFilters
gridRef={gridRef}
tableRef={tableRef}
/>
</FilterBar>
</Stack>
);
};

View file

@ -2,10 +2,10 @@ import { createContext, useContext } from 'react';
import { ListKey } from '/@/renderer/store';
export const AlbumArtistDetailSongListContext = createContext<{ id?: string; pageKey: ListKey }>({
pageKey: 'albumArtist',
pageKey: 'albumArtist',
});
export const useAlbumArtistDetailSongListContext = () => {
const ctxValue = useContext(AlbumArtistDetailSongListContext);
return ctxValue;
const ctxValue = useContext(AlbumArtistDetailSongListContext);
return ctxValue;
};

View file

@ -2,10 +2,10 @@ import { createContext, useContext } from 'react';
import { ListKey } from '/@/renderer/store';
export const AlbumArtistListContext = createContext<{ id?: string; pageKey: ListKey }>({
pageKey: 'albumArtist',
pageKey: 'albumArtist',
});
export const useAlbumArtistListContext = () => {
const ctxValue = useContext(AlbumArtistListContext);
return ctxValue;
const ctxValue = useContext(AlbumArtistListContext);
return ctxValue;
};

View file

@ -6,16 +6,19 @@ import { api } from '/@/renderer/api';
import { QueryHookArgs } from '../../../lib/react-query';
export const useAlbumArtistDetail = (args: QueryHookArgs<AlbumArtistDetailQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
enabled: !!server?.id && !!query.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumArtistDetail({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
...options,
});
return useQuery({
enabled: !!server?.id && !!query.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumArtistDetail({
apiClientProps: { server, signal },
query,
});
},
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
...options,
});
};

View file

@ -6,16 +6,16 @@ import { api } from '/@/renderer/api';
import { QueryHookArgs } from '../../../lib/react-query';
export const useAlbumArtistList = (args: QueryHookArgs<AlbumArtistListQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumArtistList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albumArtists.list(server?.id || '', query),
...options,
});
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumArtistList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albumArtists.list(server?.id || '', query),
...options,
});
};

View file

@ -6,16 +6,19 @@ import { api } from '/@/renderer/api';
import { QueryHookArgs } from '../../../lib/react-query';
export const useAlbumArtistInfo = (args: QueryHookArgs<AlbumArtistDetailQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
enabled: !!server?.id && !!query.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumArtistDetail({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
...options,
});
return useQuery({
enabled: !!server?.id && !!query.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumArtistDetail({
apiClientProps: { server, signal },
query,
});
},
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
...options,
});
};

View file

@ -6,16 +6,16 @@ import { getServerById } from '/@/renderer/store';
import { api } from '/@/renderer/api';
export const useTopSongsList = (args: QueryHookArgs<TopSongListQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getTopSongList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query),
...options,
});
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getTopSongList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query),
...options,
});
};

View file

@ -12,51 +12,56 @@ import { AlbumArtistDetailContent } from '/@/renderer/features/artists/component
import { useCurrentServer } from '/@/renderer/store';
const AlbumArtistDetailRoute = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
const { albumArtistId } = useParams() as { albumArtistId: string };
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const detailQuery = useAlbumArtistDetail({ query: { id: albumArtistId }, serverId: server?.id });
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumArtistId],
type: LibraryItem.ALBUM_ARTIST,
},
playType: playButtonBehavior,
const { albumArtistId } = useParams() as { albumArtistId: string };
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
serverId: server?.id,
});
};
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
if (detailQuery.isLoading || !background) return null;
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumArtistId],
type: LibraryItem.ALBUM_ARTIST,
},
playType: playButtonBehavior,
});
};
return (
<AnimatedPage key={`album-artist-detail-${albumArtistId}`}>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: background,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
target: headerRef,
}}
>
<AlbumArtistDetailHeader
ref={headerRef}
background={background}
/>
<AlbumArtistDetailContent />
</NativeScrollArea>
</AnimatedPage>
);
if (detailQuery.isLoading || !background) return null;
return (
<AnimatedPage key={`album-artist-detail-${albumArtistId}`}>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: background,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
<LibraryHeaderBar.Title>
{detailQuery?.data?.name}
</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
target: headerRef,
}}
>
<AlbumArtistDetailHeader
ref={headerRef}
background={background}
/>
<AlbumArtistDetailContent />
</NativeScrollArea>
</AnimatedPage>
);
};
export default AlbumArtistDetailRoute;

View file

@ -9,35 +9,38 @@ import { AnimatedPage } from '/@/renderer/features/shared';
import { useCurrentServer } from '../../../store/auth.store';
const AlbumArtistDetailTopSongsListRoute = () => {
const tableRef = useRef<AgGridReactType | null>(null);
const { albumArtistId } = useParams() as { albumArtistId: string };
const server = useCurrentServer();
const tableRef = useRef<AgGridReactType | null>(null);
const { albumArtistId } = useParams() as { albumArtistId: string };
const server = useCurrentServer();
const detailQuery = useAlbumArtistDetail({ query: { id: albumArtistId }, serverId: server?.id });
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
serverId: server?.id,
});
const topSongsQuery = useTopSongsList({
options: { enabled: !!detailQuery?.data?.name },
query: { artist: detailQuery?.data?.name || '', artistId: albumArtistId },
serverId: server?.id,
});
const topSongsQuery = useTopSongsList({
options: { enabled: !!detailQuery?.data?.name },
query: { artist: detailQuery?.data?.name || '', artistId: albumArtistId },
serverId: server?.id,
});
const itemCount = topSongsQuery?.data?.items?.length || 0;
const itemCount = topSongsQuery?.data?.items?.length || 0;
if (detailQuery.isLoading || topSongsQuery?.isLoading) return null;
if (detailQuery.isLoading || topSongsQuery?.isLoading) return null;
return (
<AnimatedPage>
<AlbumArtistDetailTopSongsListHeader
data={topSongsQuery?.data?.items || []}
itemCount={itemCount}
title={detailQuery?.data?.name || 'Unknown'}
/>
<AlbumArtistDetailTopSongsListContent
data={topSongsQuery?.data?.items || []}
tableRef={tableRef}
/>
</AnimatedPage>
);
return (
<AnimatedPage>
<AlbumArtistDetailTopSongsListHeader
data={topSongsQuery?.data?.items || []}
itemCount={itemCount}
title={detailQuery?.data?.name || 'Unknown'}
/>
<AlbumArtistDetailTopSongsListContent
data={topSongsQuery?.data?.items || []}
tableRef={tableRef}
/>
</AnimatedPage>
);
};
export default AlbumArtistDetailTopSongsListRoute;

View file

@ -10,46 +10,46 @@ import { useCurrentServer } from '../../../store/auth.store';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
const AlbumArtistListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const pageKey = generatePageKey('albumArtist', undefined);
const server = useCurrentServer();
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const pageKey = generatePageKey('albumArtist', undefined);
const server = useCurrentServer();
const albumArtistListFilter = useAlbumArtistListFilter({ id: undefined, key: pageKey });
const albumArtistListFilter = useAlbumArtistListFilter({ id: undefined, key: pageKey });
const itemCountCheck = useAlbumArtistList({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 1,
startIndex: 0,
...albumArtistListFilter,
},
serverId: server?.id,
});
const itemCountCheck = useAlbumArtistList({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 1,
startIndex: 0,
...albumArtistListFilter,
},
serverId: server?.id,
});
const itemCount =
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const itemCount =
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
return (
<AnimatedPage>
<AlbumArtistListContext.Provider value={{ id: undefined, pageKey }}>
<AlbumArtistListHeader
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<AlbumArtistListContent
gridRef={gridRef}
tableRef={tableRef}
/>
</AlbumArtistListContext.Provider>
</AnimatedPage>
);
return (
<AnimatedPage>
<AlbumArtistListContext.Provider value={{ id: undefined, pageKey }}>
<AlbumArtistListHeader
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<AlbumArtistListContent
gridRef={gridRef}
tableRef={tableRef}
/>
</AlbumArtistListContext.Provider>
</AnimatedPage>
);
};
export default AlbumArtistListRoute;

View file

@ -1,70 +1,70 @@
import { SetContextMenuItems } from '/@/renderer/features/context-menu/events';
export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'removeFromQueue' },
{ id: 'moveToBottomOfQueue' },
{ divider: true, id: 'moveToTopOfQueue' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ disabled: false, id: 'deselectAll' },
{ divider: true, id: 'removeFromQueue' },
{ id: 'moveToBottomOfQueue' },
{ divider: true, id: 'moveToTopOfQueue' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ disabled: false, id: 'deselectAll' },
];
export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
];
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'addToPlaylist' },
{ divider: true, id: 'removeFromPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'addToPlaylist' },
{ divider: true, id: 'removeFromPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
];
export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
];
export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
];
export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
];
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'deletePlaylist' },
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ id: 'deletePlaylist' },
];

File diff suppressed because it is too large Load diff

View file

@ -3,47 +3,47 @@ import { createUseExternalEvents } from '@mantine/utils';
import { LibraryItem } from '/@/renderer/api/types';
export type OpenContextMenuProps = {
context?: any;
data: any[];
dataNodes?: RowNode[];
menuItems: SetContextMenuItems;
tableApi?: GridOptions['api'];
type: LibraryItem;
xPos: number;
yPos: number;
context?: any;
data: any[];
dataNodes?: RowNode[];
menuItems: SetContextMenuItems;
tableApi?: GridOptions['api'];
type: LibraryItem;
xPos: number;
yPos: number;
};
export type ContextMenuEvents = {
closeContextMenu: () => void;
openContextMenu: (args: OpenContextMenuProps) => void;
closeContextMenu: () => void;
openContextMenu: (args: OpenContextMenuProps) => void;
};
export type ContextMenuItemType =
| 'play'
| 'playLast'
| 'playNext'
| 'addToPlaylist'
| 'removeFromPlaylist'
| 'addToFavorites'
| 'removeFromFavorites'
| 'setRating'
| 'deletePlaylist'
| 'createPlaylist'
| 'moveToBottomOfQueue'
| 'moveToTopOfQueue'
| 'removeFromQueue'
| 'deselectAll';
| 'play'
| 'playLast'
| 'playNext'
| 'addToPlaylist'
| 'removeFromPlaylist'
| 'addToFavorites'
| 'removeFromFavorites'
| 'setRating'
| 'deletePlaylist'
| 'createPlaylist'
| 'moveToBottomOfQueue'
| 'moveToTopOfQueue'
| 'removeFromQueue'
| 'deselectAll';
export type SetContextMenuItems = {
children?: boolean;
disabled?: boolean;
divider?: boolean;
id: ContextMenuItemType;
onClick?: () => void;
children?: boolean;
disabled?: boolean;
divider?: boolean;
id: ContextMenuItemType;
onClick?: () => void;
}[];
export const [useContextMenuEvents, createEvent] =
createUseExternalEvents<ContextMenuEvents>('context-menu');
createUseExternalEvents<ContextMenuEvents>('context-menu');
export const openContextMenu = createEvent('openContextMenu');
export const closeContextMenu = createEvent('closeContextMenu');

View file

@ -4,69 +4,71 @@ import { Album, AlbumArtist, Artist, LibraryItem, QueueSong, Song } from '/@/ren
import { openContextMenu, SetContextMenuItems } from '/@/renderer/features/context-menu/events';
export const useHandleTableContextMenu = (
itemType: LibraryItem,
contextMenuItems: SetContextMenuItems,
context?: any,
itemType: LibraryItem,
contextMenuItems: SetContextMenuItems,
context?: any,
) => {
const handleContextMenu = (e: CellContextMenuEvent) => {
if (!e.event) return;
const clickEvent = e.event as MouseEvent;
clickEvent.preventDefault();
const handleContextMenu = (e: CellContextMenuEvent) => {
if (!e.event) return;
const clickEvent = e.event as MouseEvent;
clickEvent.preventDefault();
let selectedNodes = sortBy(e.api.getSelectedNodes(), ['rowIndex']);
let selectedRows = selectedNodes.map((node) => node.data);
let selectedNodes = sortBy(e.api.getSelectedNodes(), ['rowIndex']);
let selectedRows = selectedNodes.map((node) => node.data);
if (!e.data?.id) {
return;
}
if (!e.data?.id) {
return;
}
const shouldReplaceSelected = !selectedNodes.map((node) => node.data.id).includes(e.data.id);
const shouldReplaceSelected = !selectedNodes
.map((node) => node.data.id)
.includes(e.data.id);
if (shouldReplaceSelected) {
e.api.deselectAll();
e.node.setSelected(true);
selectedRows = [e.data];
selectedNodes = e.api.getSelectedNodes();
}
if (shouldReplaceSelected) {
e.api.deselectAll();
e.node.setSelected(true);
selectedRows = [e.data];
selectedNodes = e.api.getSelectedNodes();
}
openContextMenu({
context,
data: selectedRows,
dataNodes: selectedNodes,
menuItems: contextMenuItems,
tableApi: e.api,
type: itemType,
xPos: clickEvent.clientX,
yPos: clickEvent.clientY,
});
};
openContextMenu({
context,
data: selectedRows,
dataNodes: selectedNodes,
menuItems: contextMenuItems,
tableApi: e.api,
type: itemType,
xPos: clickEvent.clientX,
yPos: clickEvent.clientY,
});
};
return handleContextMenu;
return handleContextMenu;
};
export const useHandleGeneralContextMenu = (
itemType: LibraryItem,
contextMenuItems: SetContextMenuItems,
context?: any,
itemType: LibraryItem,
contextMenuItems: SetContextMenuItems,
context?: any,
) => {
const handleContextMenu = (
e: any,
data: Song[] | QueueSong[] | AlbumArtist[] | Artist[] | Album[],
) => {
if (!e) return;
const clickEvent = e as MouseEvent;
clickEvent.preventDefault();
const handleContextMenu = (
e: any,
data: Song[] | QueueSong[] | AlbumArtist[] | Artist[] | Album[],
) => {
if (!e) return;
const clickEvent = e as MouseEvent;
clickEvent.preventDefault();
openContextMenu({
context,
data,
dataNodes: undefined,
menuItems: contextMenuItems,
type: itemType,
xPos: clickEvent.clientX + 15,
yPos: clickEvent.clientY + 5,
});
};
openContextMenu({
context,
data,
dataNodes: undefined,
menuItems: contextMenuItems,
type: itemType,
xPos: clickEvent.clientX + 15,
yPos: clickEvent.clientY + 5,
});
};
return handleContextMenu;
return handleContextMenu;
};

View file

@ -6,18 +6,18 @@ import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useGenreList = (args: QueryHookArgs<GenreListQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
cacheTime: 1000 * 60 * 60 * 2,
enabled: !!server,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return controller.getGenreList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.genres.list(server?.id || ''),
staleTime: 1000 * 60 * 60,
...options,
});
return useQuery({
cacheTime: 1000 * 60 * 60 * 2,
enabled: !!server,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return controller.getGenreList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.genres.list(server?.id || ''),
staleTime: 1000 * 60 * 60,
...options,
});
};

View file

@ -6,31 +6,31 @@ import { getServerById } from '/@/renderer/store';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
export const useRecentlyPlayed = (args: QueryHookArgs<Partial<AlbumListQuery>>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
const { options, query, serverId } = args;
const server = getServerById(serverId);
const requestQuery: AlbumListQuery = {
limit: 5,
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
const requestQuery: AlbumListQuery = {
limit: 5,
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumList({
apiClientProps: {
server,
signal,
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query: requestQuery,
});
},
query: requestQuery,
});
},
queryKey: queryKeys.albums.list(server?.id || '', requestQuery),
...options,
});
queryKey: queryKeys.albums.list(server?.id || '', requestQuery),
...options,
});
};

View file

@ -11,186 +11,191 @@ import { SwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import { Platform } from '/@/renderer/types';
const HomeRoute = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
const itemsPerPage = 15;
const { windowBarStyle } = useWindowSettings();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
const itemsPerPage = 15;
const { windowBarStyle } = useWindowSettings();
const feature = useAlbumList({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 20,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const feature = useAlbumList({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 20,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const featureItemsWithImage = useMemo(() => {
return feature.data?.items?.filter((item) => item.imageUrl) ?? [];
}, [feature.data?.items]);
const featureItemsWithImage = useMemo(() => {
return feature.data?.items?.filter((item) => item.imageUrl) ?? [];
}, [feature.data?.items]);
const random = useAlbumList({
options: {
staleTime: 1000 * 60 * 5,
},
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
});
const random = useAlbumList({
options: {
staleTime: 1000 * 60 * 5,
},
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
});
const recentlyPlayed = useRecentlyPlayed({
options: {
staleTime: 0,
},
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const recentlyPlayed = useRecentlyPlayed({
options: {
staleTime: 0,
},
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const recentlyAdded = useAlbumList({
options: {
staleTime: 0,
},
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const recentlyAdded = useAlbumList({
options: {
staleTime: 0,
},
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const mostPlayed = useAlbumList({
options: {
staleTime: 0,
},
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const mostPlayed = useAlbumList({
options: {
staleTime: 0,
},
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const isLoading =
random.isFetching ||
recentlyPlayed.isFetching ||
recentlyAdded.isFetching ||
mostPlayed.isFetching;
const isLoading =
random.isFetching ||
recentlyPlayed.isFetching ||
recentlyAdded.isFetching ||
mostPlayed.isFetching;
if (isLoading) {
return <Spinner container />;
}
if (isLoading) {
return <Spinner container />;
}
const carousels = [
{
data: random?.data?.items,
title: 'Explore from your library',
uniqueId: 'random',
},
{
data: recentlyPlayed?.data?.items,
pagination: {
itemsPerPage,
},
title: 'Recently played',
uniqueId: 'recentlyPlayed',
},
{
data: recentlyAdded?.data?.items,
pagination: {
itemsPerPage,
},
title: 'Newly added releases',
uniqueId: 'recentlyAdded',
},
{
data: mostPlayed?.data?.items,
pagination: {
itemsPerPage,
},
title: 'Most played',
uniqueId: 'mostPlayed',
},
];
const carousels = [
{
data: random?.data?.items,
title: 'Explore from your library',
uniqueId: 'random',
},
{
data: recentlyPlayed?.data?.items,
pagination: {
itemsPerPage,
},
title: 'Recently played',
uniqueId: 'recentlyPlayed',
},
{
data: recentlyAdded?.data?.items,
pagination: {
itemsPerPage,
},
title: 'Newly added releases',
uniqueId: 'recentlyAdded',
},
{
data: mostPlayed?.data?.items,
pagination: {
itemsPerPage,
},
title: 'Most played',
uniqueId: 'mostPlayed',
},
];
return (
<AnimatedPage>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: 'var(--titlebar-bg)',
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Home</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
offset: ['0px', '200px'],
}}
>
<Stack
mb="5rem"
pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'}
px="2rem"
spacing="lg"
>
<FeatureCarousel data={featureItemsWithImage} />
{carousels
.filter((carousel) => {
if (server?.type === ServerType.JELLYFIN && carousel.uniqueId === 'recentlyPlayed') {
return null;
}
return carousel;
})
.map((carousel) => (
<SwiperGridCarousel
key={`carousel-${carousel.uniqueId}`}
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
]}
data={carousel.data}
itemType={LibraryItem.ALBUM}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
return (
<AnimatedPage>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: 'var(--titlebar-bg)',
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Home</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
offset: ['0px', '200px'],
}}
title={{ label: carousel.title }}
uniqueId={carousel.uniqueId}
/>
))}
</Stack>
</NativeScrollArea>
</AnimatedPage>
);
>
<Stack
mb="5rem"
pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'}
px="2rem"
spacing="lg"
>
<FeatureCarousel data={featureItemsWithImage} />
{carousels
.filter((carousel) => {
if (
server?.type === ServerType.JELLYFIN &&
carousel.uniqueId === 'recentlyPlayed'
) {
return null;
}
return carousel;
})
.map((carousel) => (
<SwiperGridCarousel
key={`carousel-${carousel.uniqueId}`}
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [
{ idProperty: 'id', slugProperty: 'albumArtistId' },
],
},
},
]}
data={carousel.data}
itemType={LibraryItem.ALBUM}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
title={{ label: carousel.title }}
uniqueId={carousel.uniqueId}
/>
))}
</Stack>
</NativeScrollArea>
</AnimatedPage>
);
};
export default HomeRoute;

View file

@ -6,170 +6,170 @@ import { openModal } from '@mantine/modals';
import orderBy from 'lodash/orderBy';
import styled from 'styled-components';
import {
InternetProviderLyricSearchResponse,
LyricSource,
LyricsOverride,
InternetProviderLyricSearchResponse,
LyricSource,
LyricsOverride,
} from '../../../api/types';
import { useLyricSearch } from '../queries/lyric-search-query';
import { ScrollArea, Spinner, Text, TextInput } from '/@/renderer/components';
const SearchItem = styled.button`
all: unset;
box-sizing: border-box !important;
padding: 0.5rem;
border-radius: 5px;
cursor: pointer;
all: unset;
box-sizing: border-box !important;
padding: 0.5rem;
border-radius: 5px;
cursor: pointer;
&:hover,
&:focus-visible {
color: var(--btn-default-fg-hover);
background: var(--btn-default-bg-hover);
}
&:hover,
&:focus-visible {
color: var(--btn-default-fg-hover);
background: var(--btn-default-bg-hover);
}
`;
interface SearchResultProps {
data: InternetProviderLyricSearchResponse;
onClick?: () => void;
data: InternetProviderLyricSearchResponse;
onClick?: () => void;
}
const SearchResult = ({ data, onClick }: SearchResultProps) => {
const { artist, name, source, score, id } = data;
const { artist, name, source, score, id } = data;
const percentageScore = useMemo(() => {
if (!score) return 0;
return ((1 - score) * 100).toFixed(2);
}, [score]);
const percentageScore = useMemo(() => {
if (!score) return 0;
return ((1 - score) * 100).toFixed(2);
}, [score]);
const cleanId =
source === LyricSource.GENIUS ? id.replace(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)/g, '') : id;
const cleanId =
source === LyricSource.GENIUS ? id.replace(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)/g, '') : id;
return (
<SearchItem onClick={onClick}>
<Group
noWrap
position="apart"
>
<Stack
maw="65%"
spacing={0}
>
<Text
size="md"
weight={600}
>
{name}
</Text>
<Text $secondary>{artist}</Text>
<Group
noWrap
spacing="sm"
>
<Text
$secondary
size="sm"
return (
<SearchItem onClick={onClick}>
<Group
noWrap
position="apart"
>
{[source, cleanId].join(' — ')}
</Text>
</Group>
</Stack>
<Text>{percentageScore}%</Text>
</Group>
</SearchItem>
);
<Stack
maw="65%"
spacing={0}
>
<Text
size="md"
weight={600}
>
{name}
</Text>
<Text $secondary>{artist}</Text>
<Group
noWrap
spacing="sm"
>
<Text
$secondary
size="sm"
>
{[source, cleanId].join(' — ')}
</Text>
</Group>
</Stack>
<Text>{percentageScore}%</Text>
</Group>
</SearchItem>
);
};
interface LyricSearchFormProps {
artist?: string;
name?: string;
onSearchOverride?: (params: LyricsOverride) => void;
artist?: string;
name?: string;
onSearchOverride?: (params: LyricsOverride) => void;
}
export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {
const form = useForm({
initialValues: {
artist: artist || '',
name: name || '',
},
});
const [debouncedArtist] = useDebouncedValue(form.values.artist, 500);
const [debouncedName] = useDebouncedValue(form.values.name, 500);
const { data, isInitialLoading } = useLyricSearch({
query: { artist: debouncedArtist, name: debouncedName },
});
const searchResults = useMemo(() => {
if (!data) return [];
const results: InternetProviderLyricSearchResponse[] = [];
Object.keys(data).forEach((key) => {
(data[key as keyof typeof data] || []).forEach((result) => results.push(result));
const form = useForm({
initialValues: {
artist: artist || '',
name: name || '',
},
});
const scoredResults = orderBy(results, ['score'], ['asc']);
const [debouncedArtist] = useDebouncedValue(form.values.artist, 500);
const [debouncedName] = useDebouncedValue(form.values.name, 500);
return scoredResults;
}, [data]);
const { data, isInitialLoading } = useLyricSearch({
query: { artist: debouncedArtist, name: debouncedName },
});
return (
<Stack w="100%">
<form>
<Group grow>
<TextInput
data-autofocus
label="Name"
{...form.getInputProps('name')}
/>
<TextInput
label="Artist"
{...form.getInputProps('artist')}
/>
</Group>
</form>
<Divider />
{isInitialLoading ? (
<Spinner container />
) : (
<ScrollArea
offsetScrollbars
h={400}
pr="1rem"
type="auto"
w="100%"
>
<Stack spacing="md">
{searchResults.map((result) => (
<SearchResult
key={`${result.source}-${result.id}`}
data={result}
onClick={() => {
onSearchOverride?.({
artist: result.artist,
id: result.id,
name: result.name,
remote: true,
source: result.source as LyricSource,
});
}}
/>
))}
</Stack>
</ScrollArea>
)}
</Stack>
);
const searchResults = useMemo(() => {
if (!data) return [];
const results: InternetProviderLyricSearchResponse[] = [];
Object.keys(data).forEach((key) => {
(data[key as keyof typeof data] || []).forEach((result) => results.push(result));
});
const scoredResults = orderBy(results, ['score'], ['asc']);
return scoredResults;
}, [data]);
return (
<Stack w="100%">
<form>
<Group grow>
<TextInput
data-autofocus
label="Name"
{...form.getInputProps('name')}
/>
<TextInput
label="Artist"
{...form.getInputProps('artist')}
/>
</Group>
</form>
<Divider />
{isInitialLoading ? (
<Spinner container />
) : (
<ScrollArea
offsetScrollbars
h={400}
pr="1rem"
type="auto"
w="100%"
>
<Stack spacing="md">
{searchResults.map((result) => (
<SearchResult
key={`${result.source}-${result.id}`}
data={result}
onClick={() => {
onSearchOverride?.({
artist: result.artist,
id: result.id,
name: result.name,
remote: true,
source: result.source as LyricSource,
});
}}
/>
))}
</Stack>
</ScrollArea>
)}
</Stack>
);
};
export const openLyricSearchModal = ({ artist, name, onSearchOverride }: LyricSearchFormProps) => {
openModal({
children: (
<LyricsSearchForm
artist={artist}
name={name}
onSearchOverride={onSearchOverride}
/>
),
size: 'lg',
title: 'Lyrics Search',
});
openModal({
children: (
<LyricsSearchForm
artist={artist}
name={name}
onSearchOverride={onSearchOverride}
/>
),
size: 'lg',
title: 'Lyrics Search',
});
};

View file

@ -3,29 +3,29 @@ import { TextTitle } from '/@/renderer/components/text-title';
import styled from 'styled-components';
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
text: string;
text: string;
}
const StyledText = styled(TextTitle)`
color: var(--main-fg);
font-weight: 400;
font-size: 2.5vmax;
transform: scale(0.95);
opacity: 0.5;
color: var(--main-fg);
font-weight: 400;
font-size: 2.5vmax;
transform: scale(0.95);
opacity: 0.5;
&.active {
font-weight: 800;
transform: scale(1);
opacity: 1;
}
&.active {
font-weight: 800;
transform: scale(1);
opacity: 1;
}
&.active.unsynchronized {
opacity: 0.8;
}
&.active.unsynchronized {
opacity: 0.8;
}
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
`;
export const LyricLine = ({ text, ...props }: LyricLineProps) => {
return <StyledText {...props}>{text}</StyledText>;
return <StyledText {...props}>{text}</StyledText>;
};

View file

@ -4,88 +4,88 @@ import { LyricsOverride } from '/@/renderer/api/types';
import { Button, NumberInput, Tooltip } from '/@/renderer/components';
import { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form';
import {
useCurrentSong,
useLyricsSettings,
useSettingsStore,
useSettingsStoreActions,
useCurrentSong,
useLyricsSettings,
useSettingsStore,
useSettingsStoreActions,
} from '/@/renderer/store';
interface LyricsActionsProps {
onRemoveLyric: () => void;
onSearchOverride: (params: LyricsOverride) => void;
onRemoveLyric: () => void;
onSearchOverride: (params: LyricsOverride) => void;
}
export const LyricsActions = ({ onRemoveLyric, onSearchOverride }: LyricsActionsProps) => {
const currentSong = useCurrentSong();
const { setSettings } = useSettingsStoreActions();
const { delayMs, sources } = useLyricsSettings();
const currentSong = useCurrentSong();
const { setSettings } = useSettingsStoreActions();
const { delayMs, sources } = useLyricsSettings();
const handleLyricOffset = (e: number) => {
setSettings({
lyrics: {
...useSettingsStore.getState().lyrics,
delayMs: e,
},
});
};
const handleLyricOffset = (e: number) => {
setSettings({
lyrics: {
...useSettingsStore.getState().lyrics,
delayMs: e,
},
});
};
const isActionsDisabled = !currentSong;
const isDesktop = isElectron();
const isActionsDisabled = !currentSong;
const isDesktop = isElectron();
return (
<>
{isDesktop && sources.length ? (
<Button
uppercase
disabled={isActionsDisabled}
variant="subtle"
onClick={() =>
openLyricSearchModal({
artist: currentSong?.artistName,
name: currentSong?.name,
onSearchOverride,
})
}
>
Search
</Button>
) : null}
<Button
aria-label="Decrease lyric offset"
variant="subtle"
onClick={() => handleLyricOffset(delayMs - 50)}
>
<RiSubtractFill />
</Button>
<Tooltip
label="Offset (ms)"
openDelay={500}
>
<NumberInput
aria-label="Lyric offset"
styles={{ input: { textAlign: 'center' } }}
value={delayMs || 0}
width={55}
onChange={handleLyricOffset}
/>
</Tooltip>
<Button
aria-label="Increase lyric offset"
variant="subtle"
onClick={() => handleLyricOffset(delayMs + 50)}
>
<RiAddFill />
</Button>
{isDesktop && sources.length ? (
<Button
uppercase
disabled={isActionsDisabled}
variant="subtle"
onClick={onRemoveLyric}
>
Clear
</Button>
) : null}
</>
);
return (
<>
{isDesktop && sources.length ? (
<Button
uppercase
disabled={isActionsDisabled}
variant="subtle"
onClick={() =>
openLyricSearchModal({
artist: currentSong?.artistName,
name: currentSong?.name,
onSearchOverride,
})
}
>
Search
</Button>
) : null}
<Button
aria-label="Decrease lyric offset"
variant="subtle"
onClick={() => handleLyricOffset(delayMs - 50)}
>
<RiSubtractFill />
</Button>
<Tooltip
label="Offset (ms)"
openDelay={500}
>
<NumberInput
aria-label="Lyric offset"
styles={{ input: { textAlign: 'center' } }}
value={delayMs || 0}
width={55}
onChange={handleLyricOffset}
/>
</Tooltip>
<Button
aria-label="Increase lyric offset"
variant="subtle"
onClick={() => handleLyricOffset(delayMs + 50)}
>
<RiAddFill />
</Button>
{isDesktop && sources.length ? (
<Button
uppercase
disabled={isActionsDisabled}
variant="subtle"
onClick={onRemoveLyric}
>
Clear
</Button>
) : null}
</>
);
};

View file

@ -11,196 +11,198 @@ import { ErrorFallback } from '/@/renderer/features/action-required';
import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { getServerById, useCurrentSong, usePlayerStore } from '/@/renderer/store';
import {
FullLyricsMetadata,
LyricsOverride,
SynchronizedLyricMetadata,
UnsynchronizedLyricMetadata,
FullLyricsMetadata,
LyricsOverride,
SynchronizedLyricMetadata,
UnsynchronizedLyricMetadata,
} from '/@/renderer/api/types';
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
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;
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;
}
&:hover {
opacity: 1 !important;
}
&:focus-within {
opacity: 1 !important;
}
&:focus-within {
opacity: 1 !important;
}
`;
const LyricsContainer = styled.div`
position: relative;
display: flex;
width: 100%;
height: 100%;
position: relative;
display: flex;
width: 100%;
height: 100%;
&:hover {
${ActionsContainer} {
opacity: 0.6;
&: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%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
&.mantine-ScrollArea-root {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
}
text-align: center;
& .mantine-ScrollArea-viewport {
height: 100% !important;
}
mask-image: linear-gradient(
180deg,
transparent 5%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
& .mantine-ScrollArea-viewport > div {
height: 100%;
}
&.mantine-ScrollArea-root {
width: 100%;
height: 100%;
}
& .mantine-ScrollArea-viewport {
height: 100% !important;
}
& .mantine-ScrollArea-viewport > div {
height: 100%;
}
`;
function isSynchronized(
data: Partial<FullLyricsMetadata> | undefined,
data: Partial<FullLyricsMetadata> | undefined,
): data is SynchronizedLyricMetadata {
// Type magic. The only difference between Synchronized and Unsynchhronized is
// the datatype of lyrics. This makes Typescript happier later...
if (!data) return false;
return Array.isArray(data.lyrics);
// Type magic. The only difference between Synchronized and Unsynchhronized is
// the datatype of lyrics. This makes Typescript happier later...
if (!data) return false;
return Array.isArray(data.lyrics);
}
export const Lyrics = () => {
const currentSong = useCurrentSong();
const currentServer = getServerById(currentSong?.serverId);
const currentSong = useCurrentSong();
const currentServer = getServerById(currentSong?.serverId);
const [clear, setClear] = useState(false);
const [clear, setClear] = useState(false);
const { data, isInitialLoading } = useSongLyricsBySong(
{
query: { songId: currentSong?.id || '' },
serverId: currentServer?.id,
},
currentSong,
);
const [override, setOverride] = useState<LyricsOverride | undefined>(undefined);
const handleOnSearchOverride = useCallback((params: LyricsOverride) => {
setOverride(params);
setClear(false);
}, []);
const { data: overrideLyrics, isInitialLoading: isOverrideLoading } = useSongLyricsByRemoteId({
options: {
enabled: !!override,
},
query: {
remoteSongId: override?.id,
remoteSource: override?.source,
},
serverId: currentServer?.id,
});
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state) => state.current.song,
() => {
setOverride(undefined);
setClear(false);
},
{ equalityFn: (a, b) => a?.id === b?.id },
const { data, isInitialLoading } = useSongLyricsBySong(
{
query: { songId: currentSong?.id || '' },
serverId: currentServer?.id,
},
currentSong,
);
return () => {
unsubSongChange();
};
}, []);
const [override, setOverride] = useState<LyricsOverride | undefined>(undefined);
const isLoadingLyrics = isInitialLoading || isOverrideLoading;
const handleOnSearchOverride = useCallback((params: LyricsOverride) => {
setOverride(params);
setClear(false);
}, []);
const hasNoLyrics = (!data?.lyrics && !overrideLyrics) || clear;
const { data: overrideLyrics, isInitialLoading: isOverrideLoading } = useSongLyricsByRemoteId({
options: {
enabled: !!override,
},
query: {
remoteSongId: override?.id,
remoteSource: override?.source,
},
serverId: currentServer?.id,
});
const lyricsMetadata:
| Partial<SynchronizedLyricMetadata>
| Partial<UnsynchronizedLyricMetadata>
| undefined = overrideLyrics
? {
artist: override?.artist,
lyrics: overrideLyrics,
name: override?.name,
remote: true,
source: override?.source,
}
: data;
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state) => state.current.song,
() => {
setOverride(undefined);
setClear(false);
},
{ equalityFn: (a, b) => a?.id === b?.id },
);
const isSynchronizedLyrics = isSynchronized(lyricsMetadata);
return () => {
unsubSongChange();
};
}, []);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<LyricsContainer>
{isLoadingLyrics ? (
<Spinner
container
size={25}
/>
) : (
<AnimatePresence mode="sync">
{hasNoLyrics ? (
<Center w="100%">
<Group>
<RiInformationFill size="2rem" />
<TextTitle
order={3}
weight={700}
>
No lyrics found
</TextTitle>
</Group>
</Center>
) : (
<ScrollContainer
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
{isSynchronizedLyrics ? (
<SynchronizedLyrics {...lyricsMetadata} />
const isLoadingLyrics = isInitialLoading || isOverrideLoading;
const hasNoLyrics = (!data?.lyrics && !overrideLyrics) || clear;
const lyricsMetadata:
| Partial<SynchronizedLyricMetadata>
| Partial<UnsynchronizedLyricMetadata>
| undefined = overrideLyrics
? {
artist: override?.artist,
lyrics: overrideLyrics,
name: override?.name,
remote: true,
source: override?.source,
}
: data;
const isSynchronizedLyrics = isSynchronized(lyricsMetadata);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<LyricsContainer>
{isLoadingLyrics ? (
<Spinner
container
size={25}
/>
) : (
<UnsynchronizedLyrics {...(lyricsMetadata as UnsynchronizedLyricMetadata)} />
<AnimatePresence mode="sync">
{hasNoLyrics ? (
<Center w="100%">
<Group>
<RiInformationFill size="2rem" />
<TextTitle
order={3}
weight={700}
>
No lyrics found
</TextTitle>
</Group>
</Center>
) : (
<ScrollContainer
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
{isSynchronizedLyrics ? (
<SynchronizedLyrics {...lyricsMetadata} />
) : (
<UnsynchronizedLyrics
{...(lyricsMetadata as UnsynchronizedLyricMetadata)}
/>
)}
</ScrollContainer>
)}
</AnimatePresence>
)}
</ScrollContainer>
)}
</AnimatePresence>
)}
<ActionsContainer>
<LyricsActions
onRemoveLyric={() => setClear(true)}
onSearchOverride={handleOnSearchOverride}
/>
</ActionsContainer>
</LyricsContainer>
</ErrorBoundary>
);
<ActionsContainer>
<LyricsActions
onRemoveLyric={() => setClear(true)}
onSearchOverride={handleOnSearchOverride}
/>
</ActionsContainer>
</LyricsContainer>
</ErrorBoundary>
);
};

View file

@ -1,11 +1,11 @@
import { UseQueryResult, useQuery } from '@tanstack/react-query';
import {
LyricsQuery,
QueueSong,
SynchronizedLyricsArray,
InternetProviderLyricResponse,
FullLyricsMetadata,
LyricGetQuery,
LyricsQuery,
QueueSong,
SynchronizedLyricsArray,
InternetProviderLyricResponse,
FullLyricsMetadata,
LyricGetQuery,
} from '/@/renderer/api/types';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById, useLyricsSettings } from '/@/renderer/store';
@ -25,142 +25,144 @@ const timeExp = /\[(\d{2,}):(\d{2})(?:\.(\d{2,3}))?]([^\n]+)\n/g;
const alternateTimeExp = /\[(\d*),(\d*)]([^\n]+)\n/g;
const formatLyrics = (lyrics: string) => {
const synchronizedLines = lyrics.matchAll(timeExp);
const formattedLyrics: SynchronizedLyricsArray = [];
const synchronizedLines = lyrics.matchAll(timeExp);
const formattedLyrics: SynchronizedLyricsArray = [];
for (const line of synchronizedLines) {
const [, minute, sec, ms, text] = line;
const minutes = parseInt(minute, 10);
const seconds = parseInt(sec, 10);
const milis = ms?.length === 3 ? parseInt(ms, 10) : parseInt(ms, 10) * 10;
for (const line of synchronizedLines) {
const [, minute, sec, ms, text] = line;
const minutes = parseInt(minute, 10);
const seconds = parseInt(sec, 10);
const milis = ms?.length === 3 ? parseInt(ms, 10) : parseInt(ms, 10) * 10;
const timeInMilis = (minutes * 60 + seconds) * 1000 + milis;
const timeInMilis = (minutes * 60 + seconds) * 1000 + milis;
formattedLyrics.push([timeInMilis, text]);
}
formattedLyrics.push([timeInMilis, text]);
}
if (formattedLyrics.length > 0) return formattedLyrics;
if (formattedLyrics.length > 0) return formattedLyrics;
const alternateSynchronizedLines = lyrics.matchAll(alternateTimeExp);
for (const line of alternateSynchronizedLines) {
const [, timeInMilis, , text] = line;
const cleanText = text
.replaceAll(/\(\d+,\d+\)/g, '')
.replaceAll(/\s,/g, ',')
.replaceAll(/\s\./g, '.');
formattedLyrics.push([Number(timeInMilis), cleanText]);
}
const alternateSynchronizedLines = lyrics.matchAll(alternateTimeExp);
for (const line of alternateSynchronizedLines) {
const [, timeInMilis, , text] = line;
const cleanText = text
.replaceAll(/\(\d+,\d+\)/g, '')
.replaceAll(/\s,/g, ',')
.replaceAll(/\s\./g, '.');
formattedLyrics.push([Number(timeInMilis), cleanText]);
}
if (formattedLyrics.length > 0) return formattedLyrics;
if (formattedLyrics.length > 0) return formattedLyrics;
// If no synchronized lyrics were found, return the original lyrics
return lyrics;
// If no synchronized lyrics were found, return the original lyrics
return lyrics;
};
export const useServerLyrics = (
args: QueryHookArgs<LyricsQuery>,
args: QueryHookArgs<LyricsQuery>,
): UseQueryResult<string | null> => {
const { query, serverId } = args;
const server = getServerById(serverId);
const { query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
// Note: This currently fetches for every song, even if it shouldn't have
// lyrics, because for some reason HasLyrics is not exposed. Thus, ignore the error
onError: () => {},
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
// This should only be called for Jellyfin. Return null to ignore errors
if (server.type !== ServerType.JELLYFIN) return null;
return api.controller.getLyrics({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.songs.lyrics(server?.id || '', query),
});
return useQuery({
// Note: This currently fetches for every song, even if it shouldn't have
// lyrics, because for some reason HasLyrics is not exposed. Thus, ignore the error
onError: () => {},
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
// This should only be called for Jellyfin. Return null to ignore errors
if (server.type !== ServerType.JELLYFIN) return null;
return api.controller.getLyrics({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.songs.lyrics(server?.id || '', query),
});
};
export const useSongLyricsBySong = (
args: QueryHookArgs<LyricsQuery>,
song: QueueSong | undefined,
args: QueryHookArgs<LyricsQuery>,
song: QueueSong | undefined,
): UseQueryResult<FullLyricsMetadata> => {
const { query } = args;
const { fetch } = useLyricsSettings();
const server = getServerById(song?.serverId);
const { query } = args;
const { fetch } = useLyricsSettings();
const server = getServerById(song?.serverId);
return useQuery({
cacheTime: 1000 * 60 * 10,
enabled: !!song && !!server,
onError: () => {},
queryFn: async ({ signal }) => {
if (!server) throw new Error('Server not found');
if (!song) return null;
return useQuery({
cacheTime: 1000 * 60 * 10,
enabled: !!song && !!server,
onError: () => {},
queryFn: async ({ signal }) => {
if (!server) throw new Error('Server not found');
if (!song) return null;
if (song.lyrics) {
return {
artist: song.artists?.[0]?.name,
lyrics: formatLyrics(song.lyrics),
name: song.name,
remote: false,
source: server?.name ?? 'music server',
};
}
if (song.lyrics) {
return {
artist: song.artists?.[0]?.name,
lyrics: formatLyrics(song.lyrics),
name: song.name,
remote: false,
source: server?.name ?? 'music server',
};
}
if (server.type === ServerType.JELLYFIN) {
const jfLyrics = await api.controller
.getLyrics({
apiClientProps: { server, signal },
query: { songId: song.id },
})
.catch((err) => console.log(err));
if (server.type === ServerType.JELLYFIN) {
const jfLyrics = await api.controller
.getLyrics({
apiClientProps: { server, signal },
query: { songId: song.id },
})
.catch((err) => console.log(err));
if (jfLyrics) {
return {
artist: song.artists?.[0]?.name,
lyrics: jfLyrics,
name: song.name,
remote: false,
source: server?.name ?? 'music server',
};
}
}
if (jfLyrics) {
return {
artist: song.artists?.[0]?.name,
lyrics: jfLyrics,
name: song.name,
remote: false,
source: server?.name ?? 'music server',
};
}
}
if (fetch) {
const remoteLyricsResult: InternetProviderLyricResponse | null =
await lyricsIpc?.getRemoteLyricsBySong(song);
if (fetch) {
const remoteLyricsResult: InternetProviderLyricResponse | null =
await lyricsIpc?.getRemoteLyricsBySong(song);
if (remoteLyricsResult) {
return {
...remoteLyricsResult,
lyrics: formatLyrics(remoteLyricsResult.lyrics),
remote: true,
};
}
}
if (remoteLyricsResult) {
return {
...remoteLyricsResult,
lyrics: formatLyrics(remoteLyricsResult.lyrics),
remote: true,
};
}
}
return null;
},
queryKey: queryKeys.songs.lyrics(server?.id || '', query),
staleTime: 1000 * 60 * 2,
});
return null;
},
queryKey: queryKeys.songs.lyrics(server?.id || '', query),
staleTime: 1000 * 60 * 2,
});
};
export const useSongLyricsByRemoteId = (
args: QueryHookArgs<Partial<LyricGetQuery>>,
args: QueryHookArgs<Partial<LyricGetQuery>>,
): UseQueryResult<string | null> => {
const { query } = args;
const { query } = args;
return useQuery({
cacheTime: 1000 * 60 * 10,
enabled: !!query.remoteSongId && !!query.remoteSource,
onError: () => {},
queryFn: async () => {
const remoteLyricsResult: string | null = await lyricsIpc?.getRemoteLyricsByRemoteId(query);
return useQuery({
cacheTime: 1000 * 60 * 10,
enabled: !!query.remoteSongId && !!query.remoteSource,
onError: () => {},
queryFn: async () => {
const remoteLyricsResult: string | null = await lyricsIpc?.getRemoteLyricsByRemoteId(
query,
);
if (remoteLyricsResult) {
return formatLyrics(remoteLyricsResult);
}
if (remoteLyricsResult) {
return formatLyrics(remoteLyricsResult);
}
return null;
},
queryKey: queryKeys.songs.lyricsByRemoteId(query),
staleTime: 1000 * 60 * 5,
});
return null;
},
queryKey: queryKeys.songs.lyricsByRemoteId(query),
staleTime: 1000 * 60 * 5,
});
};

View file

@ -2,23 +2,23 @@ import { useQuery } from '@tanstack/react-query';
import isElectron from 'is-electron';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
InternetProviderLyricSearchResponse,
LyricSearchQuery,
LyricSource,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
LyricSource,
} from '/@/renderer/api/types';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
const lyricsIpc = isElectron() ? window.electron.lyrics : null;
export const useLyricSearch = (args: Omit<QueryHookArgs<LyricSearchQuery>, 'serverId'>) => {
const { options, query } = args;
const { options, query } = args;
return useQuery<Record<LyricSource, InternetProviderLyricSearchResponse[]>>({
cacheTime: 1000 * 60 * 1,
enabled: !!query.artist || !!query.name,
queryFn: () => lyricsIpc?.searchRemoteLyrics(query),
queryKey: queryKeys.songs.lyricsSearch(query),
staleTime: 1000 * 60 * 1,
...options,
});
return useQuery<Record<LyricSource, InternetProviderLyricSearchResponse[]>>({
cacheTime: 1000 * 60 * 1,
enabled: !!query.artist || !!query.name,
queryFn: () => lyricsIpc?.searchRemoteLyrics(query),
queryKey: queryKeys.songs.lyricsSearch(query),
staleTime: 1000 * 60 * 1,
...options,
});
};

View file

@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef } from 'react';
import {
useCurrentStatus,
useCurrentTime,
useLyricsSettings,
usePlayerType,
useSeeked,
useCurrentStatus,
useCurrentTime,
useLyricsSettings,
usePlayerType,
useSeeked,
} from '/@/renderer/store';
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
@ -16,303 +16,310 @@ import styled from 'styled-components';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const SynchronizedLyricsContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
height: 100%;
padding: 10vh 0 6vh;
overflow: scroll;
transform: translateY(-2rem);
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
height: 100%;
padding: 10vh 0 6vh;
overflow: scroll;
transform: translateY(-2rem);
mask-image: linear-gradient(
180deg,
transparent 5%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
mask-image: linear-gradient(
180deg,
transparent 5%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
@media screen and (max-width: 768px) {
padding: 5vh 0;
}
@media screen and (max-width: 768px) {
padding: 5vh 0;
}
`;
interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: SynchronizedLyricsArray;
lyrics: SynchronizedLyricsArray;
}
export const SynchronizedLyrics = ({
artist,
lyrics,
name,
remote,
source,
artist,
lyrics,
name,
remote,
source,
}: SynchronizedLyricsProps) => {
const playersRef = PlayersRef;
const status = useCurrentStatus();
const playerType = usePlayerType();
const now = useCurrentTime();
const settings = useLyricsSettings();
const playersRef = PlayersRef;
const status = useCurrentStatus();
const playerType = usePlayerType();
const now = useCurrentTime();
const settings = useLyricsSettings();
const seeked = useSeeked();
const seeked = useSeeked();
// A reference to the timeout handler
const lyricTimer = useRef<ReturnType<typeof setTimeout>>();
// A reference to the timeout handler
const lyricTimer = useRef<ReturnType<typeof setTimeout>>();
// A reference to the lyrics. This is necessary for the
// timers, which are not part of react necessarily, to always
// have the most updated values
const lyricRef = useRef<SynchronizedLyricsArray>();
// A reference to the lyrics. This is necessary for the
// timers, which are not part of react necessarily, to always
// have the most updated values
const lyricRef = useRef<SynchronizedLyricsArray>();
// A constantly increasing value, used to tell timers that may be out of date
// whether to proceed or stop
const timerEpoch = useRef(0);
// A constantly increasing value, used to tell timers that may be out of date
// whether to proceed or stop
const timerEpoch = useRef(0);
const delayMsRef = useRef(settings.delayMs);
const followRef = useRef(settings.follow);
const delayMsRef = useRef(settings.delayMs);
const followRef = useRef(settings.follow);
const getCurrentLyric = (timeInMs: number) => {
if (lyricRef.current) {
const activeLyrics = lyricRef.current;
for (let idx = 0; idx < activeLyrics.length; idx += 1) {
if (timeInMs <= activeLyrics[idx][0]) {
return idx === 0 ? idx : idx - 1;
}
}
const getCurrentLyric = (timeInMs: number) => {
if (lyricRef.current) {
const activeLyrics = lyricRef.current;
for (let idx = 0; idx < activeLyrics.length; idx += 1) {
if (timeInMs <= activeLyrics[idx][0]) {
return idx === 0 ? idx : idx - 1;
}
}
return activeLyrics.length - 1;
}
return -1;
};
const getCurrentTime = useCallback(async () => {
if (isElectron() && playerType !== PlaybackType.WEB) {
if (mpvPlayer) {
return mpvPlayer.getCurrentTime();
}
return 0;
}
if (playersRef.current === undefined) {
return 0;
}
const player = (playersRef.current.player1 ?? playersRef.current.player2).getInternalPlayer();
// If it is null, this probably means we added a new song while the lyrics tab is open
// and the queue was previously empty
if (!player) return 0;
return player.currentTime;
}, [playerType, playersRef]);
const setCurrentLyric = useCallback((timeInMs: number, epoch?: number, targetIndex?: number) => {
const start = performance.now();
let nextEpoch: number;
if (epoch === undefined) {
timerEpoch.current = (timerEpoch.current + 1) % 10000;
nextEpoch = timerEpoch.current;
} else if (epoch !== timerEpoch.current) {
return;
} else {
nextEpoch = epoch;
}
let index: number;
if (targetIndex === undefined) {
index = getCurrentLyric(timeInMs);
} else {
index = targetIndex;
}
// Directly modify the dom instead of using react to prevent rerender
document
.querySelectorAll('.synchronized-lyrics .active')
.forEach((node) => node.classList.remove('active'));
if (index === -1) {
lyricRef.current = undefined;
return;
}
const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
const currentLyric = document.querySelector(`#lyric-${index}`) as HTMLElement;
const offsetTop = currentLyric?.offsetTop - doc?.clientHeight / 2 ?? 0;
if (currentLyric === null) {
lyricRef.current = undefined;
return;
}
currentLyric.classList.add('active');
if (followRef.current) {
doc?.scroll({ behavior: 'smooth', top: offsetTop });
}
if (index !== lyricRef.current!.length - 1) {
const nextTime = lyricRef.current![index + 1][0];
const elapsed = performance.now() - start;
lyricTimer.current = setTimeout(() => {
setCurrentLyric(nextTime, nextEpoch, index + 1);
}, nextTime - timeInMs - elapsed);
}
}, []);
useEffect(() => {
// Copy the follow settings into a ref that can be accessed in the timeout
followRef.current = settings.follow;
}, [settings.follow]);
useEffect(() => {
// This handler is used to handle when lyrics change. It is in some sense the
// 'primary' handler for parsing lyrics, as unlike the other callbacks, it will
// ALSO remove listeners on close. Use the promisified getCurrentTime(), because
// we don't want to be dependent on npw, which may not be precise
lyricRef.current = lyrics;
if (status === PlayerStatus.PLAYING) {
let rejected = false;
getCurrentTime()
.then((timeInSec: number) => {
if (rejected) {
return false;
}
setCurrentLyric(timeInSec * 1000 - delayMsRef.current);
return true;
})
.catch(console.error);
return () => {
// Case 1: cleanup happens before we hear back from
// the main process. In this case, when the promise resolves, ignore the result
rejected = true;
// Case 2: Cleanup happens after we hear back from main process but
// (potentially) before the next lyric. In this case, clear the timer.
// Do NOT do this for other cleanup functions, as it should only be done
// when switching to a new song (or an empty one)
if (lyricTimer.current) clearTimeout(lyricTimer.current);
};
}
return () => {};
}, [getCurrentTime, lyrics, playerType, setCurrentLyric, status]);
useEffect(() => {
// This handler is used to deal with changes to the current delay. If the offset
// changes, we should immediately stop the current listening set and calculate
// the correct one using the new offset. Afterwards, timing can be calculated like normal
const changed = delayMsRef.current !== settings.delayMs;
if (!changed) {
return () => {};
}
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
let rejected = false;
delayMsRef.current = settings.delayMs;
getCurrentTime()
.then((timeInSec: number) => {
if (rejected) {
return false;
return activeLyrics.length - 1;
}
setCurrentLyric(timeInSec * 1000 - delayMsRef.current);
return true;
})
.catch(console.error);
return () => {
// In the event this ends earlier, just kill the promise. Cleanup of
// timeouts is otherwise handled by another handler
rejected = true;
return -1;
};
}, [getCurrentTime, setCurrentLyric, settings.delayMs]);
useEffect(() => {
// This handler is used specifically for dealing with seeking. In this case,
// we assume that now is the accurate time
if (status !== PlayerStatus.PLAYING) {
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
const getCurrentTime = useCallback(async () => {
if (isElectron() && playerType !== PlaybackType.WEB) {
if (mpvPlayer) {
return mpvPlayer.getCurrentTime();
}
return 0;
}
return;
}
if (!seeked) {
return;
}
if (playersRef.current === undefined) {
return 0;
}
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
const player = (
playersRef.current.player1 ?? playersRef.current.player2
).getInternalPlayer();
setCurrentLyric(now * 1000 - delayMsRef.current);
}, [now, seeked, setCurrentLyric, status]);
// If it is null, this probably means we added a new song while the lyrics tab is open
// and the queue was previously empty
if (!player) return 0;
useEffect(() => {
// Guaranteed cleanup; stop the timer, and just in case also increment
// the epoch to instruct any dangling timers to stop
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
return player.currentTime;
}, [playerType, playersRef]);
timerEpoch.current += 1;
}, []);
const setCurrentLyric = useCallback(
(timeInMs: number, epoch?: number, targetIndex?: number) => {
const start = performance.now();
let nextEpoch: number;
const hideScrollbar = () => {
const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
doc.classList.add('hide-scrollbar');
};
if (epoch === undefined) {
timerEpoch.current = (timerEpoch.current + 1) % 10000;
nextEpoch = timerEpoch.current;
} else if (epoch !== timerEpoch.current) {
return;
} else {
nextEpoch = epoch;
}
const showScrollbar = () => {
const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
doc.classList.remove('hide-scrollbar');
};
let index: number;
return (
<SynchronizedLyricsContainer
className="synchronized-lyrics overlay-scrollbar"
id="sychronized-lyrics-scroll-container"
onMouseEnter={showScrollbar}
onMouseLeave={hideScrollbar}
>
{source && (
<LyricLine
className="lyric-credit"
text={`Provided by ${source}`}
/>
)}
{remote && (
<LyricLine
className="lyric-credit"
text={`"${name} by ${artist}"`}
/>
)}
{lyrics.map(([, text], idx) => (
<LyricLine
key={idx}
className="lyric-line synchronized"
id={`lyric-${idx}`}
text={text}
/>
))}
</SynchronizedLyricsContainer>
);
if (targetIndex === undefined) {
index = getCurrentLyric(timeInMs);
} else {
index = targetIndex;
}
// Directly modify the dom instead of using react to prevent rerender
document
.querySelectorAll('.synchronized-lyrics .active')
.forEach((node) => node.classList.remove('active'));
if (index === -1) {
lyricRef.current = undefined;
return;
}
const doc = document.getElementById(
'sychronized-lyrics-scroll-container',
) as HTMLElement;
const currentLyric = document.querySelector(`#lyric-${index}`) as HTMLElement;
const offsetTop = currentLyric?.offsetTop - doc?.clientHeight / 2 ?? 0;
if (currentLyric === null) {
lyricRef.current = undefined;
return;
}
currentLyric.classList.add('active');
if (followRef.current) {
doc?.scroll({ behavior: 'smooth', top: offsetTop });
}
if (index !== lyricRef.current!.length - 1) {
const nextTime = lyricRef.current![index + 1][0];
const elapsed = performance.now() - start;
lyricTimer.current = setTimeout(() => {
setCurrentLyric(nextTime, nextEpoch, index + 1);
}, nextTime - timeInMs - elapsed);
}
},
[],
);
useEffect(() => {
// Copy the follow settings into a ref that can be accessed in the timeout
followRef.current = settings.follow;
}, [settings.follow]);
useEffect(() => {
// This handler is used to handle when lyrics change. It is in some sense the
// 'primary' handler for parsing lyrics, as unlike the other callbacks, it will
// ALSO remove listeners on close. Use the promisified getCurrentTime(), because
// we don't want to be dependent on npw, which may not be precise
lyricRef.current = lyrics;
if (status === PlayerStatus.PLAYING) {
let rejected = false;
getCurrentTime()
.then((timeInSec: number) => {
if (rejected) {
return false;
}
setCurrentLyric(timeInSec * 1000 - delayMsRef.current);
return true;
})
.catch(console.error);
return () => {
// Case 1: cleanup happens before we hear back from
// the main process. In this case, when the promise resolves, ignore the result
rejected = true;
// Case 2: Cleanup happens after we hear back from main process but
// (potentially) before the next lyric. In this case, clear the timer.
// Do NOT do this for other cleanup functions, as it should only be done
// when switching to a new song (or an empty one)
if (lyricTimer.current) clearTimeout(lyricTimer.current);
};
}
return () => {};
}, [getCurrentTime, lyrics, playerType, setCurrentLyric, status]);
useEffect(() => {
// This handler is used to deal with changes to the current delay. If the offset
// changes, we should immediately stop the current listening set and calculate
// the correct one using the new offset. Afterwards, timing can be calculated like normal
const changed = delayMsRef.current !== settings.delayMs;
if (!changed) {
return () => {};
}
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
let rejected = false;
delayMsRef.current = settings.delayMs;
getCurrentTime()
.then((timeInSec: number) => {
if (rejected) {
return false;
}
setCurrentLyric(timeInSec * 1000 - delayMsRef.current);
return true;
})
.catch(console.error);
return () => {
// In the event this ends earlier, just kill the promise. Cleanup of
// timeouts is otherwise handled by another handler
rejected = true;
};
}, [getCurrentTime, setCurrentLyric, settings.delayMs]);
useEffect(() => {
// This handler is used specifically for dealing with seeking. In this case,
// we assume that now is the accurate time
if (status !== PlayerStatus.PLAYING) {
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
return;
}
if (!seeked) {
return;
}
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
setCurrentLyric(now * 1000 - delayMsRef.current);
}, [now, seeked, setCurrentLyric, status]);
useEffect(() => {
// Guaranteed cleanup; stop the timer, and just in case also increment
// the epoch to instruct any dangling timers to stop
if (lyricTimer.current) {
clearTimeout(lyricTimer.current);
}
timerEpoch.current += 1;
}, []);
const hideScrollbar = () => {
const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
doc.classList.add('hide-scrollbar');
};
const showScrollbar = () => {
const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement;
doc.classList.remove('hide-scrollbar');
};
return (
<SynchronizedLyricsContainer
className="synchronized-lyrics overlay-scrollbar"
id="sychronized-lyrics-scroll-container"
onMouseEnter={showScrollbar}
onMouseLeave={hideScrollbar}
>
{source && (
<LyricLine
className="lyric-credit"
text={`Provided by ${source}`}
/>
)}
{remote && (
<LyricLine
className="lyric-credit"
text={`"${name} by ${artist}"`}
/>
)}
{lyrics.map(([, text], idx) => (
<LyricLine
key={idx}
className="lyric-line synchronized"
id={`lyric-${idx}`}
text={text}
/>
))}
</SynchronizedLyricsContainer>
);
};

View file

@ -4,65 +4,65 @@ import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
import { FullLyricsMetadata } from '/@/renderer/api/types';
interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: string;
lyrics: string;
}
const UnsynchronizedLyricsContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
height: 100%;
padding: 10vh 0 6vh;
overflow: scroll;
transform: translateY(-2rem);
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
height: 100%;
padding: 10vh 0 6vh;
overflow: scroll;
transform: translateY(-2rem);
mask-image: linear-gradient(
180deg,
transparent 5%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
mask-image: linear-gradient(
180deg,
transparent 5%,
rgba(0, 0, 0, 100%) 20%,
rgba(0, 0, 0, 100%) 85%,
transparent 95%
);
@media screen and (max-width: 768px) {
padding: 5vh 0;
}
@media screen and (max-width: 768px) {
padding: 5vh 0;
}
`;
export const UnsynchronizedLyrics = ({
artist,
lyrics,
name,
remote,
source,
artist,
lyrics,
name,
remote,
source,
}: UnsynchronizedLyricsProps) => {
const lines = useMemo(() => {
return lyrics.split('\n');
}, [lyrics]);
const lines = useMemo(() => {
return lyrics.split('\n');
}, [lyrics]);
return (
<UnsynchronizedLyricsContainer className="unsynchronized-lyrics">
{source && (
<LyricLine
className="lyric-credit"
text={`Provided by ${source}`}
/>
)}
{remote && (
<LyricLine
className="lyric-credit"
text={`"${name} by ${artist}"`}
/>
)}
{lines.map((text, idx) => (
<LyricLine
key={idx}
className="lyric-line"
id={`lyric-${idx}`}
text={text}
/>
))}
</UnsynchronizedLyricsContainer>
);
return (
<UnsynchronizedLyricsContainer className="unsynchronized-lyrics">
{source && (
<LyricLine
className="lyric-credit"
text={`Provided by ${source}`}
/>
)}
{remote && (
<LyricLine
className="lyric-credit"
text={`"${name} by ${artist}"`}
/>
)}
{lines.map((text, idx) => (
<LyricLine
key={idx}
className="lyric-line"
id={`lyric-${idx}`}
text={text}
/>
))}
</UnsynchronizedLyricsContainer>
);
};

View file

@ -6,32 +6,32 @@ import { Song } from '/@/renderer/api/types';
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
export const DrawerPlayQueue = () => {
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
return (
<Flex
direction="column"
h="100%"
>
<Box
bg="var(--main-bg)"
sx={{ borderRadius: '10px' }}
>
<PlayQueueListControls
tableRef={queueRef}
type="sideQueue"
/>
</Box>
<Flex
bg="var(--main-bg)"
h="100%"
mb="0.6rem"
>
<PlayQueue
ref={queueRef}
type="sideQueue"
/>
</Flex>
</Flex>
);
return (
<Flex
direction="column"
h="100%"
>
<Box
bg="var(--main-bg)"
sx={{ borderRadius: '10px' }}
>
<PlayQueueListControls
tableRef={queueRef}
type="sideQueue"
/>
</Box>
<Flex
bg="var(--main-bg)"
h="100%"
mb="0.6rem"
>
<PlayQueue
ref={queueRef}
type="sideQueue"
/>
</Flex>
</Flex>
);
};

View file

@ -2,14 +2,14 @@ import { PageHeader } from '/@/renderer/components';
import { LibraryHeaderBar } from '/@/renderer/features/shared';
export const NowPlayingHeader = () => {
// const currentSong = useCurrentSong();
// const theme = useTheme();
// const currentSong = useCurrentSong();
// const theme = useTheme();
return (
<PageHeader backgroundColor="var(--titlebar-bg)">
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Queue</LibraryHeaderBar.Title>
</LibraryHeaderBar>
</PageHeader>
);
return (
<PageHeader backgroundColor="var(--titlebar-bg)">
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Queue</LibraryHeaderBar.Title>
</LibraryHeaderBar>
</PageHeader>
);
};

View file

@ -4,12 +4,12 @@ import { Group } from '@mantine/core';
import { Button, Popover } from '/@/renderer/components';
import isElectron from 'is-electron';
import {
RiArrowDownLine,
RiArrowUpLine,
RiShuffleLine,
RiDeleteBinLine,
RiListSettingsLine,
RiEraserLine,
RiArrowDownLine,
RiArrowUpLine,
RiShuffleLine,
RiDeleteBinLine,
RiListSettingsLine,
RiEraserLine,
} from 'react-icons/ri';
import { Song } from '/@/renderer/api/types';
import { usePlayerControls, useQueueControls } from '/@/renderer/store';
@ -21,156 +21,156 @@ import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
interface PlayQueueListOptionsProps {
tableRef: MutableRefObject<{ grid: AgGridReactType<Song> } | null>;
type: TableType;
tableRef: MutableRefObject<{ grid: AgGridReactType<Song> } | null>;
type: TableType;
}
export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsProps) => {
const { clearQueue, moveToBottomOfQueue, moveToTopOfQueue, shuffleQueue, removeFromQueue } =
useQueueControls();
const { clearQueue, moveToBottomOfQueue, moveToTopOfQueue, shuffleQueue, removeFromQueue } =
useQueueControls();
const { pause } = usePlayerControls();
const { pause } = usePlayerControls();
const playerType = usePlayerType();
const setCurrentTime = useSetCurrentTime();
const playerType = usePlayerType();
const setCurrentTime = useSetCurrentTime();
const handleMoveToBottom = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const handleMoveToBottom = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const playerData = moveToBottomOfQueue(uniqueIds);
const playerData = moveToBottomOfQueue(uniqueIds);
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
};
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
};
const handleMoveToTop = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const handleMoveToTop = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const playerData = moveToTopOfQueue(uniqueIds);
const playerData = moveToTopOfQueue(uniqueIds);
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
};
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
};
const handleRemoveSelected = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const handleRemoveSelected = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const currentSong = usePlayerStore.getState().current.song;
const playerData = removeFromQueue(uniqueIds);
const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong.uniqueId);
const currentSong = usePlayerStore.getState().current.song;
const playerData = removeFromQueue(uniqueIds);
const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong.uniqueId);
if (playerType === PlaybackType.LOCAL) {
if (isCurrentSongRemoved) {
mpvPlayer.setQueue(playerData);
} else {
mpvPlayer.setQueueNext(playerData);
}
}
};
if (playerType === PlaybackType.LOCAL) {
if (isCurrentSongRemoved) {
mpvPlayer.setQueue(playerData);
} else {
mpvPlayer.setQueueNext(playerData);
}
}
};
const handleClearQueue = () => {
const playerData = clearQueue();
const handleClearQueue = () => {
const playerData = clearQueue();
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData);
mpvPlayer.pause();
}
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData);
mpvPlayer.pause();
}
setCurrentTime(0);
pause();
};
setCurrentTime(0);
pause();
};
const handleShuffleQueue = () => {
const playerData = shuffleQueue();
const handleShuffleQueue = () => {
const playerData = shuffleQueue();
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
};
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
};
return (
<Group
position="apart"
px="1rem"
py="1rem"
sx={{ alignItems: 'center' }}
w="100%"
>
<Group spacing="sm">
<Button
compact
size="md"
tooltip={{ label: 'Shuffle queue' }}
variant="default"
onClick={handleShuffleQueue}
return (
<Group
position="apart"
px="1rem"
py="1rem"
sx={{ alignItems: 'center' }}
w="100%"
>
<RiShuffleLine size="1.1rem" />
</Button>
<Button
compact
size="md"
tooltip={{ label: 'Move selected to bottom' }}
variant="default"
onClick={handleMoveToBottom}
>
<RiArrowDownLine size="1.1rem" />
</Button>
<Button
compact
size="md"
tooltip={{ label: 'Move selected to top' }}
variant="default"
onClick={handleMoveToTop}
>
<RiArrowUpLine size="1.1rem" />
</Button>
<Button
compact
size="md"
tooltip={{ label: 'Remove selected' }}
variant="default"
onClick={handleRemoveSelected}
>
<RiEraserLine size="1.1rem" />
</Button>
<Button
compact
size="md"
tooltip={{ label: 'Clear queue' }}
variant="default"
onClick={handleClearQueue}
>
<RiDeleteBinLine size="1.1rem" />
</Button>
</Group>
<Group>
<Popover
position="top-end"
transitionProps={{ transition: 'fade' }}
>
<Popover.Target>
<Button
compact
size="md"
tooltip={{ label: 'Configure' }}
variant="subtle"
>
<RiListSettingsLine size="1.1rem" />
</Button>
</Popover.Target>
<Popover.Dropdown>
<TableConfigDropdown type={type} />
</Popover.Dropdown>
</Popover>
</Group>
</Group>
);
<Group spacing="sm">
<Button
compact
size="md"
tooltip={{ label: 'Shuffle queue' }}
variant="default"
onClick={handleShuffleQueue}
>
<RiShuffleLine size="1.1rem" />
</Button>
<Button
compact
size="md"
tooltip={{ label: 'Move selected to bottom' }}
variant="default"
onClick={handleMoveToBottom}
>
<RiArrowDownLine size="1.1rem" />
</Button>
<Button
compact
size="md"
tooltip={{ label: 'Move selected to top' }}
variant="default"
onClick={handleMoveToTop}
>
<RiArrowUpLine size="1.1rem" />
</Button>
<Button
compact
size="md"
tooltip={{ label: 'Remove selected' }}
variant="default"
onClick={handleRemoveSelected}
>
<RiEraserLine size="1.1rem" />
</Button>
<Button
compact
size="md"
tooltip={{ label: 'Clear queue' }}
variant="default"
onClick={handleClearQueue}
>
<RiDeleteBinLine size="1.1rem" />
</Button>
</Group>
<Group>
<Popover
position="top-end"
transitionProps={{ transition: 'fade' }}
>
<Popover.Target>
<Button
compact
size="md"
tooltip={{ label: 'Configure' }}
variant="subtle"
>
<RiListSettingsLine size="1.1rem" />
</Button>
</Popover.Target>
<Popover.Dropdown>
<TableConfigDropdown type={type} />
</Popover.Dropdown>
</Popover>
</Group>
</Group>
);
};

View file

@ -1,26 +1,26 @@
import type { Ref } from 'react';
import { useState, forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import type {
CellDoubleClickedEvent,
RowClassRules,
RowDragEvent,
RowNode,
CellDoubleClickedEvent,
RowClassRules,
RowDragEvent,
RowNode,
} from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import '@ag-grid-community/styles/ag-theme-alpine.css';
import {
useAppStoreActions,
useCurrentSong,
useDefaultQueue,
usePlayerControls,
usePreviousSong,
useQueueControls,
useAppStoreActions,
useCurrentSong,
useDefaultQueue,
usePlayerControls,
usePreviousSong,
useQueueControls,
} from '/@/renderer/store';
import {
usePlayerType,
useSettingsStore,
useSettingsStoreActions,
useTableSettings,
usePlayerType,
useSettingsStore,
useSettingsStoreActions,
useTableSettings,
} from '/@/renderer/store/settings.store';
import { useMergedRef } from '@mantine/hooks';
import isElectron from 'is-electron';
@ -39,194 +39,200 @@ const utils = isElectron() ? window.electron.utils : null;
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
type QueueProps = {
type: TableType;
type: TableType;
};
export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
const tableRef = useRef<AgGridReactType | null>(null);
const mergedRef = useMergedRef(ref, tableRef);
const queue = useDefaultQueue();
const { reorderQueue, setCurrentTrack } = useQueueControls();
const currentSong = useCurrentSong();
const previousSong = usePreviousSong();
const { setSettings } = useSettingsStoreActions();
const { setAppStore } = useAppStoreActions();
const tableConfig = useTableSettings(type);
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
const playerType = usePlayerType();
const { play } = usePlayerControls();
const tableRef = useRef<AgGridReactType | null>(null);
const mergedRef = useMergedRef(ref, tableRef);
const queue = useDefaultQueue();
const { reorderQueue, setCurrentTrack } = useQueueControls();
const currentSong = useCurrentSong();
const previousSong = usePreviousSong();
const { setSettings } = useSettingsStoreActions();
const { setAppStore } = useAppStoreActions();
const tableConfig = useTableSettings(type);
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
const playerType = usePlayerType();
const { play } = usePlayerControls();
useEffect(() => {
if (tableRef.current) {
setGridApi(tableRef.current);
}
}, []);
useImperativeHandle(ref, () => ({
get grid() {
return gridApi;
},
}));
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
const handleDoubleClick = (e: CellDoubleClickedEvent) => {
const playerData = setCurrentTrack(e.data.uniqueId);
mpris?.updateSong({
currentTime: 0,
song: playerData.current.song,
status: 'Playing',
});
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData);
mpvPlayer.play();
}
play();
};
const handleDragStart = () => {
if (type === 'sideDrawerQueue') {
setAppStore({ isReorderingQueue: true });
}
};
let timeout: any;
const handleDragEnd = (e: RowDragEvent<QueueSong>) => {
if (!e.nodes.length) return;
const selectedUniqueIds = e.nodes
.map((node) => node.data?.uniqueId)
.filter((e) => e !== undefined);
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
if (type === 'sideDrawerQueue') {
setAppStore({ isReorderingQueue: false });
}
const { api } = tableRef?.current || {};
clearTimeout(timeout);
timeout = setTimeout(() => api?.redrawRows(), 250);
};
const handleGridReady = () => {
const { api } = tableRef?.current || {};
if (currentSong?.uniqueId) {
const currentNode = api?.getRowNode(currentSong?.uniqueId);
if (!currentNode) return;
api?.ensureNodeVisible(currentNode, 'middle');
}
};
const handleColumnChange = () => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
const columnsInSettings = useSettingsStore.getState().tables[type].columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!useSettingsStore.getState().tables[type].autoFit && {
width: column.getActualWidth(),
}),
});
}
}
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
columns: updatedColumns,
},
},
});
};
const debouncedColumnChange = debounce(handleColumnChange, 250);
const handleGridSizeChange = () => {
if (tableConfig.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const rowClassRules = useMemo<RowClassRules | undefined>(() => {
return {
'current-song': (params) => {
return params.data.uniqueId === currentSong?.uniqueId;
},
};
}, [currentSong?.uniqueId]);
// Redraw the current song row when the previous song changes
useEffect(() => {
if (tableRef?.current) {
const { api, columnApi } = tableRef?.current || {};
if (api == null || columnApi == null) {
return;
}
const currentNode = currentSong?.uniqueId ? api.getRowNode(currentSong.uniqueId) : undefined;
const previousNode = previousSong?.uniqueId
? api.getRowNode(previousSong?.uniqueId)
: undefined;
const rowNodes = [currentNode, previousNode].filter((e) => e !== undefined) as RowNode<any>[];
if (rowNodes) {
api.redrawRows({ rowNodes });
if (tableConfig.followCurrentSong) {
if (!currentNode) return;
api.ensureNodeVisible(currentNode, 'middle');
useEffect(() => {
if (tableRef.current) {
setGridApi(tableRef.current);
}
}
}
}, [currentSong, previousSong, tableConfig.followCurrentSong]);
}, []);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
useImperativeHandle(ref, () => ({
get grid() {
return gridApi;
},
}));
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<VirtualGridAutoSizerContainer>
<VirtualTable
ref={mergedRef}
alwaysShowHorizontalScroll
rowDragEntireRow
rowDragMultiRow
autoFitColumns={tableConfig.autoFit}
columnDefs={columnDefs}
deselectOnClickOutside={type === 'fullScreen'}
getRowId={(data) => data.data.uniqueId}
rowBuffer={50}
rowClassRules={rowClassRules}
rowData={queue}
rowHeight={tableConfig.rowHeight || 40}
suppressCellFocus={type === 'fullScreen'}
onCellContextMenu={handleContextMenu}
onCellDoubleClicked={handleDoubleClick}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onDragStarted={handleDragStart}
onGridReady={handleGridReady}
onGridSizeChanged={handleGridSizeChange}
onRowDragEnd={handleDragEnd}
/>
</VirtualGridAutoSizerContainer>
</ErrorBoundary>
);
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
const handleDoubleClick = (e: CellDoubleClickedEvent) => {
const playerData = setCurrentTrack(e.data.uniqueId);
mpris?.updateSong({
currentTime: 0,
song: playerData.current.song,
status: 'Playing',
});
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData);
mpvPlayer.play();
}
play();
};
const handleDragStart = () => {
if (type === 'sideDrawerQueue') {
setAppStore({ isReorderingQueue: true });
}
};
let timeout: any;
const handleDragEnd = (e: RowDragEvent<QueueSong>) => {
if (!e.nodes.length) return;
const selectedUniqueIds = e.nodes
.map((node) => node.data?.uniqueId)
.filter((e) => e !== undefined);
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
if (type === 'sideDrawerQueue') {
setAppStore({ isReorderingQueue: false });
}
const { api } = tableRef?.current || {};
clearTimeout(timeout);
timeout = setTimeout(() => api?.redrawRows(), 250);
};
const handleGridReady = () => {
const { api } = tableRef?.current || {};
if (currentSong?.uniqueId) {
const currentNode = api?.getRowNode(currentSong?.uniqueId);
if (!currentNode) return;
api?.ensureNodeVisible(currentNode, 'middle');
}
};
const handleColumnChange = () => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
const columnsInSettings = useSettingsStore.getState().tables[type].columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find(
(c) => c.column === column.getColDef().colId,
);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!useSettingsStore.getState().tables[type].autoFit && {
width: column.getActualWidth(),
}),
});
}
}
setSettings({
tables: {
...useSettingsStore.getState().tables,
[type]: {
...useSettingsStore.getState().tables[type],
columns: updatedColumns,
},
},
});
};
const debouncedColumnChange = debounce(handleColumnChange, 250);
const handleGridSizeChange = () => {
if (tableConfig.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const rowClassRules = useMemo<RowClassRules | undefined>(() => {
return {
'current-song': (params) => {
return params.data.uniqueId === currentSong?.uniqueId;
},
};
}, [currentSong?.uniqueId]);
// Redraw the current song row when the previous song changes
useEffect(() => {
if (tableRef?.current) {
const { api, columnApi } = tableRef?.current || {};
if (api == null || columnApi == null) {
return;
}
const currentNode = currentSong?.uniqueId
? api.getRowNode(currentSong.uniqueId)
: undefined;
const previousNode = previousSong?.uniqueId
? api.getRowNode(previousSong?.uniqueId)
: undefined;
const rowNodes = [currentNode, previousNode].filter(
(e) => e !== undefined,
) as RowNode<any>[];
if (rowNodes) {
api.redrawRows({ rowNodes });
if (tableConfig.followCurrentSong) {
if (!currentNode) return;
api.ensureNodeVisible(currentNode, 'middle');
}
}
}
}, [currentSong, previousSong, tableConfig.followCurrentSong]);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<VirtualGridAutoSizerContainer>
<VirtualTable
ref={mergedRef}
alwaysShowHorizontalScroll
rowDragEntireRow
rowDragMultiRow
autoFitColumns={tableConfig.autoFit}
columnDefs={columnDefs}
deselectOnClickOutside={type === 'fullScreen'}
getRowId={(data) => data.data.uniqueId}
rowBuffer={50}
rowClassRules={rowClassRules}
rowData={queue}
rowHeight={tableConfig.rowHeight || 40}
suppressCellFocus={type === 'fullScreen'}
onCellContextMenu={handleContextMenu}
onCellDoubleClicked={handleDoubleClick}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onDragStarted={handleDragStart}
onGridReady={handleGridReady}
onGridSizeChanged={handleGridSizeChange}
onRowDragEnd={handleDragEnd}
/>
</VirtualGridAutoSizerContainer>
</ErrorBoundary>
);
});

View file

@ -10,30 +10,30 @@ import { Platform } from '/@/renderer/types';
import { VirtualGridContainer } from '/@/renderer/components/virtual-grid';
export const SidebarPlayQueue = () => {
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
const { windowBarStyle } = useWindowSettings();
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
const { windowBarStyle } = useWindowSettings();
const isWeb = windowBarStyle === Platform.WEB;
return (
<VirtualGridContainer>
{isWeb && (
<Stack mr={isWeb ? '130px' : undefined}>
<PageHeader backgroundColor="var(--titlebar-bg)" />
</Stack>
)}
<Paper
display={!isWeb ? 'flex' : undefined}
h={!isWeb ? '65px' : undefined}
>
<PlayQueueListControls
tableRef={queueRef}
type="sideQueue"
/>
</Paper>
<PlayQueue
ref={queueRef}
type="sideQueue"
/>
</VirtualGridContainer>
);
const isWeb = windowBarStyle === Platform.WEB;
return (
<VirtualGridContainer>
{isWeb && (
<Stack mr={isWeb ? '130px' : undefined}>
<PageHeader backgroundColor="var(--titlebar-bg)" />
</Stack>
)}
<Paper
display={!isWeb ? 'flex' : undefined}
h={!isWeb ? '65px' : undefined}
>
<PlayQueueListControls
tableRef={queueRef}
type="sideQueue"
/>
</Paper>
<PlayQueue
ref={queueRef}
type="sideQueue"
/>
</VirtualGridContainer>
);
};

View file

@ -9,25 +9,25 @@ import { PlayQueueListControls } from '/@/renderer/features/now-playing/componen
import { VirtualGridContainer } from '/@/renderer/components/virtual-grid';
const NowPlayingRoute = () => {
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
return (
<AnimatedPage>
<VirtualGridContainer>
<NowPlayingHeader />
<Paper sx={{ borderTop: '1px solid var(--generic-border-color)' }}>
<PlayQueueListControls
tableRef={queueRef}
type="nowPlaying"
/>
</Paper>
<PlayQueue
ref={queueRef}
type="nowPlaying"
/>
</VirtualGridContainer>
</AnimatedPage>
);
return (
<AnimatedPage>
<VirtualGridContainer>
<NowPlayingHeader />
<Paper sx={{ borderTop: '1px solid var(--generic-border-color)' }}>
<PlayQueueListControls
tableRef={queueRef}
type="nowPlaying"
/>
</Paper>
<PlayQueue
ref={queueRef}
type="nowPlaying"
/>
</VirtualGridContainer>
</AnimatedPage>
);
};
export default NowPlayingRoute;

View file

@ -5,34 +5,34 @@ import formatDuration from 'format-duration';
import isElectron from 'is-electron';
import { IoIosPause } from 'react-icons/io';
import {
RiMenuAddFill,
RiPlayFill,
RiRepeat2Line,
RiRepeatOneLine,
RiRewindFill,
RiShuffleFill,
RiSkipBackFill,
RiSkipForwardFill,
RiSpeedFill,
RiStopFill,
RiMenuAddFill,
RiPlayFill,
RiRepeat2Line,
RiRepeatOneLine,
RiRewindFill,
RiShuffleFill,
RiSkipBackFill,
RiSkipForwardFill,
RiSpeedFill,
RiStopFill,
} from 'react-icons/ri';
import styled from 'styled-components';
import { Text } from '/@/renderer/components';
import { useCenterControls } from '../hooks/use-center-controls';
import { PlayerButton } from './player-button';
import {
useCurrentSong,
useCurrentStatus,
useCurrentPlayer,
useSetCurrentTime,
useRepeatStatus,
useShuffleStatus,
useCurrentTime,
useCurrentSong,
useCurrentStatus,
useCurrentPlayer,
useSetCurrentTime,
useRepeatStatus,
useShuffleStatus,
useCurrentTime,
} from '/@/renderer/store';
import {
useHotkeySettings,
usePlayerType,
useSettingsStore,
useHotkeySettings,
usePlayerType,
useSettingsStore,
} from '/@/renderer/store/settings.store';
import { PlayerStatus, PlaybackType, PlayerShuffle, PlayerRepeat } from '/@/renderer/types';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
@ -40,282 +40,286 @@ import { openShuffleAllModal } from './shuffle-all-modal';
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
interface CenterControlsProps {
playersRef: any;
playersRef: any;
}
const ButtonsContainer = styled.div`
display: flex;
gap: 0.5rem;
align-items: center;
display: flex;
gap: 0.5rem;
align-items: center;
`;
const SliderContainer = styled.div`
display: flex;
width: 95%;
height: 20px;
display: flex;
width: 95%;
height: 20px;
`;
const SliderValueWrapper = styled.div<{ position: 'left' | 'right' }>`
display: flex;
flex: 1;
align-self: flex-end;
justify-content: center;
max-width: 50px;
display: flex;
flex: 1;
align-self: flex-end;
justify-content: center;
max-width: 50px;
@media (max-width: 768px) {
display: none;
}
@media (max-width: 768px) {
display: none;
}
`;
const SliderWrapper = styled.div`
display: flex;
flex: 6;
align-items: center;
height: 100%;
display: flex;
flex: 6;
align-items: center;
height: 100%;
`;
const ControlsContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
height: 35px;
display: flex;
align-items: center;
justify-content: center;
height: 35px;
@media (max-width: 768px) {
${ButtonsContainer} {
gap: 0;
}
@media (max-width: 768px) {
${ButtonsContainer} {
gap: 0;
}
${SliderValueWrapper} {
display: none;
${SliderValueWrapper} {
display: none;
}
}
}
`;
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const queryClient = useQueryClient();
const [isSeeking, setIsSeeking] = useState(false);
const currentSong = useCurrentSong();
const songDuration = currentSong?.duration;
const skip = useSettingsStore((state) => state.general.skipButtons);
const playerType = usePlayerType();
const player1 = playersRef?.current?.player1;
const player2 = playersRef?.current?.player2;
const status = useCurrentStatus();
const player = useCurrentPlayer();
const setCurrentTime = useSetCurrentTime();
const repeat = useRepeatStatus();
const shuffle = useShuffleStatus();
const { bindings } = useHotkeySettings();
const queryClient = useQueryClient();
const [isSeeking, setIsSeeking] = useState(false);
const currentSong = useCurrentSong();
const songDuration = currentSong?.duration;
const skip = useSettingsStore((state) => state.general.skipButtons);
const playerType = usePlayerType();
const player1 = playersRef?.current?.player1;
const player2 = playersRef?.current?.player2;
const status = useCurrentStatus();
const player = useCurrentPlayer();
const setCurrentTime = useSetCurrentTime();
const repeat = useRepeatStatus();
const shuffle = useShuffleStatus();
const { bindings } = useHotkeySettings();
const {
handleNextTrack,
handlePlayPause,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
handleToggleRepeat,
handleToggleShuffle,
handleStop,
handlePause,
handlePlay,
} = useCenterControls({ playersRef });
const handlePlayQueueAdd = usePlayQueueAdd();
const {
handleNextTrack,
handlePlayPause,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
handleToggleRepeat,
handleToggleShuffle,
handleStop,
handlePause,
handlePlay,
} = useCenterControls({ playersRef });
const handlePlayQueueAdd = usePlayQueueAdd();
const currentTime = useCurrentTime();
const currentPlayerRef = player === 1 ? player1 : player2;
const duration = formatDuration((songDuration || 0) * 1000);
const formattedTime = formatDuration(currentTime * 1000 || 0);
const currentTime = useCurrentTime();
const currentPlayerRef = player === 1 ? player1 : player2;
const duration = formatDuration((songDuration || 0) * 1000);
const formattedTime = formatDuration(currentTime * 1000 || 0);
useEffect(() => {
let interval: any;
useEffect(() => {
let interval: any;
if (status === PlayerStatus.PLAYING && !isSeeking) {
if (!isElectron() || playerType === PlaybackType.WEB) {
interval = setInterval(() => {
setCurrentTime(currentPlayerRef.getCurrentTime());
}, 1000);
}
} else {
clearInterval(interval);
}
return () => clearInterval(interval);
}, [currentPlayerRef, isSeeking, setCurrentTime, playerType, status]);
const [seekValue, setSeekValue] = useState(0);
useHotkeys([
[bindings.playPause.isGlobal ? '' : bindings.playPause.hotkey, handlePlayPause],
[bindings.play.isGlobal ? '' : bindings.play.hotkey, handlePlay],
[bindings.pause.isGlobal ? '' : bindings.pause.hotkey, handlePause],
[bindings.stop.isGlobal ? '' : bindings.stop.hotkey, handleStop],
[bindings.next.isGlobal ? '' : bindings.next.hotkey, handleNextTrack],
[bindings.previous.isGlobal ? '' : bindings.previous.hotkey, handlePrevTrack],
[bindings.toggleRepeat.isGlobal ? '' : bindings.toggleRepeat.hotkey, handleToggleRepeat],
[bindings.toggleShuffle.isGlobal ? '' : bindings.toggleShuffle.hotkey, handleToggleShuffle],
[
bindings.skipBackward.isGlobal ? '' : bindings.skipBackward.hotkey,
() => handleSkipBackward(skip?.skipBackwardSeconds || 5),
],
[
bindings.skipForward.isGlobal ? '' : bindings.skipForward.hotkey,
() => handleSkipForward(skip?.skipForwardSeconds || 5),
],
]);
return (
<>
<ControlsContainer>
<ButtonsContainer>
<PlayerButton
icon={<RiStopFill size={15} />}
tooltip={{
label: 'Stop',
openDelay: 500,
}}
variant="tertiary"
onClick={handleStop}
/>
<PlayerButton
$isActive={shuffle !== PlayerShuffle.NONE}
icon={<RiShuffleFill size={15} />}
tooltip={{
label:
shuffle === PlayerShuffle.NONE
? 'Shuffle disabled'
: shuffle === PlayerShuffle.TRACK
? 'Shuffle tracks'
: 'Shuffle albums',
openDelay: 500,
}}
variant="tertiary"
onClick={handleToggleShuffle}
/>
<PlayerButton
icon={<RiSkipBackFill size={15} />}
tooltip={{ label: 'Previous track', openDelay: 500 }}
variant="secondary"
onClick={handlePrevTrack}
/>
{skip?.enabled && (
<PlayerButton
icon={<RiRewindFill size={15} />}
tooltip={{
label: `Skip backwards ${skip?.skipBackwardSeconds} seconds`,
openDelay: 500,
}}
variant="secondary"
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
/>
)}
<PlayerButton
icon={
status === PlayerStatus.PAUSED ? <RiPlayFill size={20} /> : <IoIosPause size={20} />
if (status === PlayerStatus.PLAYING && !isSeeking) {
if (!isElectron() || playerType === PlaybackType.WEB) {
interval = setInterval(() => {
setCurrentTime(currentPlayerRef.getCurrentTime());
}, 1000);
}
tooltip={{
label: status === PlayerStatus.PAUSED ? 'Play' : 'Pause',
openDelay: 500,
}}
variant="main"
onClick={handlePlayPause}
/>
{skip?.enabled && (
<PlayerButton
icon={<RiSpeedFill size={15} />}
tooltip={{
label: `Skip forwards ${skip?.skipForwardSeconds} seconds`,
openDelay: 500,
}}
variant="secondary"
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
/>
)}
<PlayerButton
icon={<RiSkipForwardFill size={15} />}
tooltip={{ label: 'Next track', openDelay: 500 }}
variant="secondary"
onClick={handleNextTrack}
/>
<PlayerButton
$isActive={repeat !== PlayerRepeat.NONE}
icon={
repeat === PlayerRepeat.ONE ? (
<RiRepeatOneLine size={15} />
) : (
<RiRepeat2Line size={15} />
)
}
tooltip={{
label: `${
repeat === PlayerRepeat.NONE
? 'Repeat disabled'
: repeat === PlayerRepeat.ALL
? 'Repeat all'
: 'Repeat one'
}`,
openDelay: 500,
}}
variant="tertiary"
onClick={handleToggleRepeat}
/>
} else {
clearInterval(interval);
}
<PlayerButton
icon={<RiMenuAddFill size={15} />}
tooltip={{
label: 'Shuffle all',
openDelay: 500,
}}
variant="tertiary"
onClick={() =>
openShuffleAllModal({
handlePlayQueueAdd,
queryClient,
})
}
/>
</ButtonsContainer>
</ControlsContainer>
<SliderContainer>
<SliderValueWrapper position="left">
<Text
$noSelect
$secondary
size="xs"
weight={600}
>
{formattedTime}
</Text>
</SliderValueWrapper>
<SliderWrapper>
<PlayerbarSlider
label={(value) => formatDuration(value * 1000)}
max={songDuration}
min={0}
size={6}
value={!isSeeking ? currentTime : seekValue}
w="100%"
onChange={(e) => {
setIsSeeking(true);
setSeekValue(e);
}}
onChangeEnd={(e) => {
handleSeekSlider(e);
setIsSeeking(false);
}}
/>
</SliderWrapper>
<SliderValueWrapper position="right">
<Text
$noSelect
$secondary
size="xs"
weight={600}
>
{duration}
</Text>
</SliderValueWrapper>
</SliderContainer>
</>
);
return () => clearInterval(interval);
}, [currentPlayerRef, isSeeking, setCurrentTime, playerType, status]);
const [seekValue, setSeekValue] = useState(0);
useHotkeys([
[bindings.playPause.isGlobal ? '' : bindings.playPause.hotkey, handlePlayPause],
[bindings.play.isGlobal ? '' : bindings.play.hotkey, handlePlay],
[bindings.pause.isGlobal ? '' : bindings.pause.hotkey, handlePause],
[bindings.stop.isGlobal ? '' : bindings.stop.hotkey, handleStop],
[bindings.next.isGlobal ? '' : bindings.next.hotkey, handleNextTrack],
[bindings.previous.isGlobal ? '' : bindings.previous.hotkey, handlePrevTrack],
[bindings.toggleRepeat.isGlobal ? '' : bindings.toggleRepeat.hotkey, handleToggleRepeat],
[bindings.toggleShuffle.isGlobal ? '' : bindings.toggleShuffle.hotkey, handleToggleShuffle],
[
bindings.skipBackward.isGlobal ? '' : bindings.skipBackward.hotkey,
() => handleSkipBackward(skip?.skipBackwardSeconds || 5),
],
[
bindings.skipForward.isGlobal ? '' : bindings.skipForward.hotkey,
() => handleSkipForward(skip?.skipForwardSeconds || 5),
],
]);
return (
<>
<ControlsContainer>
<ButtonsContainer>
<PlayerButton
icon={<RiStopFill size={15} />}
tooltip={{
label: 'Stop',
openDelay: 500,
}}
variant="tertiary"
onClick={handleStop}
/>
<PlayerButton
$isActive={shuffle !== PlayerShuffle.NONE}
icon={<RiShuffleFill size={15} />}
tooltip={{
label:
shuffle === PlayerShuffle.NONE
? 'Shuffle disabled'
: shuffle === PlayerShuffle.TRACK
? 'Shuffle tracks'
: 'Shuffle albums',
openDelay: 500,
}}
variant="tertiary"
onClick={handleToggleShuffle}
/>
<PlayerButton
icon={<RiSkipBackFill size={15} />}
tooltip={{ label: 'Previous track', openDelay: 500 }}
variant="secondary"
onClick={handlePrevTrack}
/>
{skip?.enabled && (
<PlayerButton
icon={<RiRewindFill size={15} />}
tooltip={{
label: `Skip backwards ${skip?.skipBackwardSeconds} seconds`,
openDelay: 500,
}}
variant="secondary"
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
/>
)}
<PlayerButton
icon={
status === PlayerStatus.PAUSED ? (
<RiPlayFill size={20} />
) : (
<IoIosPause size={20} />
)
}
tooltip={{
label: status === PlayerStatus.PAUSED ? 'Play' : 'Pause',
openDelay: 500,
}}
variant="main"
onClick={handlePlayPause}
/>
{skip?.enabled && (
<PlayerButton
icon={<RiSpeedFill size={15} />}
tooltip={{
label: `Skip forwards ${skip?.skipForwardSeconds} seconds`,
openDelay: 500,
}}
variant="secondary"
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
/>
)}
<PlayerButton
icon={<RiSkipForwardFill size={15} />}
tooltip={{ label: 'Next track', openDelay: 500 }}
variant="secondary"
onClick={handleNextTrack}
/>
<PlayerButton
$isActive={repeat !== PlayerRepeat.NONE}
icon={
repeat === PlayerRepeat.ONE ? (
<RiRepeatOneLine size={15} />
) : (
<RiRepeat2Line size={15} />
)
}
tooltip={{
label: `${
repeat === PlayerRepeat.NONE
? 'Repeat disabled'
: repeat === PlayerRepeat.ALL
? 'Repeat all'
: 'Repeat one'
}`,
openDelay: 500,
}}
variant="tertiary"
onClick={handleToggleRepeat}
/>
<PlayerButton
icon={<RiMenuAddFill size={15} />}
tooltip={{
label: 'Shuffle all',
openDelay: 500,
}}
variant="tertiary"
onClick={() =>
openShuffleAllModal({
handlePlayQueueAdd,
queryClient,
})
}
/>
</ButtonsContainer>
</ControlsContainer>
<SliderContainer>
<SliderValueWrapper position="left">
<Text
$noSelect
$secondary
size="xs"
weight={600}
>
{formattedTime}
</Text>
</SliderValueWrapper>
<SliderWrapper>
<PlayerbarSlider
label={(value) => formatDuration(value * 1000)}
max={songDuration}
min={0}
size={6}
value={!isSeeking ? currentTime : seekValue}
w="100%"
onChange={(e) => {
setIsSeeking(true);
setSeekValue(e);
}}
onChangeEnd={(e) => {
handleSeekSlider(e);
setIsSeeking(false);
}}
/>
</SliderWrapper>
<SliderValueWrapper position="right">
<Text
$noSelect
$secondary
size="xs"
weight={600}
>
{duration}
</Text>
</SliderValueWrapper>
</SliderContainer>
</>
);
};

View file

@ -11,10 +11,10 @@ import { Badge, Text, TextTitle } from '/@/renderer/components';
import { useFastAverageColor } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import {
PlayerData,
useFullScreenPlayerStore,
usePlayerData,
usePlayerStore,
PlayerData,
useFullScreenPlayerStore,
usePlayerData,
usePlayerStore,
} from '/@/renderer/store';
const Image = styled(motion.img)<{ $useAspectRatio: boolean }>`
@ -28,247 +28,249 @@ const Image = styled(motion.img)<{ $useAspectRatio: boolean }>`
`;
const ImageContainer = styled(motion.div)`
position: relative;
display: flex;
align-items: flex-end;
max-width: 100%;
aspect-ratio: 1/1;
height: 65%;
margin-bottom: 1rem;
position: relative;
display: flex;
align-items: flex-end;
max-width: 100%;
aspect-ratio: 1/1;
height: 65%;
margin-bottom: 1rem;
`;
const MetadataContainer = styled(Stack)`
h1 {
font-size: 3.5vh;
}
h1 {
font-size: 3.5vh;
}
`;
const PlayerContainer = styled(Flex)`
@media screen and (max-height: 640px) {
.full-screen-player-image-metadata {
display: none;
height: 100%;
margin-bottom: 0;
}
@media screen and (max-height: 640px) {
.full-screen-player-image-metadata {
display: none;
height: 100%;
margin-bottom: 0;
}
${ImageContainer} {
height: 100%;
margin-bottom: 0;
${ImageContainer} {
height: 100%;
margin-bottom: 0;
}
}
}
`;
const imageVariants: Variants = {
closed: {
opacity: 0,
transition: {
duration: 0.8,
ease: 'linear',
closed: {
opacity: 0,
transition: {
duration: 0.8,
ease: 'linear',
},
},
initial: {
opacity: 0,
},
open: (custom) => {
const { isOpen } = custom;
return {
opacity: isOpen ? 1 : 0,
transition: {
duration: 0.4,
ease: 'linear',
},
};
},
},
initial: {
opacity: 0,
},
open: (custom) => {
const { isOpen } = custom;
return {
opacity: isOpen ? 1 : 0,
transition: {
duration: 0.4,
ease: 'linear',
},
};
},
};
const scaleImageUrl = (url?: string | null) => {
return url
?.replace(/&size=\d+/, '&size=500')
.replace(/\?width=\d+/, '?width=500')
.replace(/&height=\d+/, '&height=500');
return url
?.replace(/&size=\d+/, '&size=500')
.replace(/\?width=\d+/, '?width=500')
.replace(/&height=\d+/, '&height=500');
};
const ImageWithPlaceholder = ({
useAspectRatio,
...props
useAspectRatio,
...props
}: HTMLMotionProps<'img'> & { useAspectRatio: boolean }) => {
if (!props.src) {
return (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: '100%',
width: '100%',
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size="25%"
/>
</Center>
);
}
if (!props.src) {
return (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: '100%',
width: '100%',
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size="25%"
/>
</Center>
);
}
return (
<Image
$useAspectRatio={useAspectRatio}
{...props}
/>
);
return (
<Image
$useAspectRatio={useAspectRatio}
{...props}
/>
);
};
export const FullScreenPlayerImage = () => {
const { queue } = usePlayerData();
const useImageAspectRatio = useFullScreenPlayerStore((state) => state.useImageAspectRatio);
const currentSong = queue.current;
const background = useFastAverageColor(queue.current?.imageUrl, true, 'dominant');
const imageKey = `image-${background}`;
const { queue } = usePlayerData();
const useImageAspectRatio = useFullScreenPlayerStore((state) => state.useImageAspectRatio);
const currentSong = queue.current;
const background = useFastAverageColor(queue.current?.imageUrl, true, 'dominant');
const imageKey = `image-${background}`;
const [imageState, setImageState] = useSetState({
bottomImage: scaleImageUrl(queue.next?.imageUrl),
current: 0,
topImage: scaleImageUrl(queue.current?.imageUrl),
});
const [imageState, setImageState] = useSetState({
bottomImage: scaleImageUrl(queue.next?.imageUrl),
current: 0,
topImage: scaleImageUrl(queue.current?.imageUrl),
});
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state) => [state.current.song, state.actions.getPlayerData().queue],
(state) => {
const isTop = imageState.current === 0;
const queue = state[1] as PlayerData['queue'];
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state) => [state.current.song, state.actions.getPlayerData().queue],
(state) => {
const isTop = imageState.current === 0;
const queue = state[1] as PlayerData['queue'];
const currentImageUrl = scaleImageUrl(queue.current?.imageUrl);
const nextImageUrl = scaleImageUrl(queue.next?.imageUrl);
const currentImageUrl = scaleImageUrl(queue.current?.imageUrl);
const nextImageUrl = scaleImageUrl(queue.next?.imageUrl);
setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl,
current: isTop ? 1 : 0,
topImage: isTop ? nextImageUrl : currentImageUrl,
});
},
{ equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id },
);
setImageState({
bottomImage: isTop ? currentImageUrl : nextImageUrl,
current: isTop ? 1 : 0,
topImage: isTop ? nextImageUrl : currentImageUrl,
});
},
{ equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id },
);
return () => {
unsubSongChange();
};
}, [imageState, queue, setImageState]);
return () => {
unsubSongChange();
};
}, [imageState, queue, setImageState]);
return (
<PlayerContainer
align="center"
className="full-screen-player-image-container"
direction="column"
justify="flex-start"
p="1rem"
>
<ImageContainer>
<AnimatePresence
initial={false}
mode="sync"
>
{imageState.current === 0 && (
<ImageWithPlaceholder
key={imageKey}
animate="open"
className="full-screen-player-image"
custom={{ isOpen: imageState.current === 0 }}
draggable={false}
exit="closed"
initial="closed"
placeholder="var(--placeholder-bg)"
src={imageState.topImage || ''}
useAspectRatio={useImageAspectRatio}
variants={imageVariants}
/>
)}
{imageState.current === 1 && (
<ImageWithPlaceholder
key={imageKey}
animate="open"
className="full-screen-player-image"
custom={{ isOpen: imageState.current === 1 }}
draggable={false}
exit="closed"
initial="closed"
placeholder="var(--placeholder-bg)"
src={imageState.bottomImage || ''}
useAspectRatio={useImageAspectRatio}
variants={imageVariants}
/>
)}
</AnimatePresence>
</ImageContainer>
<MetadataContainer
className="full-screen-player-image-metadata"
maw="100%"
spacing="xs"
>
<TextTitle
align="center"
order={1}
overflow="hidden"
w="100%"
weight={900}
>
{currentSong?.name}
</TextTitle>
<TextTitle
$link
$secondary
align="center"
component={Link}
order={2}
overflow="hidden"
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: currentSong?.albumId || '',
})}
w="100%"
weight={600}
>
{currentSong?.album}{' '}
</TextTitle>
{currentSong?.artists?.map((artist, index) => (
<TextTitle
key={`fs-artist-${artist.id}`}
$secondary
return (
<PlayerContainer
align="center"
order={4}
>
{index > 0 && (
<Text
sx={{
display: 'inline-block',
padding: '0 0.5rem',
}}
>
</Text>
)}
<Text
$link
$secondary
component={Link}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
weight={600}
className="full-screen-player-image-container"
direction="column"
justify="flex-start"
p="1rem"
>
<ImageContainer>
<AnimatePresence
initial={false}
mode="sync"
>
{imageState.current === 0 && (
<ImageWithPlaceholder
key={imageKey}
animate="open"
className="full-screen-player-image"
custom={{ isOpen: imageState.current === 0 }}
draggable={false}
exit="closed"
initial="closed"
placeholder="var(--placeholder-bg)"
src={imageState.topImage || ''}
useAspectRatio={useImageAspectRatio}
variants={imageVariants}
/>
)}
{imageState.current === 1 && (
<ImageWithPlaceholder
key={imageKey}
animate="open"
className="full-screen-player-image"
custom={{ isOpen: imageState.current === 1 }}
draggable={false}
exit="closed"
initial="closed"
placeholder="var(--placeholder-bg)"
src={imageState.bottomImage || ''}
useAspectRatio={useImageAspectRatio}
variants={imageVariants}
/>
)}
</AnimatePresence>
</ImageContainer>
<MetadataContainer
className="full-screen-player-image-metadata"
maw="100%"
spacing="xs"
>
{artist.name}
</Text>
</TextTitle>
))}
<Group position="center">
{currentSong?.container && (
<Badge size="lg">
{currentSong?.container} {currentSong?.bitRate}
</Badge>
)}
{currentSong?.releaseYear && <Badge size="lg">{currentSong?.releaseYear}</Badge>}
</Group>
</MetadataContainer>
</PlayerContainer>
);
<TextTitle
align="center"
order={1}
overflow="hidden"
w="100%"
weight={900}
>
{currentSong?.name}
</TextTitle>
<TextTitle
$link
$secondary
align="center"
component={Link}
order={2}
overflow="hidden"
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: currentSong?.albumId || '',
})}
w="100%"
weight={600}
>
{currentSong?.album}{' '}
</TextTitle>
{currentSong?.artists?.map((artist, index) => (
<TextTitle
key={`fs-artist-${artist.id}`}
$secondary
align="center"
order={4}
>
{index > 0 && (
<Text
sx={{
display: 'inline-block',
padding: '0 0.5rem',
}}
>
</Text>
)}
<Text
$link
$secondary
component={Link}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
weight={600}
>
{artist.name}
</Text>
</TextTitle>
))}
<Group position="center">
{currentSong?.container && (
<Badge size="lg">
{currentSong?.container} {currentSong?.bitRate}
</Badge>
)}
{currentSong?.releaseYear && (
<Badge size="lg">{currentSong?.releaseYear}</Badge>
)}
</Group>
</MetadataContainer>
</PlayerContainer>
);
};

View file

@ -6,122 +6,122 @@ import styled from 'styled-components';
import { Button, TextTitle } from '/@/renderer/components';
import { PlayQueue } from '/@/renderer/features/now-playing';
import {
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
} from '/@/renderer/store/full-screen-player.store';
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
const QueueContainer = styled.div`
position: relative;
display: flex;
height: 100%;
position: relative;
display: flex;
height: 100%;
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
--ag-background-color: rgba(0, 0, 0, 0%) !important;
--ag-odd-row-background-color: rgba(0, 0, 0, 0%) !important;
}
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
--ag-background-color: rgba(0, 0, 0, 0%) !important;
--ag-odd-row-background-color: rgba(0, 0, 0, 0%) !important;
}
.ag-header {
display: none !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);
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--main-fg);
`;
const HeaderItemWrapper = styled.div`
position: relative;
z-index: 2;
position: relative;
z-index: 2;
`;
const GridContainer = styled.div`
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: 1fr;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: 1fr;
`;
export const FullScreenPlayerQueue = () => {
const { activeTab } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const { activeTab } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const headerItems = [
{
active: activeTab === 'queue',
icon: <RiFileMusicLine size="1.5rem" />,
label: 'Up Next',
onClick: () => setStore({ activeTab: 'queue' }),
},
{
active: activeTab === 'related',
icon: <HiOutlineQueueList size="1.5rem" />,
label: 'Related',
onClick: () => setStore({ activeTab: 'related' }),
},
{
active: activeTab === 'lyrics',
icon: <RiFileTextLine size="1.5rem" />,
label: 'Lyrics',
onClick: () => setStore({ activeTab: 'lyrics' }),
},
];
const headerItems = [
{
active: activeTab === 'queue',
icon: <RiFileMusicLine size="1.5rem" />,
label: 'Up Next',
onClick: () => setStore({ activeTab: 'queue' }),
},
{
active: activeTab === 'related',
icon: <HiOutlineQueueList size="1.5rem" />,
label: 'Related',
onClick: () => setStore({ activeTab: 'related' }),
},
{
active: activeTab === 'lyrics',
icon: <RiFileTextLine size="1.5rem" />,
label: 'Lyrics',
onClick: () => setStore({ activeTab: 'lyrics' }),
},
];
return (
<GridContainer className="full-screen-player-queue-container">
<Group
grow
align="center"
position="center"
>
{headerItems.map((item) => (
<HeaderItemWrapper key={`tab-${item.label}`}>
<Button
fullWidth
uppercase
fw="600"
pos="relative"
size="lg"
sx={{
alignItems: 'center',
color: item.active
? 'var(--main-fg) !important'
: 'var(--main-fg-secondary) !important',
letterSpacing: '1px',
}}
variant="subtle"
onClick={item.onClick}
return (
<GridContainer className="full-screen-player-queue-container">
<Group
grow
align="center"
position="center"
>
{item.label}
</Button>
{item.active ? <ActiveTabIndicator layoutId="underline" /> : null}
</HeaderItemWrapper>
))}
</Group>
{activeTab === 'queue' ? (
<QueueContainer>
<PlayQueue type="fullScreen" />
</QueueContainer>
) : activeTab === 'related' ? (
<Center>
<Group>
<RiInformationFill size="2rem" />
<TextTitle
order={3}
weight={700}
>
COMING SOON
</TextTitle>
</Group>
</Center>
) : activeTab === 'lyrics' ? (
<Lyrics />
) : null}
</GridContainer>
);
{headerItems.map((item) => (
<HeaderItemWrapper key={`tab-${item.label}`}>
<Button
fullWidth
uppercase
fw="600"
pos="relative"
size="lg"
sx={{
alignItems: 'center',
color: item.active
? 'var(--main-fg) !important'
: 'var(--main-fg-secondary) !important',
letterSpacing: '1px',
}}
variant="subtle"
onClick={item.onClick}
>
{item.label}
</Button>
{item.active ? <ActiveTabIndicator layoutId="underline" /> : null}
</HeaderItemWrapper>
))}
</Group>
{activeTab === 'queue' ? (
<QueueContainer>
<PlayQueue type="fullScreen" />
</QueueContainer>
) : activeTab === 'related' ? (
<Center>
<Group>
<RiInformationFill size="2rem" />
<TextTitle
order={3}
weight={700}
>
COMING SOON
</TextTitle>
</Group>
</Center>
) : activeTab === 'lyrics' ? (
<Lyrics />
) : null}
</GridContainer>
);
};

View file

@ -7,10 +7,10 @@ import { useLocation } from 'react-router';
import styled from 'styled-components';
import { Button, Option, Popover, Switch } from '/@/renderer/components';
import {
useCurrentSong,
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
useWindowSettings,
useCurrentSong,
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
useWindowSettings,
} from '/@/renderer/store';
import { useFastAverageColor } from '../../../hooks/use-fast-average-color';
import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
@ -19,197 +19,197 @@ import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
import { Platform } from '/@/renderer/types';
const Container = styled(motion.div)`
position: absolute;
top: 0;
left: 0;
z-index: 200;
display: flex;
justify-content: center;
padding: 2rem;
position: absolute;
top: 0;
left: 0;
z-index: 200;
display: flex;
justify-content: center;
padding: 2rem;
@media screen and (max-width: 768px) {
padding: 2rem 2rem 1rem;
}
@media screen and (max-width: 768px) {
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;
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 (max-width: 768px) {
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
margin-top: 0;
}
@media screen and (max-width: 768px) {
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
margin-top: 0;
}
`;
const BackgroundImageOverlay = styled.div`
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(20, 21, 23, 40%), var(--main-bg));
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(20, 21, 23, 40%), var(--main-bg));
`;
const Controls = () => {
const { dynamicBackground, expanded, useImageAspectRatio } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const { dynamicBackground, expanded, useImageAspectRatio } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const handleToggleFullScreenPlayer = () => {
setStore({ expanded: !expanded });
};
const handleToggleFullScreenPlayer = () => {
setStore({ expanded: !expanded });
};
useHotkeys([['Escape', handleToggleFullScreenPlayer]]);
useHotkeys([['Escape', handleToggleFullScreenPlayer]]);
return (
<Group
p="1rem"
pos="absolute"
spacing="sm"
sx={{
left: 0,
top: 10,
}}
>
<Button
compact
size="sm"
tooltip={{ label: 'Minimize' }}
variant="subtle"
onClick={handleToggleFullScreenPlayer}
>
<RiArrowDownSLine size="2rem" />
</Button>
<Popover position="bottom-start">
<Popover.Target>
<Button
compact
size="sm"
tooltip={{ label: 'Configure' }}
variant="subtle"
>
<RiSettings3Line size="1.5rem" />
</Button>
</Popover.Target>
<Popover.Dropdown>
<Option>
<Option.Label>Dynamic Background</Option.Label>
<Option.Control>
<Switch
defaultChecked={dynamicBackground}
onChange={(e) =>
setStore({
dynamicBackground: e.target.checked,
})
}
/>
</Option.Control>
</Option>
<Option>
<Option.Label>Use image aspect ratio</Option.Label>
<Option.Control>
<Switch
defaultChecked={useImageAspectRatio}
onChange={(e) =>
setStore({
useImageAspectRatio: e.target.checked,
})
}
/>
</Option.Control>
</Option>
<TableConfigDropdown type="fullScreen" />
</Popover.Dropdown>
</Popover>
</Group>
);
return (
<Group
p="1rem"
pos="absolute"
spacing="sm"
sx={{
left: 0,
top: 10,
}}
>
<Button
compact
size="sm"
tooltip={{ label: 'Minimize' }}
variant="subtle"
onClick={handleToggleFullScreenPlayer}
>
<RiArrowDownSLine size="2rem" />
</Button>
<Popover position="bottom-start">
<Popover.Target>
<Button
compact
size="sm"
tooltip={{ label: 'Configure' }}
variant="subtle"
>
<RiSettings3Line size="1.5rem" />
</Button>
</Popover.Target>
<Popover.Dropdown>
<Option>
<Option.Label>Dynamic Background</Option.Label>
<Option.Control>
<Switch
defaultChecked={dynamicBackground}
onChange={(e) =>
setStore({
dynamicBackground: e.target.checked,
})
}
/>
</Option.Control>
</Option>
<Option>
<Option.Label>Use image aspect ratio</Option.Label>
<Option.Control>
<Switch
defaultChecked={useImageAspectRatio}
onChange={(e) =>
setStore({
useImageAspectRatio: e.target.checked,
})
}
/>
</Option.Control>
</Option>
<TableConfigDropdown type="fullScreen" />
</Popover.Dropdown>
</Popover>
</Group>
);
};
const containerVariants: Variants = {
closed: (custom) => {
const { windowBarStyle } = custom;
return {
height:
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? 'calc(100vh - 120px)'
: 'calc(100vh - 90px)',
position: 'absolute',
top: '100vh',
transition: {
duration: 0.5,
ease: 'easeInOut',
},
width: '100vw',
y: -100,
};
},
open: (custom) => {
const { dynamicBackground, background, windowBarStyle } = custom;
return {
background: dynamicBackground ? background : 'var(--main-bg)',
height:
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? 'calc(100vh - 120px)'
: 'calc(100vh - 90px)',
left: 0,
position: 'absolute',
top: 0,
transition: {
background: {
duration: 0.5,
ease: 'easeInOut',
},
delay: 0.1,
duration: 0.5,
ease: 'easeInOut',
},
width: '100vw',
y: 0,
};
},
closed: (custom) => {
const { windowBarStyle } = custom;
return {
height:
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? 'calc(100vh - 120px)'
: 'calc(100vh - 90px)',
position: 'absolute',
top: '100vh',
transition: {
duration: 0.5,
ease: 'easeInOut',
},
width: '100vw',
y: -100,
};
},
open: (custom) => {
const { dynamicBackground, background, windowBarStyle } = custom;
return {
background: dynamicBackground ? background : 'var(--main-bg)',
height:
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? 'calc(100vh - 120px)'
: 'calc(100vh - 90px)',
left: 0,
position: 'absolute',
top: 0,
transition: {
background: {
duration: 0.5,
ease: 'easeInOut',
},
delay: 0.1,
duration: 0.5,
ease: 'easeInOut',
},
width: '100vw',
y: 0,
};
},
};
export const FullScreenPlayer = () => {
const { dynamicBackground } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const { windowBarStyle } = useWindowSettings();
const { dynamicBackground } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const { windowBarStyle } = useWindowSettings();
const location = useLocation();
const isOpenedRef = useRef<boolean | null>(null);
const location = useLocation();
const isOpenedRef = useRef<boolean | null>(null);
useLayoutEffect(() => {
if (isOpenedRef.current !== null) {
setStore({ expanded: false });
}
useLayoutEffect(() => {
if (isOpenedRef.current !== null) {
setStore({ expanded: false });
}
isOpenedRef.current = true;
}, [location, setStore]);
isOpenedRef.current = true;
}, [location, setStore]);
const currentSong = useCurrentSong();
const background = useFastAverageColor(currentSong?.imageUrl, true, 'dominant');
const currentSong = useCurrentSong();
const background = useFastAverageColor(currentSong?.imageUrl, true, 'dominant');
return (
<Container
animate="open"
custom={{ background, dynamicBackground, windowBarStyle }}
exit="closed"
initial="closed"
transition={{ duration: 2 }}
variants={containerVariants}
>
<Controls />
{dynamicBackground && <BackgroundImageOverlay />}
<ResponsiveContainer>
<FullScreenPlayerImage />
<FullScreenPlayerQueue />
</ResponsiveContainer>
</Container>
);
return (
<Container
animate="open"
custom={{ background, dynamicBackground, windowBarStyle }}
exit="closed"
initial="closed"
transition={{ duration: 2 }}
variants={containerVariants}
>
<Controls />
{dynamicBackground && <BackgroundImageOverlay />}
<ResponsiveContainer>
<FullScreenPlayerImage />
<FullScreenPlayerQueue />
</ResponsiveContainer>
</Container>
);
};

View file

@ -8,12 +8,12 @@ import styled from 'styled-components';
import { Button, Text, Tooltip } from '/@/renderer/components';
import { AppRoute } from '/@/renderer/router/routes';
import {
useAppStoreActions,
useCurrentSong,
useSetFullScreenPlayerStore,
useFullScreenPlayerStore,
useSidebarStore,
useHotkeySettings,
useAppStoreActions,
useCurrentSong,
useSetFullScreenPlayerStore,
useFullScreenPlayerStore,
useSidebarStore,
useHotkeySettings,
} from '/@/renderer/store';
import { fadeIn } from '/@/renderer/styles';
import { LibraryItem } from '/@/renderer/api/types';
@ -21,254 +21,261 @@ import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/conte
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
const ImageWrapper = styled.div`
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem 1rem 1rem 0;
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;
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;
background-color: var(--placeholder-bg);
cursor: pointer;
filter: drop-shadow(0 5px 6px rgb(0, 0, 0, 50%));
position: relative;
width: 60px;
height: 60px;
background-color: var(--placeholder-bg);
cursor: pointer;
filter: drop-shadow(0 5px 6px rgb(0, 0, 0, 50%));
${fadeIn};
animation: fadein 0.2s ease-in-out;
${fadeIn};
animation: fadein 0.2s ease-in-out;
button {
display: none;
}
button {
display: none;
}
&:hover button {
display: block;
}
&:hover button {
display: block;
}
`;
const PlayerbarImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
`;
const LineItem = styled.div<{ $secondary?: boolean }>`
display: inline-block;
width: 95%;
max-width: 20vw;
overflow: hidden;
color: ${(props) => props.$secondary && 'var(--main-fg-secondary)'};
line-height: 1.3;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
width: 95%;
max-width: 20vw;
overflow: hidden;
color: ${(props) => props.$secondary && 'var(--main-fg-secondary)'};
line-height: 1.3;
white-space: nowrap;
text-overflow: ellipsis;
a {
color: ${(props) => props.$secondary && 'var(--text-secondary)'};
}
a {
color: ${(props) => props.$secondary && 'var(--text-secondary)'};
}
`;
const LeftControlsContainer = styled.div`
display: flex;
width: 100%;
height: 100%;
padding-left: 1rem;
display: flex;
width: 100%;
height: 100%;
padding-left: 1rem;
@media (max-width: 640px) {
${ImageWrapper} {
display: none;
@media (max-width: 640px) {
${ImageWrapper} {
display: none;
}
}
}
`;
export const LeftControls = () => {
const { setSideBar } = useAppStoreActions();
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
const { image, collapsed } = useSidebarStore();
const hideImage = image && !collapsed;
const currentSong = useCurrentSong();
const title = currentSong?.name;
const artists = currentSong?.artists;
const { bindings } = useHotkeySettings();
const { setSideBar } = useAppStoreActions();
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
const { image, collapsed } = useSidebarStore();
const hideImage = image && !collapsed;
const currentSong = useCurrentSong();
const title = currentSong?.name;
const artists = currentSong?.artists;
const { bindings } = useHotkeySettings();
const isSongDefined = Boolean(currentSong?.id);
const isSongDefined = Boolean(currentSong?.id);
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.SONG,
SONG_CONTEXT_MENU_ITEMS,
);
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.SONG,
SONG_CONTEXT_MENU_ITEMS,
);
const handleToggleFullScreenPlayer = (e?: MouseEvent<HTMLDivElement> | KeyboardEvent) => {
e?.stopPropagation();
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
};
const handleToggleFullScreenPlayer = (e?: MouseEvent<HTMLDivElement> | KeyboardEvent) => {
e?.stopPropagation();
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
};
const handleToggleSidebarImage = (e?: MouseEvent<HTMLButtonElement>) => {
e?.stopPropagation();
setSideBar({ image: true });
};
const handleToggleSidebarImage = (e?: MouseEvent<HTMLButtonElement>) => {
e?.stopPropagation();
setSideBar({ image: true });
};
useHotkeys([
[
bindings.toggleFullscreenPlayer.allowGlobal ? '' : bindings.toggleFullscreenPlayer.hotkey,
handleToggleFullScreenPlayer,
],
]);
useHotkeys([
[
bindings.toggleFullscreenPlayer.allowGlobal
? ''
: bindings.toggleFullscreenPlayer.hotkey,
handleToggleFullScreenPlayer,
],
]);
return (
<LeftControlsContainer>
<LayoutGroup>
<AnimatePresence
initial={false}
mode="wait"
>
{!hideImage && (
<ImageWrapper>
<Image
key="playerbar-image"
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
initial={{ opacity: 0, x: -50 }}
role="button"
transition={{ duration: 0.3, ease: 'easeInOut' }}
onClick={handleToggleFullScreenPlayer}
>
<Tooltip
label="Toggle fullscreen player"
openDelay={500}
return (
<LeftControlsContainer>
<LayoutGroup>
<AnimatePresence
initial={false}
mode="wait"
>
{currentSong?.imageUrl ? (
<PlayerbarImage
loading="eager"
src={currentSong?.imageUrl}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
height: '100%',
}}
>
<RiDiscLine
color="var(--placeholder-fg)"
size={50}
/>
</Center>
)}
</Tooltip>
{!hideImage && (
<ImageWrapper>
<Image
key="playerbar-image"
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
initial={{ opacity: 0, x: -50 }}
role="button"
transition={{ duration: 0.3, ease: 'easeInOut' }}
onClick={handleToggleFullScreenPlayer}
>
<Tooltip
label="Toggle fullscreen player"
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>
)}
</Tooltip>
{!collapsed && (
<Button
compact
opacity={0.8}
radius={50}
size="md"
sx={{ cursor: 'default', position: 'absolute', right: 2, top: 2 }}
tooltip={{ label: 'Expand', openDelay: 500 }}
variant="default"
onClick={handleToggleSidebarImage}
>
<RiArrowUpSLine
color="white"
size={20}
/>
</Button>
)}
</Image>
</ImageWrapper>
)}
</AnimatePresence>
<MetadataStack layout="position">
<LineItem>
<Group
noWrap
align="flex-start"
spacing="xs"
>
<Text
$link
component={Link}
overflow="hidden"
size="md"
to={AppRoute.NOW_PLAYING}
weight={500}
>
{title || '—'}
</Text>
{isSongDefined && (
<Button
compact
variant="subtle"
onClick={(e) => handleGeneralContextMenu(e, [currentSong!])}
>
<RiMore2Fill size="1.2rem" />
</Button>
)}
</Group>
</LineItem>
<LineItem $secondary>
{artists?.map((artist, index) => (
<React.Fragment key={`bar-${artist.id}`}>
{index > 0 && (
<Text
$link
$secondary
size="md"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
component={Link}
overflow="hidden"
size="md"
to={
artist.id
? generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})
: ''
}
weight={500}
>
{artist.name || '—'}
</Text>
</React.Fragment>
))}
</LineItem>
<LineItem $secondary>
<Text
$link
component={Link}
overflow="hidden"
size="md"
to={
currentSong?.albumId
? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: currentSong.albumId,
})
: ''
}
weight={500}
>
{currentSong?.album || '—'}
</Text>
</LineItem>
</MetadataStack>
</LayoutGroup>
</LeftControlsContainer>
);
{!collapsed && (
<Button
compact
opacity={0.8}
radius={50}
size="md"
sx={{
cursor: 'default',
position: 'absolute',
right: 2,
top: 2,
}}
tooltip={{ label: 'Expand', openDelay: 500 }}
variant="default"
onClick={handleToggleSidebarImage}
>
<RiArrowUpSLine
color="white"
size={20}
/>
</Button>
)}
</Image>
</ImageWrapper>
)}
</AnimatePresence>
<MetadataStack layout="position">
<LineItem>
<Group
noWrap
align="flex-start"
spacing="xs"
>
<Text
$link
component={Link}
overflow="hidden"
size="md"
to={AppRoute.NOW_PLAYING}
weight={500}
>
{title || '—'}
</Text>
{isSongDefined && (
<Button
compact
variant="subtle"
onClick={(e) => handleGeneralContextMenu(e, [currentSong!])}
>
<RiMore2Fill size="1.2rem" />
</Button>
)}
</Group>
</LineItem>
<LineItem $secondary>
{artists?.map((artist, index) => (
<React.Fragment key={`bar-${artist.id}`}>
{index > 0 && (
<Text
$link
$secondary
size="md"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
<Text
$link
component={Link}
overflow="hidden"
size="md"
to={
artist.id
? generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})
: ''
}
weight={500}
>
{artist.name || '—'}
</Text>
</React.Fragment>
))}
</LineItem>
<LineItem $secondary>
<Text
$link
component={Link}
overflow="hidden"
size="md"
to={
currentSong?.albumId
? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: currentSong.albumId,
})
: ''
}
weight={500}
>
{currentSong?.album || '—'}
</Text>
</LineItem>
</MetadataStack>
</LayoutGroup>
</LeftControlsContainer>
);
};

View file

@ -8,153 +8,154 @@ import { Tooltip } from '/@/renderer/components';
type MantineButtonProps = UnstyledButtonProps & ComponentPropsWithoutRef<'button'>;
interface PlayerButtonProps extends MantineButtonProps {
$isActive?: boolean;
icon: ReactNode;
tooltip?: Omit<TooltipProps, 'children'>;
variant: 'main' | 'secondary' | 'tertiary';
$isActive?: boolean;
icon: ReactNode;
tooltip?: Omit<TooltipProps, 'children'>;
variant: 'main' | 'secondary' | 'tertiary';
}
const WrapperMainVariant = css`
margin: 0 0.5rem;
margin: 0 0.5rem;
`;
type MotionWrapperProps = { variant: PlayerButtonProps['variant'] };
const MotionWrapper = styled(motion.div)<MotionWrapperProps>`
display: flex;
align-items: center;
justify-content: center;
display: flex;
align-items: center;
justify-content: center;
${({ variant }) => variant === 'main' && WrapperMainVariant};
${({ 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);
padding: 0.5rem;
background: var(--playerbar-btn-main-bg);
border-radius: 50%;
svg {
fill: var(--playerbar-btn-main-fg-hover);
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;
padding: 0.5rem;
`;
const ButtonTertiaryVariant = css`
padding: 0.5rem;
padding: 0.5rem;
svg {
display: flex;
}
&:focus-visible {
svg {
fill: var(--playerbar-btn-fg-hover);
stroke: var(--playerbar-btn-fg-hover);
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>`
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem;
overflow: visible;
background: var(--playerbar-btn-bg-hover);
all: unset;
cursor: default;
button {
display: flex;
}
&:focus-visible {
align-items: center;
width: 100%;
padding: 0.5rem;
overflow: visible;
background: var(--playerbar-btn-bg-hover);
outline: 1px var(--primary-color) solid;
}
all: unset;
cursor: default;
&:disabled {
opacity: 0.5;
}
button {
display: flex;
}
svg {
display: flex;
fill: ${({ $isActive }) => ($isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)')};
stroke: var(--playerbar-btn-fg);
}
&:focus-visible {
background: var(--playerbar-btn-bg-hover);
outline: 1px var(--primary-color) solid;
}
&:hover {
background: var(--playerbar-btn-bg-hover);
&:disabled {
opacity: 0.5;
}
svg {
fill: ${({ $isActive }) =>
$isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg-hover)'};
display: flex;
fill: ${({ $isActive }) =>
$isActive ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'};
stroke: var(--playerbar-btn-fg);
}
}
${({ variant }) =>
variant === 'main'
? ButtonMainVariant
: variant === 'secondary'
? ButtonSecondaryVariant
: ButtonTertiaryVariant};
&: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>(
({ tooltip, variant, icon, ...rest }: PlayerButtonProps, ref) => {
if (tooltip) {
return (
<Tooltip {...tooltip}>
<MotionWrapper
ref={ref}
variant={variant}
>
<StyledPlayerButton
variant={variant}
{...rest}
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
</Tooltip>
);
}
({ tooltip, variant, icon, ...rest }: PlayerButtonProps, ref) => {
if (tooltip) {
return (
<Tooltip {...tooltip}>
<MotionWrapper
ref={ref}
variant={variant}
>
<StyledPlayerButton
variant={variant}
{...rest}
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
</Tooltip>
);
}
return (
<MotionWrapper
ref={ref}
variant={variant}
>
<StyledPlayerButton
variant={variant}
{...rest}
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
);
},
return (
<MotionWrapper
ref={ref}
variant={variant}
>
<StyledPlayerButton
variant={variant}
{...rest}
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
);
},
);
PlayerButton.defaultProps = {
$isActive: false,
tooltip: undefined,
$isActive: false,
tooltip: undefined,
};

View file

@ -1,46 +1,46 @@
import { rem, Slider, SliderProps } from '@mantine/core';
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)',
},
},
}}
{...props}
/>
);
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)',
},
},
}}
{...props}
/>
);
};

View file

@ -5,13 +5,13 @@ import { useSettingsStore } from '/@/renderer/store/settings.store';
import { PlaybackType } from '/@/renderer/types';
import { AudioPlayer } from '/@/renderer/components';
import {
useCurrentPlayer,
useCurrentStatus,
useMuted,
usePlayer1Data,
usePlayer2Data,
usePlayerControls,
useVolume,
useCurrentPlayer,
useCurrentStatus,
useMuted,
usePlayer1Data,
usePlayer2Data,
usePlayerControls,
useVolume,
} from '/@/renderer/store';
import { CenterControls } from './center-controls';
import { LeftControls } from './left-controls';
@ -19,97 +19,97 @@ import { RightControls } from './right-controls';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
const PlayerbarContainer = styled.div`
width: 100vw;
height: 100%;
border-top: var(--playerbar-border-top);
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%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 1rem;
height: 100%;
@media (max-width: 768px) {
grid-template-columns: minmax(0, 0.5fr) minmax(0, 1fr) minmax(0, 0.5fr);
}
@media (max-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;
align-self: center;
width: 100%;
height: 100%;
overflow: hidden;
`;
const LeftGridItem = styled.div`
width: 100%;
height: 100%;
overflow: hidden;
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;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
`;
const utils = isElectron() ? window.electron.utils : null;
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
export const Playerbar = () => {
const playersRef = PlayersRef;
const settings = useSettingsStore((state) => state.playback);
const volume = useVolume();
const player1 = usePlayer1Data();
const player2 = usePlayer2Data();
const status = useCurrentStatus();
const player = useCurrentPlayer();
const muted = useMuted();
const { autoNext } = usePlayerControls();
const playersRef = PlayersRef;
const settings = useSettingsStore((state) => state.playback);
const volume = useVolume();
const player1 = usePlayer1Data();
const player2 = usePlayer2Data();
const status = useCurrentStatus();
const player = useCurrentPlayer();
const muted = useMuted();
const { autoNext } = usePlayerControls();
const autoNextFn = useCallback(() => {
const playerData = autoNext();
mpris?.updateSong({
currentTime: 0,
song: playerData.current.song,
});
}, [autoNext]);
const autoNextFn = useCallback(() => {
const playerData = autoNext();
mpris?.updateSong({
currentTime: 0,
song: playerData.current.song,
});
}, [autoNext]);
return (
<PlayerbarContainer>
<PlayerbarControlsGrid>
<LeftGridItem>
<LeftControls />
</LeftGridItem>
<CenterGridItem>
<CenterControls playersRef={playersRef} />
</CenterGridItem>
<RightGridItem>
<RightControls />
</RightGridItem>
</PlayerbarControlsGrid>
{settings.type === PlaybackType.WEB && (
<AudioPlayer
ref={playersRef}
autoNext={autoNextFn}
crossfadeDuration={settings.crossfadeDuration}
crossfadeStyle={settings.crossfadeStyle}
currentPlayer={player}
muted={muted}
playbackStyle={settings.style}
player1={player1}
player2={player2}
status={status}
style={settings.style}
volume={(volume / 100) ** 2}
/>
)}
</PlayerbarContainer>
);
return (
<PlayerbarContainer>
<PlayerbarControlsGrid>
<LeftGridItem>
<LeftControls />
</LeftGridItem>
<CenterGridItem>
<CenterControls playersRef={playersRef} />
</CenterGridItem>
<RightGridItem>
<RightControls />
</RightGridItem>
</PlayerbarControlsGrid>
{settings.type === PlaybackType.WEB && (
<AudioPlayer
ref={playersRef}
autoNext={autoNextFn}
crossfadeDuration={settings.crossfadeDuration}
crossfadeStyle={settings.crossfadeStyle}
currentPlayer={player}
muted={muted}
playbackStyle={settings.style}
player1={player1}
player2={player2}
status={status}
style={settings.style}
volume={(volume / 100) ** 2}
/>
)}
</PlayerbarContainer>
);
};

View file

@ -3,20 +3,20 @@ import { Flex, Group } from '@mantine/core';
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
import { HiOutlineQueueList } from 'react-icons/hi2';
import {
RiVolumeUpFill,
RiVolumeDownFill,
RiVolumeMuteFill,
RiHeartLine,
RiHeartFill,
RiVolumeUpFill,
RiVolumeDownFill,
RiVolumeMuteFill,
RiHeartLine,
RiHeartFill,
} from 'react-icons/ri';
import {
useAppStoreActions,
useCurrentServer,
useCurrentSong,
useHotkeySettings,
useMuted,
useSidebarStore,
useVolume,
useAppStoreActions,
useCurrentServer,
useCurrentSong,
useHotkeySettings,
useMuted,
useSidebarStore,
useVolume,
} from '/@/renderer/store';
import { useRightControls } from '../hooks/use-right-controls';
import { PlayerButton } from './player-button';
@ -26,178 +26,180 @@ import { Rating } from '/@/renderer/components';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
export const RightControls = () => {
const isMinWidth = useMediaQuery('(max-width: 480px)');
const volume = useVolume();
const muted = useMuted();
const server = useCurrentServer();
const currentSong = useCurrentSong();
const { setSideBar } = useAppStoreActions();
const { rightExpanded: isQueueExpanded } = useSidebarStore();
const { bindings } = useHotkeySettings();
const { handleVolumeSlider, handleVolumeWheel, handleMute, handleVolumeDown, handleVolumeUp } =
useRightControls();
const isMinWidth = useMediaQuery('(max-width: 480px)');
const volume = useVolume();
const muted = useMuted();
const server = useCurrentServer();
const currentSong = useCurrentSong();
const { setSideBar } = useAppStoreActions();
const { rightExpanded: isQueueExpanded } = useSidebarStore();
const { bindings } = useHotkeySettings();
const { handleVolumeSlider, handleVolumeWheel, handleMute, handleVolumeDown, handleVolumeUp } =
useRightControls();
const updateRatingMutation = useSetRating({});
const addToFavoritesMutation = useCreateFavorite({});
const removeFromFavoritesMutation = useDeleteFavorite({});
const updateRatingMutation = useSetRating({});
const addToFavoritesMutation = useCreateFavorite({});
const removeFromFavoritesMutation = useDeleteFavorite({});
const handleAddToFavorites = () => {
if (!currentSong) return;
const handleAddToFavorites = () => {
if (!currentSong) return;
addToFavoritesMutation.mutate({
query: {
id: [currentSong.id],
type: LibraryItem.SONG,
},
serverId: currentSong?.serverId,
});
};
const handleUpdateRating = (rating: number) => {
if (!currentSong) return;
updateRatingMutation.mutate({
query: {
item: [currentSong],
rating,
},
serverId: currentSong?.serverId,
});
};
const handleClearRating = (_e: MouseEvent<HTMLDivElement>, rating?: number) => {
if (!currentSong || !rating) return;
updateRatingMutation.mutate({
query: {
item: [currentSong],
rating: 0,
},
serverId: currentSong?.serverId,
});
};
const handleRemoveFromFavorites = () => {
if (!currentSong) return;
removeFromFavoritesMutation.mutate({
query: {
id: [currentSong.id],
type: LibraryItem.SONG,
},
serverId: currentSong?.serverId,
});
};
const handleToggleFavorite = () => {
if (!currentSong) return;
if (currentSong.userFavorite) {
handleRemoveFromFavorites();
} else {
handleAddToFavorites();
}
};
const handleToggleQueue = () => {
setSideBar({ rightExpanded: !isQueueExpanded });
};
const isSongDefined = Boolean(currentSong?.id);
const showRating = isSongDefined && server?.type === ServerType.NAVIDROME;
useHotkeys([
[bindings.volumeDown.isGlobal ? '' : bindings.volumeDown.hotkey, handleVolumeDown],
[bindings.volumeUp.isGlobal ? '' : bindings.volumeUp.hotkey, handleVolumeUp],
[bindings.volumeMute.isGlobal ? '' : bindings.volumeMute.hotkey, handleMute],
[bindings.toggleQueue.isGlobal ? '' : bindings.toggleQueue.hotkey, handleToggleQueue],
]);
return (
<Flex
align="flex-end"
direction="column"
h="100%"
px="1rem"
py="0.5rem"
>
<Group h="calc(100% / 3)">
{showRating && (
<Rating
size="sm"
value={currentSong?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
)}
</Group>
<Group
noWrap
align="center"
spacing="xs"
>
<PlayerButton
icon={
currentSong?.userFavorite ? (
<RiHeartFill
color="var(--primary-color)"
size="1.1rem"
/>
) : (
<RiHeartLine size="1.1rem" />
)
}
sx={{
svg: {
fill: !currentSong?.userFavorite ? undefined : 'var(--primary-color) !important',
addToFavoritesMutation.mutate({
query: {
id: [currentSong.id],
type: LibraryItem.SONG,
},
}}
tooltip={{
label: currentSong?.userFavorite ? 'Unfavorite' : 'Favorite',
openDelay: 500,
}}
variant="secondary"
onClick={handleToggleFavorite}
/>
<PlayerButton
icon={<HiOutlineQueueList size="1.1rem" />}
tooltip={{ label: 'View queue', openDelay: 500 }}
variant="secondary"
onClick={handleToggleQueue}
/>
<Group
noWrap
spacing="xs"
serverId: currentSong?.serverId,
});
};
const handleUpdateRating = (rating: number) => {
if (!currentSong) return;
updateRatingMutation.mutate({
query: {
item: [currentSong],
rating,
},
serverId: currentSong?.serverId,
});
};
const handleClearRating = (_e: MouseEvent<HTMLDivElement>, rating?: number) => {
if (!currentSong || !rating) return;
updateRatingMutation.mutate({
query: {
item: [currentSong],
rating: 0,
},
serverId: currentSong?.serverId,
});
};
const handleRemoveFromFavorites = () => {
if (!currentSong) return;
removeFromFavoritesMutation.mutate({
query: {
id: [currentSong.id],
type: LibraryItem.SONG,
},
serverId: currentSong?.serverId,
});
};
const handleToggleFavorite = () => {
if (!currentSong) return;
if (currentSong.userFavorite) {
handleRemoveFromFavorites();
} else {
handleAddToFavorites();
}
};
const handleToggleQueue = () => {
setSideBar({ rightExpanded: !isQueueExpanded });
};
const isSongDefined = Boolean(currentSong?.id);
const showRating = isSongDefined && server?.type === ServerType.NAVIDROME;
useHotkeys([
[bindings.volumeDown.isGlobal ? '' : bindings.volumeDown.hotkey, handleVolumeDown],
[bindings.volumeUp.isGlobal ? '' : bindings.volumeUp.hotkey, handleVolumeUp],
[bindings.volumeMute.isGlobal ? '' : bindings.volumeMute.hotkey, handleMute],
[bindings.toggleQueue.isGlobal ? '' : bindings.toggleQueue.hotkey, handleToggleQueue],
]);
return (
<Flex
align="flex-end"
direction="column"
h="100%"
px="1rem"
py="0.5rem"
>
<PlayerButton
icon={
muted ? (
<RiVolumeMuteFill size="1.2rem" />
) : volume > 50 ? (
<RiVolumeUpFill size="1.2rem" />
) : (
<RiVolumeDownFill size="1.2rem" />
)
}
tooltip={{ label: muted ? 'Muted' : volume, openDelay: 500 }}
variant="secondary"
onClick={handleMute}
onWheel={handleVolumeWheel}
/>
{!isMinWidth ? (
<PlayerbarSlider
max={100}
min={0}
size={6}
value={volume}
w="60px"
onChange={handleVolumeSlider}
onWheel={handleVolumeWheel}
/>
) : null}
</Group>
</Group>
<Group h="calc(100% / 3)" />
</Flex>
);
<Group h="calc(100% / 3)">
{showRating && (
<Rating
size="sm"
value={currentSong?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
)}
</Group>
<Group
noWrap
align="center"
spacing="xs"
>
<PlayerButton
icon={
currentSong?.userFavorite ? (
<RiHeartFill
color="var(--primary-color)"
size="1.1rem"
/>
) : (
<RiHeartLine size="1.1rem" />
)
}
sx={{
svg: {
fill: !currentSong?.userFavorite
? undefined
: 'var(--primary-color) !important',
},
}}
tooltip={{
label: currentSong?.userFavorite ? 'Unfavorite' : 'Favorite',
openDelay: 500,
}}
variant="secondary"
onClick={handleToggleFavorite}
/>
<PlayerButton
icon={<HiOutlineQueueList size="1.1rem" />}
tooltip={{ label: 'View queue', openDelay: 500 }}
variant="secondary"
onClick={handleToggleQueue}
/>
<Group
noWrap
spacing="xs"
>
<PlayerButton
icon={
muted ? (
<RiVolumeMuteFill size="1.2rem" />
) : volume > 50 ? (
<RiVolumeUpFill size="1.2rem" />
) : (
<RiVolumeDownFill size="1.2rem" />
)
}
tooltip={{ label: muted ? 'Muted' : volume, openDelay: 500 }}
variant="secondary"
onClick={handleMute}
onWheel={handleVolumeWheel}
/>
{!isMinWidth ? (
<PlayerbarSlider
max={100}
min={0}
size={6}
value={volume}
w="60px"
onChange={handleVolumeSlider}
onWheel={handleVolumeWheel}
/>
) : null}
</Group>
</Group>
<Group h="calc(100% / 3)" />
</Flex>
);
};

View file

@ -9,10 +9,10 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import {
GenreListResponse,
RandomSongListQuery,
MusicFolderListResponse,
ServerType,
GenreListResponse,
RandomSongListQuery,
MusicFolderListResponse,
ServerType,
} from '/@/renderer/api/types';
import { api } from '/@/renderer/api';
import { useAuthStore } from '/@/renderer/store';
@ -20,240 +20,240 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { Play, PlayQueueAddOptions, ServerListItem } from '/@/renderer/types';
interface ShuffleAllSlice extends RandomSongListQuery {
actions: {
setStore: (data: Partial<ShuffleAllSlice>) => void;
};
enableMaxYear: boolean;
enableMinYear: boolean;
actions: {
setStore: (data: Partial<ShuffleAllSlice>) => void;
};
enableMaxYear: boolean;
enableMinYear: boolean;
}
const useShuffleAllStore = create<ShuffleAllSlice>()(
persist(
immer((set, get) => ({
actions: {
setStore: (data) => {
set({ ...get(), ...data });
persist(
immer((set, get) => ({
actions: {
setStore: (data) => {
set({ ...get(), ...data });
},
},
enableMaxYear: false,
enableMinYear: false,
genre: '',
maxYear: 2020,
minYear: 2000,
musicFolder: '',
songCount: 100,
})),
{
merge: (persistedState, currentState) => merge(currentState, persistedState),
name: 'store_shuffle_all',
version: 1,
},
},
enableMaxYear: false,
enableMinYear: false,
genre: '',
maxYear: 2020,
minYear: 2000,
musicFolder: '',
songCount: 100,
})),
{
merge: (persistedState, currentState) => merge(currentState, persistedState),
name: 'store_shuffle_all',
version: 1,
},
),
),
);
export const useShuffleAllStoreActions = () => useShuffleAllStore((state) => state.actions);
interface ShuffleAllModalProps {
genres: GenreListResponse | undefined;
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
musicFolders: MusicFolderListResponse | undefined;
queryClient: QueryClient;
server: ServerListItem | null;
genres: GenreListResponse | undefined;
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
musicFolders: MusicFolderListResponse | undefined;
queryClient: QueryClient;
server: ServerListItem | null;
}
export const ShuffleAllModal = ({
handlePlayQueueAdd,
queryClient,
server,
genres,
musicFolders,
handlePlayQueueAdd,
queryClient,
server,
genres,
musicFolders,
}: ShuffleAllModalProps) => {
const { genre, limit, maxYear, minYear, enableMaxYear, enableMinYear, musicFolderId } =
useShuffleAllStore();
const { setStore } = useShuffleAllStoreActions();
const { genre, limit, maxYear, minYear, enableMaxYear, enableMinYear, musicFolderId } =
useShuffleAllStore();
const { setStore } = useShuffleAllStoreActions();
const handlePlay = async (playType: Play) => {
const res = await queryClient.fetchQuery({
cacheTime: 0,
queryFn: ({ signal }) =>
api.controller.getRandomSongList({
apiClientProps: {
server,
signal,
},
query: {
genre: genre || undefined,
limit,
maxYear: enableMaxYear ? maxYear || undefined : undefined,
minYear: enableMinYear ? minYear || undefined : undefined,
musicFolderId: musicFolderId || undefined,
},
}),
queryKey: queryKeys.songs.randomSongList(server?.id),
staleTime: 0,
});
const handlePlay = async (playType: Play) => {
const res = await queryClient.fetchQuery({
cacheTime: 0,
queryFn: ({ signal }) =>
api.controller.getRandomSongList({
apiClientProps: {
server,
signal,
},
query: {
genre: genre || undefined,
limit,
maxYear: enableMaxYear ? maxYear || undefined : undefined,
minYear: enableMinYear ? minYear || undefined : undefined,
musicFolderId: musicFolderId || undefined,
},
}),
queryKey: queryKeys.songs.randomSongList(server?.id),
staleTime: 0,
});
handlePlayQueueAdd?.({
byData: res?.items || [],
playType,
});
handlePlayQueueAdd?.({
byData: res?.items || [],
playType,
});
closeAllModals();
};
closeAllModals();
};
const genreData = useMemo(() => {
if (!genres) return [];
const genreData = useMemo(() => {
if (!genres) return [];
return genres.items.map((genre) => {
const value =
server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC
? genre.name
: genre.id;
return {
label: genre.name,
value,
};
});
}, [genres, server?.type]);
return genres.items.map((genre) => {
const value =
server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC
? genre.name
: genre.id;
return {
label: genre.name,
value,
};
});
}, [genres, server?.type]);
const musicFolderData = useMemo(() => {
if (!musicFolders) return [];
return musicFolders.items.map((musicFolder) => ({
label: musicFolder.name,
value: String(musicFolder.id),
}));
}, [musicFolders]);
const musicFolderData = useMemo(() => {
if (!musicFolders) return [];
return musicFolders.items.map((musicFolder) => ({
label: musicFolder.name,
value: String(musicFolder.id),
}));
}, [musicFolders]);
return (
<Stack spacing="md">
<NumberInput
required
label="How many tracks?"
max={500}
min={1}
value={limit}
onChange={(e) => setStore({ limit: e ? Number(e) : 0 })}
/>
<Group grow>
<NumberInput
label="From year"
max={2050}
min={1850}
rightSection={
<Checkbox
checked={enableMinYear}
mr="0.5rem"
onChange={(e) => setStore({ enableMinYear: e.currentTarget.checked })}
return (
<Stack spacing="md">
<NumberInput
required
label="How many tracks?"
max={500}
min={1}
value={limit}
onChange={(e) => setStore({ limit: e ? Number(e) : 0 })}
/>
}
value={minYear}
onChange={(e) => setStore({ minYear: e ? Number(e) : 0 })}
/>
<Group grow>
<NumberInput
label="From year"
max={2050}
min={1850}
rightSection={
<Checkbox
checked={enableMinYear}
mr="0.5rem"
onChange={(e) => setStore({ enableMinYear: e.currentTarget.checked })}
/>
}
value={minYear}
onChange={(e) => setStore({ minYear: e ? Number(e) : 0 })}
/>
<NumberInput
label="To year"
max={2050}
min={1850}
rightSection={
<Checkbox
checked={enableMaxYear}
mr="0.5rem"
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
<NumberInput
label="To year"
max={2050}
min={1850}
rightSection={
<Checkbox
checked={enableMaxYear}
mr="0.5rem"
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
/>
}
value={maxYear}
onChange={(e) => setStore({ maxYear: e ? Number(e) : 0 })}
/>
</Group>
<Select
clearable
data={genreData}
label="Genre"
value={genre}
onChange={(e) => setStore({ genre: e || '' })}
/>
}
value={maxYear}
onChange={(e) => setStore({ maxYear: e ? Number(e) : 0 })}
/>
</Group>
<Select
clearable
data={genreData}
label="Genre"
value={genre}
onChange={(e) => setStore({ genre: e || '' })}
/>
<Select
clearable
data={musicFolderData}
label="Music folder"
value={musicFolderId}
onChange={(e) => {
setStore({ musicFolderId: e ? String(e) : '' });
}}
/>
<Divider />
<Group grow>
<Button
leftIcon={<RiAddBoxFill size="1rem" />}
type="submit"
variant="default"
onClick={() => handlePlay(Play.LAST)}
>
Add
</Button>
<Button
leftIcon={<RiAddCircleFill size="1rem" />}
type="submit"
variant="default"
onClick={() => handlePlay(Play.NEXT)}
>
Add next
</Button>
</Group>
<Button
leftIcon={<RiPlayFill size="1rem" />}
type="submit"
variant="filled"
onClick={() => handlePlay(Play.NOW)}
>
Play
</Button>
</Stack>
);
<Select
clearable
data={musicFolderData}
label="Music folder"
value={musicFolderId}
onChange={(e) => {
setStore({ musicFolderId: e ? String(e) : '' });
}}
/>
<Divider />
<Group grow>
<Button
leftIcon={<RiAddBoxFill size="1rem" />}
type="submit"
variant="default"
onClick={() => handlePlay(Play.LAST)}
>
Add
</Button>
<Button
leftIcon={<RiAddCircleFill size="1rem" />}
type="submit"
variant="default"
onClick={() => handlePlay(Play.NEXT)}
>
Add next
</Button>
</Group>
<Button
leftIcon={<RiPlayFill size="1rem" />}
type="submit"
variant="filled"
onClick={() => handlePlay(Play.NOW)}
>
Play
</Button>
</Stack>
);
};
export const openShuffleAllModal = async (
props: Pick<ShuffleAllModalProps, 'handlePlayQueueAdd' | 'queryClient'>,
props: Pick<ShuffleAllModalProps, 'handlePlayQueueAdd' | 'queryClient'>,
) => {
const server = useAuthStore.getState().currentServer;
const server = useAuthStore.getState().currentServer;
const genres = await props.queryClient.fetchQuery({
cacheTime: 1000 * 60 * 60 * 4,
queryFn: ({ signal }) =>
api.controller.getGenreList({
apiClientProps: {
server,
signal,
},
query: null,
}),
queryKey: queryKeys.genres.list(server?.id),
staleTime: 1000 * 60 * 5,
});
const genres = await props.queryClient.fetchQuery({
cacheTime: 1000 * 60 * 60 * 4,
queryFn: ({ signal }) =>
api.controller.getGenreList({
apiClientProps: {
server,
signal,
},
query: null,
}),
queryKey: queryKeys.genres.list(server?.id),
staleTime: 1000 * 60 * 5,
});
const musicFolders = await props.queryClient.fetchQuery({
cacheTime: 1000 * 60 * 60 * 4,
queryFn: ({ signal }) =>
api.controller.getMusicFolderList({
apiClientProps: {
server,
signal,
},
}),
queryKey: queryKeys.musicFolders.list(server?.id),
staleTime: 1000 * 60 * 5,
});
const musicFolders = await props.queryClient.fetchQuery({
cacheTime: 1000 * 60 * 60 * 4,
queryFn: ({ signal }) =>
api.controller.getMusicFolderList({
apiClientProps: {
server,
signal,
},
}),
queryKey: queryKeys.musicFolders.list(server?.id),
staleTime: 1000 * 60 * 5,
});
openModal({
children: (
<ShuffleAllModal
genres={genres}
musicFolders={musicFolders}
server={server}
{...props}
/>
),
size: 'sm',
title: 'Shuffle all',
});
openModal({
children: (
<ShuffleAllModal
genres={genres}
musicFolders={musicFolders}
server={server}
{...props}
/>
),
size: 'sm',
title: 'Shuffle all',
});
};

View file

@ -2,7 +2,7 @@ import { createContext } from 'react';
import { PlayQueueAddOptions } from '/@/renderer/types';
export const PlayQueueHandlerContext = createContext<{
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
}>({
handlePlayQueueAdd: undefined,
handlePlayQueueAdd: undefined,
});

File diff suppressed because it is too large Load diff

View file

@ -7,43 +7,43 @@ import { toast } from '/@/renderer/components/toast/index';
import isElectron from 'is-electron';
import { nanoid } from 'nanoid/non-secure';
import {
LibraryItem,
QueueSong,
Song,
SongListResponse,
instanceOfCancellationError,
LibraryItem,
QueueSong,
Song,
SongListResponse,
instanceOfCancellationError,
} from '/@/renderer/api/types';
import {
getPlaylistSongsById,
getSongById,
getAlbumSongsById,
getAlbumArtistSongsById,
getSongsByQuery,
getPlaylistSongsById,
getSongById,
getAlbumSongsById,
getAlbumArtistSongsById,
getSongsByQuery,
} from '/@/renderer/features/player/utils';
import { queryKeys } from '/@/renderer/api/query-keys';
const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
let queryKey;
let queryKey;
switch (itemType) {
case LibraryItem.ALBUM:
queryKey = queryKeys.songs.list(serverId);
break;
case LibraryItem.ALBUM_ARTIST:
queryKey = queryKeys.songs.list(serverId);
break;
case LibraryItem.PLAYLIST:
queryKey = queryKeys.playlists.songList(serverId);
break;
case LibraryItem.SONG:
queryKey = queryKeys.songs.list(serverId);
break;
default:
queryKey = queryKeys.songs.list(serverId);
break;
}
switch (itemType) {
case LibraryItem.ALBUM:
queryKey = queryKeys.songs.list(serverId);
break;
case LibraryItem.ALBUM_ARTIST:
queryKey = queryKeys.songs.list(serverId);
break;
case LibraryItem.PLAYLIST:
queryKey = queryKeys.playlists.songList(serverId);
break;
case LibraryItem.SONG:
queryKey = queryKeys.songs.list(serverId);
break;
default:
queryKey = queryKeys.songs.list(serverId);
break;
}
return queryKey;
return queryKey;
};
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
@ -53,118 +53,133 @@ const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
const addToQueue = usePlayerStore.getState().actions.addToQueue;
export const useHandlePlayQueueAdd = () => {
const queryClient = useQueryClient();
const playerType = usePlayerType();
const server = useCurrentServer();
const { play } = usePlayerControls();
const timeoutIds = useRef<Record<string, ReturnType<typeof setTimeout>> | null>({});
const queryClient = useQueryClient();
const playerType = usePlayerType();
const server = useCurrentServer();
const { play } = usePlayerControls();
const timeoutIds = useRef<Record<string, ReturnType<typeof setTimeout>> | null>({});
const handlePlayQueueAdd = useCallback(
async (options: PlayQueueAddOptions) => {
if (!server) return toast.error({ message: 'No server selected', type: 'error' });
const { initialIndex, initialSongId, playType, byData, byItemType, query } = options;
let songs: QueueSong[] | null = null;
let initialSongIndex = 0;
const handlePlayQueueAdd = useCallback(
async (options: PlayQueueAddOptions) => {
if (!server) return toast.error({ message: 'No server selected', type: 'error' });
const { initialIndex, initialSongId, playType, byData, byItemType, query } = options;
let songs: QueueSong[] | null = null;
let initialSongIndex = 0;
if (byItemType) {
let songList: SongListResponse | undefined;
const { type: itemType, id } = byItemType;
if (byItemType) {
let songList: SongListResponse | undefined;
const { type: itemType, id } = byItemType;
const fetchId = nanoid();
timeoutIds.current = {
...timeoutIds.current,
[fetchId]: setTimeout(() => {
toast.info({
autoClose: false,
id: fetchId,
message: 'This is taking a while... close the notification to cancel the request',
onClose: () => {
queryClient.cancelQueries({
exact: false,
queryKey: getRootQueryKey(itemType, server?.id),
});
},
title: 'Adding to queue',
});
}, 2000),
};
const fetchId = nanoid();
timeoutIds.current = {
...timeoutIds.current,
[fetchId]: setTimeout(() => {
toast.info({
autoClose: false,
id: fetchId,
message:
'This is taking a while... close the notification to cancel the request',
onClose: () => {
queryClient.cancelQueries({
exact: false,
queryKey: getRootQueryKey(itemType, server?.id),
});
},
title: 'Adding to queue',
});
}, 2000),
};
try {
if (itemType === LibraryItem.PLAYLIST) {
songList = await getPlaylistSongsById({ id: id?.[0], query, queryClient, server });
} else if (itemType === LibraryItem.ALBUM) {
songList = await getAlbumSongsById({ id, query, queryClient, server });
} else if (itemType === LibraryItem.ALBUM_ARTIST) {
songList = await getAlbumArtistSongsById({ id, query, queryClient, server });
} else if (itemType === LibraryItem.SONG) {
if (id?.length === 1) {
songList = await getSongById({ id: id?.[0], queryClient, server });
} else {
songList = await getSongsByQuery({ query, queryClient, server });
try {
if (itemType === LibraryItem.PLAYLIST) {
songList = await getPlaylistSongsById({
id: id?.[0],
query,
queryClient,
server,
});
} else if (itemType === LibraryItem.ALBUM) {
songList = await getAlbumSongsById({ id, query, queryClient, server });
} else if (itemType === LibraryItem.ALBUM_ARTIST) {
songList = await getAlbumArtistSongsById({
id,
query,
queryClient,
server,
});
} else if (itemType === LibraryItem.SONG) {
if (id?.length === 1) {
songList = await getSongById({ id: id?.[0], queryClient, server });
} else {
songList = await getSongsByQuery({ query, queryClient, server });
}
}
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
delete timeoutIds.current[fetchId];
toast.hide(fetchId);
} catch (err: any) {
if (instanceOfCancellationError(err)) {
return null;
}
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
delete timeoutIds.current[fetchId];
toast.hide(fetchId);
return toast.error({
message: err.message,
title: 'Play queue add failed',
});
}
songs =
songList?.items?.map((song: Song) => ({ ...song, uniqueId: nanoid() })) || null;
} else if (byData) {
songs = byData.map((song) => ({ ...song, uniqueId: nanoid() })) || null;
}
}
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
delete timeoutIds.current[fetchId];
toast.hide(fetchId);
} catch (err: any) {
if (instanceOfCancellationError(err)) {
if (!songs || songs?.length === 0)
return toast.warn({
message: 'The query returned no results',
title: 'No tracks added',
});
if (initialIndex) {
initialSongIndex = initialIndex;
} else if (initialSongId) {
initialSongIndex = songs.findIndex((song) => song.id === initialSongId);
}
const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs });
if (playerType === PlaybackType.LOCAL) {
mpvPlayer?.volume(usePlayerStore.getState().volume);
if (playType === Play.NEXT || playType === Play.LAST) {
mpvPlayer?.setQueueNext(playerData);
}
if (playType === Play.NOW) {
mpvPlayer?.setQueue(playerData);
mpvPlayer?.play();
}
}
play();
mpris?.updateSong({
currentTime: usePlayerStore.getState().current.time,
repeat: usePlayerStore.getState().repeat,
shuffle: usePlayerStore.getState().shuffle,
song: playerData.current.song,
status: 'Playing',
});
return null;
}
},
[play, playerType, queryClient, server],
);
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
delete timeoutIds.current[fetchId];
toast.hide(fetchId);
return toast.error({
message: err.message,
title: 'Play queue add failed',
});
}
songs = songList?.items?.map((song: Song) => ({ ...song, uniqueId: nanoid() })) || null;
} else if (byData) {
songs = byData.map((song) => ({ ...song, uniqueId: nanoid() })) || null;
}
if (!songs || songs?.length === 0)
return toast.warn({ message: 'The query returned no results', title: 'No tracks added' });
if (initialIndex) {
initialSongIndex = initialIndex;
} else if (initialSongId) {
initialSongIndex = songs.findIndex((song) => song.id === initialSongId);
}
const playerData = addToQueue({ initialIndex: initialSongIndex, playType, songs });
if (playerType === PlaybackType.LOCAL) {
mpvPlayer?.volume(usePlayerStore.getState().volume);
if (playType === Play.NEXT || playType === Play.LAST) {
mpvPlayer?.setQueueNext(playerData);
}
if (playType === Play.NOW) {
mpvPlayer?.setQueue(playerData);
mpvPlayer?.play();
}
}
play();
mpris?.updateSong({
currentTime: usePlayerStore.getState().current.time,
repeat: usePlayerStore.getState().repeat,
shuffle: usePlayerStore.getState().shuffle,
song: playerData.current.song,
status: 'Playing',
});
return null;
},
[play, playerType, queryClient, server],
);
return handlePlayQueueAdd;
return handlePlayQueueAdd;
};

View file

@ -2,6 +2,6 @@ import { useContext } from 'react';
import { PlayQueueHandlerContext } from '/@/renderer/features/player/context/play-queue-handler-context';
export const usePlayQueueAdd = () => {
const { handlePlayQueueAdd } = useContext(PlayQueueHandlerContext);
return handlePlayQueueAdd;
const { handlePlayQueueAdd } = useContext(PlayQueueHandlerContext);
return handlePlayQueueAdd;
};

View file

@ -10,123 +10,123 @@ const utils = isElectron() ? window.electron.utils : null;
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
let volumeToSet;
const newVolumeGreaterThanHundred = volume + volumeWheelStep > 100;
if (newVolumeGreaterThanHundred) {
volumeToSet = 100;
} else {
volumeToSet = volume + volumeWheelStep;
}
let volumeToSet;
const newVolumeGreaterThanHundred = volume + volumeWheelStep > 100;
if (newVolumeGreaterThanHundred) {
volumeToSet = 100;
} else {
volumeToSet = volume + volumeWheelStep;
}
return volumeToSet;
return volumeToSet;
};
const calculateVolumeDown = (volume: number, volumeWheelStep: number) => {
let volumeToSet;
const newVolumeLessThanZero = volume - volumeWheelStep < 0;
if (newVolumeLessThanZero) {
volumeToSet = 0;
} else {
volumeToSet = volume - volumeWheelStep;
}
let volumeToSet;
const newVolumeLessThanZero = volume - volumeWheelStep < 0;
if (newVolumeLessThanZero) {
volumeToSet = 0;
} else {
volumeToSet = volume - volumeWheelStep;
}
return volumeToSet;
return volumeToSet;
};
export const useRightControls = () => {
const { setVolume, setMuted } = usePlayerControls();
const volume = useVolume();
const muted = useMuted();
const { volumeWheelStep } = useGeneralSettings();
const { setVolume, setMuted } = usePlayerControls();
const volume = useVolume();
const muted = useMuted();
const { volumeWheelStep } = useGeneralSettings();
// Ensure that the mpv player volume is set on startup
useEffect(() => {
if (isElectron()) {
mpvPlayer.volume(volume);
mpris?.updateVolume(volume / 100);
// Ensure that the mpv player volume is set on startup
useEffect(() => {
if (isElectron()) {
mpvPlayer.volume(volume);
mpris?.updateVolume(volume / 100);
if (muted) {
mpvPlayer.mute();
}
}
if (muted) {
mpvPlayer.mute();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleVolumeSlider = (e: number) => {
mpvPlayer?.volume(e);
mpris?.updateVolume(e / 100);
setVolume(e);
};
const handleVolumeSliderState = (e: number) => {
mpris?.updateVolume(e / 100);
setVolume(e);
};
const handleVolumeDown = useCallback(() => {
const volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
mpvPlayer?.volume(volumeToSet);
mpris?.updateVolume(volumeToSet / 100);
setVolume(volumeToSet);
}, [setVolume, volume, volumeWheelStep]);
const handleVolumeUp = useCallback(() => {
const volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
mpvPlayer?.volume(volumeToSet);
mpris?.updateVolume(volumeToSet / 100);
setVolume(volumeToSet);
}, [setVolume, volume, volumeWheelStep]);
const handleVolumeWheel = useCallback(
(e: WheelEvent<HTMLDivElement | HTMLButtonElement>) => {
let volumeToSet;
if (e.deltaY > 0) {
volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
} else {
volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
}
mpvPlayer?.volume(volumeToSet);
mpris?.updateVolume(volumeToSet / 100);
setVolume(volumeToSet);
},
[setVolume, volume, volumeWheelStep],
);
const handleMute = useCallback(() => {
setMuted(!muted);
mpvPlayer?.mute();
}, [muted, setMuted]);
useEffect(() => {
if (isElectron()) {
mpvPlayerListener?.rendererVolumeMute(() => {
handleMute();
});
mpvPlayerListener?.rendererVolumeUp(() => {
handleVolumeUp();
});
mpvPlayerListener?.rendererVolumeDown(() => {
handleVolumeDown();
});
}
return () => {
ipc?.removeAllListeners('renderer-player-volume-mute');
ipc?.removeAllListeners('renderer-player-volume-up');
ipc?.removeAllListeners('renderer-player-volume-down');
const handleVolumeSlider = (e: number) => {
mpvPlayer?.volume(e);
mpris?.updateVolume(e / 100);
setVolume(e);
};
}, [handleMute, handleVolumeDown, handleVolumeUp]);
return {
handleMute,
handleVolumeDown,
handleVolumeSlider,
handleVolumeSliderState,
handleVolumeUp,
handleVolumeWheel,
};
const handleVolumeSliderState = (e: number) => {
mpris?.updateVolume(e / 100);
setVolume(e);
};
const handleVolumeDown = useCallback(() => {
const volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
mpvPlayer?.volume(volumeToSet);
mpris?.updateVolume(volumeToSet / 100);
setVolume(volumeToSet);
}, [setVolume, volume, volumeWheelStep]);
const handleVolumeUp = useCallback(() => {
const volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
mpvPlayer?.volume(volumeToSet);
mpris?.updateVolume(volumeToSet / 100);
setVolume(volumeToSet);
}, [setVolume, volume, volumeWheelStep]);
const handleVolumeWheel = useCallback(
(e: WheelEvent<HTMLDivElement | HTMLButtonElement>) => {
let volumeToSet;
if (e.deltaY > 0) {
volumeToSet = calculateVolumeDown(volume, volumeWheelStep);
} else {
volumeToSet = calculateVolumeUp(volume, volumeWheelStep);
}
mpvPlayer?.volume(volumeToSet);
mpris?.updateVolume(volumeToSet / 100);
setVolume(volumeToSet);
},
[setVolume, volume, volumeWheelStep],
);
const handleMute = useCallback(() => {
setMuted(!muted);
mpvPlayer?.mute();
}, [muted, setMuted]);
useEffect(() => {
if (isElectron()) {
mpvPlayerListener?.rendererVolumeMute(() => {
handleMute();
});
mpvPlayerListener?.rendererVolumeUp(() => {
handleVolumeUp();
});
mpvPlayerListener?.rendererVolumeDown(() => {
handleVolumeDown();
});
}
return () => {
ipc?.removeAllListeners('renderer-player-volume-mute');
ipc?.removeAllListeners('renderer-player-volume-up');
ipc?.removeAllListeners('renderer-player-volume-down');
};
}, [handleMute, handleVolumeDown, handleVolumeUp]);
return {
handleMute,
handleVolumeDown,
handleVolumeSlider,
handleVolumeSliderState,
handleVolumeUp,
handleVolumeWheel,
};
};

View file

@ -34,286 +34,294 @@ Progress Events (Jellyfin only):
*/
const checkScrobbleConditions = (args: {
scrobbleAtDuration: number;
scrobbleAtPercentage: number;
songCompletedDuration: number;
songDuration: number;
scrobbleAtDuration: number;
scrobbleAtPercentage: number;
songCompletedDuration: number;
songDuration: number;
}) => {
const { scrobbleAtDuration, scrobbleAtPercentage, songCompletedDuration, songDuration } = args;
const percentageOfSongCompleted = songDuration ? (songCompletedDuration / songDuration) * 100 : 0;
const { scrobbleAtDuration, scrobbleAtPercentage, songCompletedDuration, songDuration } = args;
const percentageOfSongCompleted = songDuration
? (songCompletedDuration / songDuration) * 100
: 0;
return (
percentageOfSongCompleted >= scrobbleAtPercentage || songCompletedDuration >= scrobbleAtDuration
);
return (
percentageOfSongCompleted >= scrobbleAtPercentage ||
songCompletedDuration >= scrobbleAtDuration
);
};
export const useScrobble = () => {
const status = useCurrentStatus();
const scrobbleSettings = usePlaybackSettings().scrobble;
const isScrobbleEnabled = scrobbleSettings?.enabled;
const sendScrobble = useSendScrobble();
const status = useCurrentStatus();
const scrobbleSettings = usePlaybackSettings().scrobble;
const isScrobbleEnabled = scrobbleSettings?.enabled;
const sendScrobble = useSendScrobble();
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
const handleScrobbleFromSeek = useCallback(
(currentTime: number) => {
if (!isScrobbleEnabled) return;
const handleScrobbleFromSeek = useCallback(
(currentTime: number) => {
if (!isScrobbleEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
const currentSong = usePlayerStore.getState().current.song;
if (!currentSong?.id || currentSong?.serverType !== ServerType.JELLYFIN) return;
if (!currentSong?.id || currentSong?.serverType !== ServerType.JELLYFIN) return;
const position =
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
const position =
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
sendScrobble.mutate({
query: {
event: 'timeupdate',
id: currentSong.id,
position,
submission: false,
sendScrobble.mutate({
query: {
event: 'timeupdate',
id: currentSong.id,
position,
submission: false,
},
serverId: currentSong?.serverId,
});
},
serverId: currentSong?.serverId,
});
},
[isScrobbleEnabled, sendScrobble],
);
const progressIntervalId = useRef<ReturnType<typeof setInterval> | null>(null);
const songChangeTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleScrobbleFromSongChange = useCallback(
(current: (QueueSong | number | undefined)[], previous: (QueueSong | number | undefined)[]) => {
if (!isScrobbleEnabled) return;
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
}
// const currentSong = current[0] as QueueSong | undefined;
const previousSong = previous[0] as QueueSong;
const previousSongTime = previous[1] as number;
// Send completion scrobble when song changes and a previous song exists
if (previousSong?.id) {
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: previousSongTime,
songDuration: previousSong.duration,
});
if (
(!isCurrentSongScrobbled && shouldSubmitScrobble) ||
previousSong?.serverType === ServerType.JELLYFIN
) {
const position =
previousSong?.serverType === ServerType.JELLYFIN ? previousSongTime * 1e7 : undefined;
sendScrobble.mutate({
query: {
id: previousSong.id,
position,
submission: true,
},
serverId: previousSong?.serverId,
});
}
}
setIsCurrentSongScrobbled(false);
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly
clearTimeout(songChangeTimeoutId.current as ReturnType<typeof setTimeout>);
songChangeTimeoutId.current = setTimeout(() => {
const currentSong = current[0] as QueueSong | undefined;
// Send start scrobble when song changes and the new song is playing
if (status === PlayerStatus.PLAYING && currentSong?.id) {
sendScrobble.mutate({
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
serverId: currentSong?.serverId,
});
if (currentSong?.serverType === ServerType.JELLYFIN) {
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
}
}, 2000);
},
[
isScrobbleEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
status,
handleScrobbleFromSeek,
],
);
const handleScrobbleFromStatusChange = useCallback(
(status: PlayerStatus | undefined) => {
if (!isScrobbleEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
if (!currentSong?.id) return;
const position =
currentSong?.serverType === ServerType.JELLYFIN
? usePlayerStore.getState().current.time * 1e7
: undefined;
// Whenever the player is restarted, send a 'start' scrobble
if (status === PlayerStatus.PLAYING) {
sendScrobble.mutate({
query: {
event: 'unpause',
id: currentSong.id,
position,
submission: false,
},
serverId: currentSong?.serverId,
});
if (currentSong?.serverType === ServerType.JELLYFIN) {
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
// Jellyfin is the only one that needs to send a 'pause' event to the server
} else if (currentSong?.serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
query: {
event: 'pause',
id: currentSong.id,
position,
submission: false,
},
serverId: currentSong?.serverId,
});
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
}
} else {
// If not already scrobbled, send a 'submission' scrobble if conditions are met
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: usePlayerStore.getState().current.time,
songDuration: currentSong.duration,
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({
query: {
id: currentSong.id,
submission: true,
},
serverId: currentSong?.serverId,
});
setIsCurrentSongScrobbled(true);
}
}
},
[
isScrobbleEnabled,
sendScrobble,
handleScrobbleFromSeek,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
],
);
// When pressing the "Previous Track" button, the player will restart the current song if the
// currentTime is >= 10 seconds. Since the song / status change events are not triggered, we will
// need to perform another check to see if the scrobble conditions are met
const handleScrobbleFromSongRestart = useCallback(
(currentTime: number) => {
if (!isScrobbleEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
if (!currentSong?.id) return;
const position =
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: currentTime,
songDuration: currentSong.duration,
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({
query: {
id: currentSong.id,
position,
submission: true,
},
serverId: currentSong?.serverId,
});
}
if (currentSong?.serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
serverId: currentSong?.serverId,
});
}
setIsCurrentSongScrobbled(false);
},
[
isScrobbleEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
],
);
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state) => [state.current.song, state.current.time],
handleScrobbleFromSongChange,
{
// We need the current time to check the scrobble condition, but we only want to
// trigger the callback when the song changes
equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id,
},
[isScrobbleEnabled, sendScrobble],
);
const unsubStatusChange = usePlayerStore.subscribe(
(state) => state.current.status,
handleScrobbleFromStatusChange,
const progressIntervalId = useRef<ReturnType<typeof setInterval> | null>(null);
const songChangeTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleScrobbleFromSongChange = useCallback(
(
current: (QueueSong | number | undefined)[],
previous: (QueueSong | number | undefined)[],
) => {
if (!isScrobbleEnabled) return;
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
}
// const currentSong = current[0] as QueueSong | undefined;
const previousSong = previous[0] as QueueSong;
const previousSongTime = previous[1] as number;
// Send completion scrobble when song changes and a previous song exists
if (previousSong?.id) {
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: previousSongTime,
songDuration: previousSong.duration,
});
if (
(!isCurrentSongScrobbled && shouldSubmitScrobble) ||
previousSong?.serverType === ServerType.JELLYFIN
) {
const position =
previousSong?.serverType === ServerType.JELLYFIN
? previousSongTime * 1e7
: undefined;
sendScrobble.mutate({
query: {
id: previousSong.id,
position,
submission: true,
},
serverId: previousSong?.serverId,
});
}
}
setIsCurrentSongScrobbled(false);
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly
clearTimeout(songChangeTimeoutId.current as ReturnType<typeof setTimeout>);
songChangeTimeoutId.current = setTimeout(() => {
const currentSong = current[0] as QueueSong | undefined;
// Send start scrobble when song changes and the new song is playing
if (status === PlayerStatus.PLAYING && currentSong?.id) {
sendScrobble.mutate({
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
serverId: currentSong?.serverId,
});
if (currentSong?.serverType === ServerType.JELLYFIN) {
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
}
}, 2000);
},
[
isScrobbleEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
status,
handleScrobbleFromSeek,
],
);
return () => {
unsubSongChange();
unsubStatusChange();
};
}, [handleScrobbleFromSongChange, handleScrobbleFromStatusChange]);
const handleScrobbleFromStatusChange = useCallback(
(status: PlayerStatus | undefined) => {
if (!isScrobbleEnabled) return;
return { handleScrobbleFromSeek, handleScrobbleFromSongRestart };
const currentSong = usePlayerStore.getState().current.song;
if (!currentSong?.id) return;
const position =
currentSong?.serverType === ServerType.JELLYFIN
? usePlayerStore.getState().current.time * 1e7
: undefined;
// Whenever the player is restarted, send a 'start' scrobble
if (status === PlayerStatus.PLAYING) {
sendScrobble.mutate({
query: {
event: 'unpause',
id: currentSong.id,
position,
submission: false,
},
serverId: currentSong?.serverId,
});
if (currentSong?.serverType === ServerType.JELLYFIN) {
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
// Jellyfin is the only one that needs to send a 'pause' event to the server
} else if (currentSong?.serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
query: {
event: 'pause',
id: currentSong.id,
position,
submission: false,
},
serverId: currentSong?.serverId,
});
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
}
} else {
// If not already scrobbled, send a 'submission' scrobble if conditions are met
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: usePlayerStore.getState().current.time,
songDuration: currentSong.duration,
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({
query: {
id: currentSong.id,
submission: true,
},
serverId: currentSong?.serverId,
});
setIsCurrentSongScrobbled(true);
}
}
},
[
isScrobbleEnabled,
sendScrobble,
handleScrobbleFromSeek,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
],
);
// When pressing the "Previous Track" button, the player will restart the current song if the
// currentTime is >= 10 seconds. Since the song / status change events are not triggered, we will
// need to perform another check to see if the scrobble conditions are met
const handleScrobbleFromSongRestart = useCallback(
(currentTime: number) => {
if (!isScrobbleEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
if (!currentSong?.id) return;
const position =
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDuration: currentTime,
songDuration: currentSong.duration,
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({
query: {
id: currentSong.id,
position,
submission: true,
},
serverId: currentSong?.serverId,
});
}
if (currentSong?.serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
serverId: currentSong?.serverId,
});
}
setIsCurrentSongScrobbled(false);
},
[
isScrobbleEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
],
);
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state) => [state.current.song, state.current.time],
handleScrobbleFromSongChange,
{
// We need the current time to check the scrobble condition, but we only want to
// trigger the callback when the song changes
equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id,
},
);
const unsubStatusChange = usePlayerStore.subscribe(
(state) => state.current.status,
handleScrobbleFromStatusChange,
);
return () => {
unsubSongChange();
unsubStatusChange();
};
}, [handleScrobbleFromSongChange, handleScrobbleFromStatusChange]);
return { handleScrobbleFromSeek, handleScrobbleFromSongRestart };
};

View file

@ -6,25 +6,25 @@ import { MutationOptions } from '/@/renderer/lib/react-query';
import { getServerById, useIncrementQueuePlayCount } from '/@/renderer/store';
export const useSendScrobble = (options?: MutationOptions) => {
const incrementPlayCount = useIncrementQueuePlayCount();
const incrementPlayCount = useIncrementQueuePlayCount();
return useMutation<
ScrobbleResponse,
AxiosError,
Omit<ScrobbleArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.scrobble({ ...args, apiClientProps: { server } });
},
onSuccess: (_data, variables) => {
// Manually increment the play count for the song in the queue if scrobble was submitted
if (variables.query.submission) {
incrementPlayCount([variables.query.id]);
}
},
...options,
});
return useMutation<
ScrobbleResponse,
AxiosError,
Omit<ScrobbleArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.scrobble({ ...args, apiClientProps: { server } });
},
onSuccess: (_data, variables) => {
// Manually increment the play count for the song in the queue if scrobble was submitted
if (variables.query.submission) {
incrementPlayCount([variables.query.id]);
}
},
...options,
});
};

View file

@ -2,195 +2,195 @@ import { QueryClient } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
PlaylistSongListQuery,
SongDetailQuery,
SongListQuery,
SongListResponse,
SongListSort,
SortOrder,
PlaylistSongListQuery,
SongDetailQuery,
SongListQuery,
SongListResponse,
SongListSort,
SortOrder,
} from '/@/renderer/api/types';
import { ServerListItem } from '/@/renderer/types';
export const getPlaylistSongsById = async (args: {
id: string;
query?: Partial<PlaylistSongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
id: string;
query?: Partial<PlaylistSongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
}) => {
const { id, queryClient, server, query } = args;
const { id, queryClient, server, query } = args;
const queryFilter: PlaylistSongListQuery = {
id,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
const queryFilter: PlaylistSongListQuery = {
id,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
const queryKey = queryKeys.playlists.songList(server?.id, id, queryFilter);
const queryKey = queryKeys.playlists.songList(server?.id, id, queryFilter);
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query: queryFilter,
}),
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: queryFilter,
}),
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
);
);
return res;
return res;
};
export const getAlbumSongsById = async (args: {
id: string[];
orderByIds?: boolean;
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
id: string[];
orderByIds?: boolean;
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
}) => {
const { id, queryClient, server, query } = args;
const { id, queryClient, server, query } = args;
const queryFilter: SongListQuery = {
albumIds: id,
sortBy: SongListSort.ALBUM,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
const queryFilter: SongListQuery = {
albumIds: id,
sortBy: SongListSort.ALBUM,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
server,
signal,
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
server,
signal,
},
query: queryFilter,
}),
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: queryFilter,
}),
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
);
);
return res;
return res;
};
export const getAlbumArtistSongsById = async (args: {
id: string[];
orderByIds?: boolean;
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
id: string[];
orderByIds?: boolean;
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
}) => {
const { id, queryClient, server, query } = args;
const { id, queryClient, server, query } = args;
const queryFilter: SongListQuery = {
artistIds: id || [],
sortBy: SongListSort.ALBUM_ARTIST,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
const queryFilter: SongListQuery = {
artistIds: id || [],
sortBy: SongListSort.ALBUM_ARTIST,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
server,
signal,
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
server,
signal,
},
query: queryFilter,
}),
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: queryFilter,
}),
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
);
);
return res;
return res;
};
export const getSongsByQuery = async (args: {
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
query?: Partial<SongListQuery>;
queryClient: QueryClient;
server: ServerListItem;
}) => {
const { queryClient, server, query } = args;
const { queryClient, server, query } = args;
const queryFilter: SongListQuery = {
sortBy: SongListSort.ALBUM,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
const queryFilter: SongListQuery = {
sortBy: SongListSort.ALBUM,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
};
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const queryKey = queryKeys.songs.list(server?.id, queryFilter);
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
server,
signal,
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
server,
signal,
},
query: queryFilter,
}),
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: queryFilter,
}),
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
);
);
return res;
return res;
};
export const getSongById = async (args: {
id: string;
queryClient: QueryClient;
server: ServerListItem;
id: string;
queryClient: QueryClient;
server: ServerListItem;
}): Promise<SongListResponse> => {
const { id, queryClient, server } = args;
const { id, queryClient, server } = args;
const queryFilter: SongDetailQuery = { id };
const queryFilter: SongDetailQuery = { id };
const queryKey = queryKeys.songs.detail(server?.id, queryFilter);
const queryKey = queryKeys.songs.detail(server?.id, queryFilter);
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getSongDetail({
apiClientProps: {
server,
signal,
const res = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getSongDetail({
apiClientProps: {
server,
signal,
},
query: queryFilter,
}),
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: queryFilter,
}),
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
);
);
if (!res) throw new Error('Song not found');
if (!res) throw new Error('Song not found');
return {
items: [res],
startIndex: 0,
totalRecordCount: 1,
};
return {
items: [res],
startIndex: 0,
totalRecordCount: 1,
};
};

View file

@ -12,211 +12,212 @@ import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
export const AddToPlaylistContextModal = ({
id,
innerProps,
id,
innerProps,
}: ContextModalProps<{
albumId?: string[];
artistId?: string[];
songId?: string[];
albumId?: string[];
artistId?: string[];
songId?: string[];
}>) => {
const { albumId, artistId, songId } = innerProps;
const server = useCurrentServer();
const [isLoading, setIsLoading] = useState(false);
const { albumId, artistId, songId } = innerProps;
const server = useCurrentServer();
const [isLoading, setIsLoading] = useState(false);
const addToPlaylistMutation = useAddToPlaylist({});
const addToPlaylistMutation = useAddToPlaylist({});
const playlistList = usePlaylistList({
query: {
_custom: {
navidrome: {
smart: false,
const playlistList = usePlaylistList({
query: {
_custom: {
navidrome: {
smart: false,
},
},
sortBy: PlaylistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
},
sortBy: PlaylistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
});
const playlistSelect = useMemo(() => {
return (
playlistList.data?.items?.map((playlist) => ({
label: playlist.name,
value: playlist.id,
})) || []
);
}, [playlistList.data]);
const form = useForm({
initialValues: {
playlistId: [],
skipDuplicates: true,
},
});
const getSongsByAlbum = async (albumId: string) => {
const query: SongListQuery = {
albumIds: [albumId],
sortBy: SongListSort.ALBUM,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const queryKey = queryKeys.songs.list(server?.id || '', query);
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
if (!server) throw new Error('No server');
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
serverId: server?.id,
});
return songsRes;
};
const playlistSelect = useMemo(() => {
return (
playlistList.data?.items?.map((playlist) => ({
label: playlist.name,
value: playlist.id,
})) || []
);
}, [playlistList.data]);
const getSongsByArtist = async (artistId: string) => {
const query: SongListQuery = {
artistIds: [artistId],
sortBy: SongListSort.ARTIST,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const queryKey = queryKeys.songs.list(server?.id || '', query);
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
if (!server) throw new Error('No server');
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
const form = useForm({
initialValues: {
playlistId: [],
skipDuplicates: true,
},
});
return songsRes;
};
const isSubmitDisabled = form.values.playlistId.length === 0 || addToPlaylistMutation.isLoading;
const handleSubmit = form.onSubmit(async (values) => {
setIsLoading(true);
const allSongIds: string[] = [];
const uniqueSongIds: string[] = [];
if (albumId && albumId.length > 0) {
for (const id of albumId) {
const songs = await getSongsByAlbum(id);
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
}
if (artistId && artistId.length > 0) {
for (const id of artistId) {
const songs = await getSongsByArtist(id);
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
}
if (songId && songId.length > 0) {
allSongIds.push(...songId);
}
for (const playlistId of values.playlistId) {
if (values.skipDuplicates) {
const query = {
id: playlistId,
startIndex: 0,
const getSongsByAlbum = async (albumId: string) => {
const query: SongListQuery = {
albumIds: [albumId],
sortBy: SongListSort.ALBUM,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
const queryKey = queryKeys.songs.list(server?.id || '', query);
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
if (!server) throw new Error('No server');
return api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query: { id: playlistId, startIndex: 0 },
});
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
if (!server) throw new Error('No server');
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
});
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
return songsRes;
};
for (const songId of allSongIds) {
if (!playlistSongIds?.includes(songId)) {
uniqueSongIds.push(songId);
}
const getSongsByArtist = async (artistId: string) => {
const query: SongListQuery = {
artistIds: [artistId],
sortBy: SongListSort.ARTIST,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const queryKey = queryKeys.songs.list(server?.id || '', query);
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
if (!server) throw new Error('No server');
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
});
return songsRes;
};
const isSubmitDisabled = form.values.playlistId.length === 0 || addToPlaylistMutation.isLoading;
const handleSubmit = form.onSubmit(async (values) => {
setIsLoading(true);
const allSongIds: string[] = [];
const uniqueSongIds: string[] = [];
if (albumId && albumId.length > 0) {
for (const id of albumId) {
const songs = await getSongsByAlbum(id);
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
}
}
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
if (!server) return null;
addToPlaylistMutation.mutate(
{
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
query: { id: playlistId },
serverId: server?.id,
},
{
onError: (err) => {
toast.error({
message: `[${
playlistSelect.find((playlist) => playlist.value === playlistId)?.label
}] ${err.message}`,
title: 'Failed to add songs to playlist',
});
},
},
);
}
}
if (artistId && artistId.length > 0) {
for (const id of artistId) {
const songs = await getSongsByArtist(id);
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
}
setIsLoading(false);
toast.success({
message: `Added ${
values.skipDuplicates ? uniqueSongIds.length : allSongIds.length
} songs to ${values.playlistId.length} playlist(s)`,
if (songId && songId.length > 0) {
allSongIds.push(...songId);
}
for (const playlistId of values.playlistId) {
if (values.skipDuplicates) {
const query = {
id: playlistId,
startIndex: 0,
};
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
if (!server) throw new Error('No server');
return api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query: { id: playlistId, startIndex: 0 },
});
});
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
for (const songId of allSongIds) {
if (!playlistSongIds?.includes(songId)) {
uniqueSongIds.push(songId);
}
}
}
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
if (!server) return null;
addToPlaylistMutation.mutate(
{
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
query: { id: playlistId },
serverId: server?.id,
},
{
onError: (err) => {
toast.error({
message: `[${
playlistSelect.find((playlist) => playlist.value === playlistId)
?.label
}] ${err.message}`,
title: 'Failed to add songs to playlist',
});
},
},
);
}
}
setIsLoading(false);
toast.success({
message: `Added ${
values.skipDuplicates ? uniqueSongIds.length : allSongIds.length
} songs to ${values.playlistId.length} playlist(s)`,
});
closeModal(id);
return null;
});
closeModal(id);
return null;
});
return (
<Box p="1rem">
<form onSubmit={handleSubmit}>
<Stack>
<MultiSelect
clearable
searchable
data={playlistSelect}
disabled={playlistList.isLoading}
label="Playlists"
size="md"
{...form.getInputProps('playlistId')}
/>
<Switch
label="Skip duplicates"
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
/>
<Group position="right">
<Group>
<Button
disabled={addToPlaylistMutation.isLoading}
size="md"
variant="subtle"
onClick={() => closeModal(id)}
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
loading={isLoading}
size="md"
type="submit"
variant="filled"
>
Add
</Button>
</Group>
</Group>
</Stack>
</form>
</Box>
);
return (
<Box p="1rem">
<form onSubmit={handleSubmit}>
<Stack>
<MultiSelect
clearable
searchable
data={playlistSelect}
disabled={playlistList.isLoading}
label="Playlists"
size="md"
{...form.getInputProps('playlistId')}
/>
<Switch
label="Skip duplicates"
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
/>
<Group position="right">
<Group>
<Button
disabled={addToPlaylistMutation.isLoading}
size="md"
variant="subtle"
onClick={() => closeModal(id)}
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
loading={isLoading}
size="md"
type="submit"
variant="filled"
>
Add
</Button>
</Group>
</Group>
</Stack>
</form>
</Box>
);
};

View file

@ -4,140 +4,142 @@ import { useRef, useState } from 'react';
import { CreatePlaylistBody, ServerType, SongListSort } from '/@/renderer/api/types';
import { Button, Switch, Text, TextInput, toast } from '/@/renderer/components';
import {
PlaylistQueryBuilder,
PlaylistQueryBuilderRef,
PlaylistQueryBuilder,
PlaylistQueryBuilderRef,
} from '/@/renderer/features/playlists/components/playlist-query-builder';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { useCurrentServer } from '/@/renderer/store';
interface CreatePlaylistFormProps {
onCancel: () => void;
onCancel: () => void;
}
export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
const mutation = useCreatePlaylist({});
const server = useCurrentServer();
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
const mutation = useCreatePlaylist({});
const server = useCurrentServer();
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
const form = useForm<CreatePlaylistBody>({
initialValues: {
_custom: {
navidrome: {
public: false,
rules: undefined,
},
},
comment: '',
name: '',
},
});
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
const handleSubmit = form.onSubmit((values) => {
if (isSmartPlaylist) {
values._custom!.navidrome = {
...values._custom?.navidrome,
rules: queryBuilderRef.current?.getFilters(),
};
}
const smartPlaylist = queryBuilderRef.current?.getFilters();
if (!server) return;
mutation.mutate(
{
body: {
...values,
_custom: {
navidrome: {
...values._custom?.navidrome,
rules:
isSmartPlaylist && smartPlaylist?.filters
? {
...convertQueryGroupToNDQuery(smartPlaylist.filters),
...smartPlaylist.extraFilters,
}
: undefined,
const form = useForm<CreatePlaylistBody>({
initialValues: {
_custom: {
navidrome: {
public: false,
rules: undefined,
},
},
},
comment: '',
name: '',
},
serverId: server.id,
},
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error creating playlist' });
},
onSuccess: () => {
toast.success({ message: `Playlist has been created` });
onCancel();
},
},
});
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
const handleSubmit = form.onSubmit((values) => {
if (isSmartPlaylist) {
values._custom!.navidrome = {
...values._custom?.navidrome,
rules: queryBuilderRef.current?.getFilters(),
};
}
const smartPlaylist = queryBuilderRef.current?.getFilters();
if (!server) return;
mutation.mutate(
{
body: {
...values,
_custom: {
navidrome: {
...values._custom?.navidrome,
rules:
isSmartPlaylist && smartPlaylist?.filters
? {
...convertQueryGroupToNDQuery(smartPlaylist.filters),
...smartPlaylist.extraFilters,
}
: undefined,
},
},
},
serverId: server.id,
},
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error creating playlist' });
},
onSuccess: () => {
toast.success({ message: `Playlist has been created` });
onCancel();
},
},
);
});
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
const isSubmitDisabled = !form.values.name || mutation.isLoading;
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
data-autofocus
required
label="Name"
{...form.getInputProps('name')}
/>
<TextInput
label="Description"
{...form.getInputProps('comment')}
/>
<Group>
{isPublicDisplayed && (
<Switch
label="Is public?"
{...form.getInputProps('_custom.navidrome.public', {
type: 'checkbox',
})}
/>
)}
{server?.type === ServerType.NAVIDROME && (
<Switch
label="Is smart playlist?"
onChange={(e) => setIsSmartPlaylist(e.currentTarget.checked)}
/>
)}
</Group>
{server?.type === ServerType.NAVIDROME && isSmartPlaylist && (
<Stack pt="1rem">
<Text>Query Editor</Text>
<PlaylistQueryBuilder
ref={queryBuilderRef}
isSaving={false}
limit={undefined}
query={undefined}
sortBy={SongListSort.ALBUM}
sortOrder="asc"
/>
</Stack>
)}
<Group position="right">
<Button
variant="subtle"
onClick={onCancel}
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
loading={mutation.isLoading}
type="submit"
variant="filled"
>
Save
</Button>
</Group>
</Stack>
</form>
);
});
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
const isSubmitDisabled = !form.values.name || mutation.isLoading;
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
data-autofocus
required
label="Name"
{...form.getInputProps('name')}
/>
<TextInput
label="Description"
{...form.getInputProps('comment')}
/>
<Group>
{isPublicDisplayed && (
<Switch
label="Is public?"
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
/>
)}
{server?.type === ServerType.NAVIDROME && (
<Switch
label="Is smart playlist?"
onChange={(e) => setIsSmartPlaylist(e.currentTarget.checked)}
/>
)}
</Group>
{server?.type === ServerType.NAVIDROME && isSmartPlaylist && (
<Stack pt="1rem">
<Text>Query Editor</Text>
<PlaylistQueryBuilder
ref={queryBuilderRef}
isSaving={false}
limit={undefined}
query={undefined}
sortBy={SongListSort.ALBUM}
sortOrder="asc"
/>
</Stack>
)}
<Group position="right">
<Button
variant="subtle"
onClick={onCancel}
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
loading={mutation.isLoading}
type="submit"
variant="filled"
>
Save
</Button>
</Group>
</Stack>
</form>
);
};

View file

@ -10,14 +10,14 @@ import styled from 'styled-components';
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
import { Button, ConfirmModal, DropdownMenu, MotionGroup, toast } from '/@/renderer/components';
import {
getColumnDefs,
useFixedTableHeader,
VirtualTable,
getColumnDefs,
useFixedTableHeader,
VirtualTable,
} from '/@/renderer/components/virtual-table';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import {
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
@ -31,226 +31,232 @@ import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
const ContentContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
padding: 1rem 2rem 5rem;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
padding: 1rem 2rem 5rem;
overflow: hidden;
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
}
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
}
.ag-header {
margin-bottom: 0.5rem;
}
.ag-header {
margin-bottom: 0.5rem;
}
`;
interface PlaylistDetailContentProps {
tableRef: MutableRefObject<AgGridReactType | null>;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
const navigate = useNavigate();
const { playlistId } = useParams() as { playlistId: string };
const page = useSongListStore();
const handlePlayQueueAdd = usePlayQueueAdd();
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const playButtonBehavior = usePlayButtonBehavior();
const navigate = useNavigate();
const { playlistId } = useParams() as { playlistId: string };
const page = useSongListStore();
const handlePlayQueueAdd = usePlayQueueAdd();
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const playButtonBehavior = usePlayButtonBehavior();
const playlistSongsQueryInfinite = usePlaylistSongListInfinite({
options: {
cacheTime: 0,
keepPreviousData: false,
},
query: {
id: playlistId,
limit: 50,
startIndex: 0,
},
serverId: server?.id,
});
const handleLoadMore = () => {
playlistSongsQueryInfinite.fetchNextPage();
};
const columnDefs: ColDef[] = useMemo(
() =>
getColumnDefs(page.table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
[page.table.columns],
);
const contextMenuItems = useMemo(() => {
if (detailQuery?.data?.rules) {
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}, [detailQuery?.data?.rules]);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
playlistId,
});
const playlistSongData = useMemo(
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p?.items),
[playlistSongsQueryInfinite.data?.pages],
);
const { intersectRef, tableContainerRef } = useFixedTableHeader();
const deletePlaylistMutation = useDeletePlaylist({});
const handleDeletePlaylist = () => {
deletePlaylistMutation.mutate(
{ query: { id: playlistId }, serverId: server?.id },
{
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
});
const playlistSongsQueryInfinite = usePlaylistSongListInfinite({
options: {
cacheTime: 0,
keepPreviousData: false,
},
onSuccess: () => {
toast.success({
message: `Playlist has been deleted`,
});
closeAllModals();
navigate(AppRoute.PLAYLISTS);
query: {
id: playlistId,
limit: 50,
startIndex: 0,
},
},
serverId: server?.id,
});
const handleLoadMore = () => {
playlistSongsQueryInfinite.fetchNextPage();
};
const columnDefs: ColDef[] = useMemo(
() =>
getColumnDefs(page.table.columns).filter(
(c) => c.colId !== 'album' && c.colId !== 'artist',
),
[page.table.columns],
);
};
const openDeletePlaylist = () => {
openModal({
children: (
<ConfirmModal
loading={deletePlaylistMutation.isLoading}
onConfirm={handleDeletePlaylist}
>
Are you sure you want to delete this playlist?
</ConfirmModal>
),
title: 'Delete playlist',
const contextMenuItems = useMemo(() => {
if (detailQuery?.data?.rules) {
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}, [detailQuery?.data?.rules]);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
playlistId,
});
};
const handlePlay = (playType?: Play) => {
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
playType: playType || playButtonBehavior,
});
};
const playlistSongData = useMemo(
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p?.items),
[playlistSongsQueryInfinite.data?.pages],
);
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
const { intersectRef, tableContainerRef } = useFixedTableHeader();
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
const deletePlaylistMutation = useDeletePlaylist({});
const loadMoreRef = useRef<HTMLButtonElement | null>(null);
const handleDeletePlaylist = () => {
deletePlaylistMutation.mutate(
{ query: { id: playlistId }, serverId: server?.id },
{
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
});
},
onSuccess: () => {
toast.success({
message: `Playlist has been deleted`,
});
closeAllModals();
navigate(AppRoute.PLAYLISTS);
},
},
);
};
return (
<ContentContainer>
<Group
ref={intersectRef}
p="1rem"
position="apart"
>
<Group>
<PlayButton onClick={() => handlePlay()} />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
variant="subtle"
>
<RiMoreFill size={20} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={() => handlePlay(type.play)}
const openDeletePlaylist = () => {
openModal({
children: (
<ConfirmModal
loading={deletePlaylistMutation.isLoading}
onConfirm={handleDeletePlaylist}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Divider />
<DropdownMenu.Item
onClick={() => {
if (!detailQuery.data || !server) return;
openUpdatePlaylistModal({ playlist: detailQuery.data, server });
}}
>
Edit playlist
</DropdownMenu.Item>
<DropdownMenu.Item onClick={openDeletePlaylist}>Delete playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
uppercase
component={Link}
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
variant="subtle"
>
View full playlist
</Button>
</Group>
</Group>
<Box ref={tableContainerRef}>
<VirtualTable
ref={tableRef}
autoFitColumns
autoHeight
deselectOnClickOutside
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={columnDefs}
getRowId={(data) => {
// It's possible that there are duplicate song ids in a playlist
return `${data.data.id}-${data.data.pageIndex}`;
}}
rowData={playlistSongData}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
<MotionGroup
p="2rem"
position="center"
onViewportEnter={handleLoadMore}
>
<Button
ref={loadMoreRef}
compact
disabled={!playlistSongsQueryInfinite.hasNextPage}
loading={playlistSongsQueryInfinite.isFetchingNextPage}
variant="subtle"
onClick={handleLoadMore}
>
{playlistSongsQueryInfinite.hasNextPage ? 'Load more' : 'End of playlist'}
</Button>
</MotionGroup>
</ContentContainer>
);
Are you sure you want to delete this playlist?
</ConfirmModal>
),
title: 'Delete playlist',
});
};
const handlePlay = (playType?: Play) => {
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
playType: playType || playButtonBehavior,
});
};
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
const loadMoreRef = useRef<HTMLButtonElement | null>(null);
return (
<ContentContainer>
<Group
ref={intersectRef}
p="1rem"
position="apart"
>
<Group>
<PlayButton onClick={() => handlePlay()} />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
variant="subtle"
>
<RiMoreFill size={20} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map(
(type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={() => handlePlay(type.play)}
>
{type.label}
</DropdownMenu.Item>
),
)}
<DropdownMenu.Divider />
<DropdownMenu.Item
onClick={() => {
if (!detailQuery.data || !server) return;
openUpdatePlaylistModal({ playlist: detailQuery.data, server });
}}
>
Edit playlist
</DropdownMenu.Item>
<DropdownMenu.Item onClick={openDeletePlaylist}>
Delete playlist
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
uppercase
component={Link}
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
variant="subtle"
>
View full playlist
</Button>
</Group>
</Group>
<Box ref={tableContainerRef}>
<VirtualTable
ref={tableRef}
autoFitColumns
autoHeight
deselectOnClickOutside
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={columnDefs}
getRowId={(data) => {
// It's possible that there are duplicate song ids in a playlist
return `${data.data.id}-${data.data.pageIndex}`;
}}
rowData={playlistSongData}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
<MotionGroup
p="2rem"
position="center"
onViewportEnter={handleLoadMore}
>
<Button
ref={loadMoreRef}
compact
disabled={!playlistSongsQueryInfinite.hasNextPage}
loading={playlistSongsQueryInfinite.isFetchingNextPage}
variant="subtle"
onClick={handleLoadMore}
>
{playlistSongsQueryInfinite.hasNextPage ? 'Load more' : 'End of playlist'}
</Button>
</MotionGroup>
</ContentContainer>
);
};

View file

@ -10,69 +10,70 @@ import { LibraryItem } from '/@/renderer/api/types';
import { useCurrentServer } from '../../../store/auth.store';
interface PlaylistDetailHeaderProps {
background: string;
imagePlaceholderUrl?: string | null;
imageUrl?: string | null;
background: string;
imagePlaceholderUrl?: string | null;
imageUrl?: string | null;
}
export const PlaylistDetailHeader = forwardRef(
(
{ background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps,
ref: Ref<HTMLDivElement>,
) => {
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
(
{ background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps,
ref: Ref<HTMLDivElement>,
) => {
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const metadataItems = [
{
id: 'songCount',
secondary: false,
value: `${detailQuery?.data?.songCount || 0} songs`,
},
{
id: 'duration',
secondary: true,
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
const metadataItems = [
{
id: 'songCount',
secondary: false,
value: `${detailQuery?.data?.songCount || 0} songs`,
},
{
id: 'duration',
secondary: true,
value:
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
const isSmartPlaylist = detailQuery?.data?.rules;
const isSmartPlaylist = detailQuery?.data?.rules;
return (
<Stack>
<LibraryHeader
ref={ref}
background={background}
imagePlaceholderUrl={imagePlaceholderUrl}
imageUrl={imageUrl}
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.PLAYLIST }}
title={detailQuery?.data?.name || ''}
>
<Stack>
<Group spacing="sm">
{metadataItems.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
</Fragment>
))}
{isSmartPlaylist && (
<>
<Text $noSelect></Text>
<Badge
radius="sm"
size="md"
>
Smart Playlist
</Badge>
</>
)}
</Group>
<Text lineClamp={3}>{detailQuery?.data?.description}</Text>
</Stack>
</LibraryHeader>
</Stack>
);
},
return (
<Stack>
<LibraryHeader
ref={ref}
background={background}
imagePlaceholderUrl={imagePlaceholderUrl}
imageUrl={imageUrl}
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.PLAYLIST }}
title={detailQuery?.data?.name || ''}
>
<Stack>
<Group spacing="sm">
{metadataItems.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
</Fragment>
))}
{isSmartPlaylist && (
<>
<Text $noSelect></Text>
<Badge
radius="sm"
size="md"
>
Smart Playlist
</Badge>
</>
)}
</Group>
<Text lineClamp={3}>{detailQuery?.data?.description}</Text>
</Stack>
</LibraryHeader>
</Stack>
);
},
);

View file

@ -1,19 +1,19 @@
import { MutableRefObject, useCallback, useMemo } from 'react';
import type {
BodyScrollEvent,
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
RowDoubleClickedEvent,
BodyScrollEvent,
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
RowDoubleClickedEvent,
} from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import {
useCurrentServer,
usePlaylistDetailStore,
usePlaylistDetailTablePagination,
useSetPlaylistDetailTable,
useSetPlaylistDetailTablePagination,
useCurrentServer,
usePlaylistDetailStore,
usePlaylistDetailTablePagination,
useSetPlaylistDetailTable,
useSetPlaylistDetailTablePagination,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { useQueryClient } from '@tanstack/react-query';
@ -21,16 +21,16 @@ import { AnimatePresence } from 'framer-motion';
import debounce from 'lodash/debounce';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import {
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import {
LibraryItem,
PlaylistSongListQuery,
QueueSong,
SongListSort,
SortOrder,
LibraryItem,
PlaylistSongListQuery,
QueueSong,
SongListSort,
SortOrder,
} from '/@/renderer/api/types';
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
import { useParams } from 'react-router';
@ -42,230 +42,232 @@ import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-gr
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
interface PlaylistDetailContentProps {
tableRef: MutableRefObject<AgGridReactType | null>;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => {
const { playlistId } = useParams() as { playlistId: string };
const queryClient = useQueryClient();
const server = useCurrentServer();
const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
return {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
const { playlistId } = useParams() as { playlistId: string };
const queryClient = useQueryClient();
const server = useCurrentServer();
const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
return {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
};
}, [page?.table.id, playlistId]);
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const p = usePlaylistDetailTablePagination(playlistId);
const pagination = {
currentPage: p?.currentPage || 0,
itemsPerPage: p?.itemsPerPage || 100,
scrollOffset: p?.scrollOffset || 0,
totalItems: p?.totalItems || 1,
totalPages: p?.totalPages || 1,
};
}, [page?.table.id, playlistId]);
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const setPagination = useSetPlaylistDetailTablePagination();
const setTable = useSetPlaylistDetailTable();
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const p = usePlaylistDetailTablePagination(playlistId);
const pagination = {
currentPage: p?.currentPage || 0,
itemsPerPage: p?.itemsPerPage || 100,
scrollOffset: p?.scrollOffset || 0,
totalItems: p?.totalItems || 1,
totalPages: p?.totalPages || 1,
};
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const setPagination = useSetPlaylistDetailTablePagination();
const setTable = useSetPlaylistDetailTable();
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const checkPlaylistList = usePlaylistSongList({
query: {
id: playlistId,
limit: 1,
startIndex: 0,
},
serverId: server?.id,
});
const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns),
[page.table.columns],
);
const onGridReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
const checkPlaylistList = usePlaylistSongList({
query: {
id: playlistId,
limit,
startIndex,
...filters,
});
if (!server) return;
const songsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query: {
id: playlistId,
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
limit: 1,
startIndex: 0,
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
},
[filters, pagination.scrollOffset, playlistId, queryClient, server],
);
const handleGridSizeChange = () => {
if (page.table.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const onPaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
try {
// Scroll to top of page on pagination change
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) {
console.log(err);
}
setPagination(playlistId, {
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
});
},
[
isPaginationEnabled,
pagination.currentPage,
pagination.itemsPerPage,
playlistId,
setPagination,
],
);
const handleColumnChange = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
const columnsInSettings = page.table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!page.table.autoFit && {
width: column.getActualWidth(),
}),
});
}
}
setTable({ columns: updatedColumns });
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
const debouncedColumnChange = debounce(handleColumnChange, 200);
const handleScroll = (e: BodyScrollEvent) => {
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
setPagination(playlistId, { scrollOffset });
};
const contextMenuItems = useMemo(() => {
if (detailQuery?.data?.rules) {
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}, [detailQuery?.data?.rules]);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
playlistId,
tableRef,
});
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
initialSongId: e.data.id,
playType: playButtonBehavior,
serverId: server?.id,
});
};
return (
<>
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
autoFitColumns={page.table.autoFit}
columnDefs={columnDefs}
getRowId={(data) => data.data.uniqueId}
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={pagination.itemsPerPage || 100}
rowHeight={page.table.rowHeight || 40}
rowModelType="infinite"
onBodyScrollEnd={handleScroll}
onCellContextMenu={handleContextMenu}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onGridReady={onGridReady}
onGridSizeChanged={handleGridSizeChange}
onPaginationChanged={onPaginationChanged}
onRowDoubleClicked={handleRowDoubleClick}
/>
</VirtualGridAutoSizerContainer>
{isPaginationEnabled && (
<AnimatePresence
presenceAffectsLayout
initial={false}
mode="wait"
>
{page.display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pageKey={playlistId}
pagination={pagination}
setIdPagination={setPagination}
tableRef={tableRef}
/>
)}
</AnimatePresence>
)}
</>
);
const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns),
[page.table.columns],
);
const onGridReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
id: playlistId,
limit,
startIndex,
...filters,
});
if (!server) return;
const songsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query: {
id: playlistId,
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
},
[filters, pagination.scrollOffset, playlistId, queryClient, server],
);
const handleGridSizeChange = () => {
if (page.table.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const onPaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
try {
// Scroll to top of page on pagination change
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) {
console.log(err);
}
setPagination(playlistId, {
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
});
},
[
isPaginationEnabled,
pagination.currentPage,
pagination.itemsPerPage,
playlistId,
setPagination,
],
);
const handleColumnChange = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
const columnsInSettings = page.table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find(
(c) => c.column === column.getColDef().colId,
);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!page.table.autoFit && {
width: column.getActualWidth(),
}),
});
}
}
setTable({ columns: updatedColumns });
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
const debouncedColumnChange = debounce(handleColumnChange, 200);
const handleScroll = (e: BodyScrollEvent) => {
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
setPagination(playlistId, { scrollOffset });
};
const contextMenuItems = useMemo(() => {
if (detailQuery?.data?.rules) {
return SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}
return PLAYLIST_SONG_CONTEXT_MENU_ITEMS;
}, [detailQuery?.data?.rules]);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, contextMenuItems, {
playlistId,
tableRef,
});
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
return (
<>
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
autoFitColumns={page.table.autoFit}
columnDefs={columnDefs}
getRowId={(data) => data.data.uniqueId}
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={pagination.itemsPerPage || 100}
rowHeight={page.table.rowHeight || 40}
rowModelType="infinite"
onBodyScrollEnd={handleScroll}
onCellContextMenu={handleContextMenu}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onGridReady={onGridReady}
onGridSizeChanged={handleGridSizeChange}
onPaginationChanged={onPaginationChanged}
onRowDoubleClicked={handleRowDoubleClick}
/>
</VirtualGridAutoSizerContainer>
{isPaginationEnabled && (
<AnimatePresence
presenceAffectsLayout
initial={false}
mode="wait"
>
{page.display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pageKey={playlistId}
pagination={pagination}
setIdPagination={setPagination}
tableRef={tableRef}
/>
)}
</AnimatePresence>
)}
</>
);
};

View file

@ -5,40 +5,40 @@ import { Flex, Group, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import {
RiSortAsc,
RiSortDesc,
RiMoreFill,
RiSettings3Fill,
RiPlayFill,
RiAddCircleFill,
RiAddBoxFill,
RiEditFill,
RiDeleteBinFill,
RiRefreshLine,
RiSortAsc,
RiSortDesc,
RiMoreFill,
RiSettings3Fill,
RiPlayFill,
RiAddCircleFill,
RiAddBoxFill,
RiEditFill,
RiDeleteBinFill,
RiRefreshLine,
} from 'react-icons/ri';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
import {
DropdownMenu,
Button,
Slider,
MultiSelect,
Switch,
Text,
ConfirmModal,
toast,
DropdownMenu,
Button,
Slider,
MultiSelect,
Switch,
Text,
ConfirmModal,
toast,
} from '/@/renderer/components';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useContainerQuery } from '/@/renderer/hooks';
import {
useCurrentServer,
SongListFilter,
usePlaylistDetailStore,
useSetPlaylistDetailFilters,
useSetPlaylistDetailTable,
useSetPlaylistStore,
useSetPlaylistTablePagination,
useCurrentServer,
SongListFilter,
usePlaylistDetailStore,
useSetPlaylistDetailFilters,
useSetPlaylistDetailTable,
useSetPlaylistStore,
useSetPlaylistTablePagination,
} from '/@/renderer/store';
import { ListDisplayType, ServerType, Play, TableColumn } from '/@/renderer/types';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
@ -48,431 +48,446 @@ import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/componen
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
const FILTERS = {
jellyfin: [
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Duration', value: SongListSort.DURATION },
{ defaultOrder: SortOrder.ASC, name: 'Most Played', value: SongListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: SongListSort.RANDOM },
{ defaultOrder: SortOrder.ASC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
{ defaultOrder: SortOrder.ASC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
{ defaultOrder: SortOrder.ASC, name: 'Release Date', value: SongListSort.RELEASE_DATE },
],
navidrome: [
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
{ defaultOrder: SortOrder.DESC, name: 'BPM', value: SongListSort.BPM },
{ defaultOrder: SortOrder.ASC, name: 'Channels', value: SongListSort.CHANNELS },
{ defaultOrder: SortOrder.ASC, name: 'Comment', value: SongListSort.COMMENT },
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: SongListSort.DURATION },
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: SongListSort.FAVORITED },
{ defaultOrder: SortOrder.ASC, name: 'Genre', value: SongListSort.GENRE },
{ defaultOrder: SortOrder.ASC, name: 'Id', value: SongListSort.ID },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
{ defaultOrder: SortOrder.DESC, name: 'Play Count', value: SongListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: SongListSort.RATING },
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
{ defaultOrder: SortOrder.DESC, name: 'Year', value: SongListSort.YEAR },
],
jellyfin: [
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Duration', value: SongListSort.DURATION },
{ defaultOrder: SortOrder.ASC, name: 'Most Played', value: SongListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: SongListSort.RANDOM },
{ defaultOrder: SortOrder.ASC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
{
defaultOrder: SortOrder.ASC,
name: 'Recently Played',
value: SongListSort.RECENTLY_PLAYED,
},
{ defaultOrder: SortOrder.ASC, name: 'Release Date', value: SongListSort.RELEASE_DATE },
],
navidrome: [
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
{ defaultOrder: SortOrder.DESC, name: 'BPM', value: SongListSort.BPM },
{ defaultOrder: SortOrder.ASC, name: 'Channels', value: SongListSort.CHANNELS },
{ defaultOrder: SortOrder.ASC, name: 'Comment', value: SongListSort.COMMENT },
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: SongListSort.DURATION },
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: SongListSort.FAVORITED },
{ defaultOrder: SortOrder.ASC, name: 'Genre', value: SongListSort.GENRE },
{ defaultOrder: SortOrder.ASC, name: 'Id', value: SongListSort.ID },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
{ defaultOrder: SortOrder.DESC, name: 'Play Count', value: SongListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: SongListSort.RATING },
{
defaultOrder: SortOrder.DESC,
name: 'Recently Added',
value: SongListSort.RECENTLY_ADDED,
},
{
defaultOrder: SortOrder.DESC,
name: 'Recently Played',
value: SongListSort.RECENTLY_PLAYED,
},
{ defaultOrder: SortOrder.DESC, name: 'Year', value: SongListSort.YEAR },
],
};
const ORDER = [
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
];
interface PlaylistDetailSongListHeaderFiltersProps {
handleToggleShowQueryBuilder: () => void;
tableRef: MutableRefObject<AgGridReactType | null>;
handleToggleShowQueryBuilder: () => void;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistDetailSongListHeaderFilters = ({
tableRef,
handleToggleShowQueryBuilder,
tableRef,
handleToggleShowQueryBuilder,
}: PlaylistDetailSongListHeaderFiltersProps) => {
const { playlistId } = useParams() as { playlistId: string };
const queryClient = useQueryClient();
const server = useCurrentServer();
const setPage = useSetPlaylistStore();
const setFilter = useSetPlaylistDetailFilters();
const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
};
const { playlistId } = useParams() as { playlistId: string };
const queryClient = useQueryClient();
const server = useCurrentServer();
const setPage = useSetPlaylistStore();
const setFilter = useSetPlaylistDetailFilters();
const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
};
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const isSmartPlaylist = detailQuery.data?.rules;
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const isSmartPlaylist = detailQuery.data?.rules;
const handlePlayQueueAdd = usePlayQueueAdd();
const handlePlayQueueAdd = usePlayQueueAdd();
const cq = useContainerQuery();
const cq = useContainerQuery();
const setPagination = useSetPlaylistTablePagination();
const setTable = useSetPlaylistDetailTable();
const setPagination = useSetPlaylistTablePagination();
const setTable = useSetPlaylistDetailTable();
const sortByLabel =
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)?.name) ||
'Unknown';
const sortByLabel =
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)
?.name) ||
'Unknown';
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
const handleItemSize = (e: number) => {
setTable({ rowHeight: e });
};
const handleItemSize = (e: number) => {
setTable({ rowHeight: e });
};
const handleFilterChange = useCallback(
async (filters: SongListFilter) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const handleFilterChange = useCallback(
async (filters: SongListFilter) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
id: playlistId,
limit,
startIndex,
...filters,
});
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, {
id: playlistId,
limit,
startIndex,
...filters,
});
const songsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
const songsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query: {
id: playlistId,
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
},
query: {
id: playlistId,
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
if (page.display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ data: { currentPage: 0 } });
}
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
if (page.display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ data: { currentPage: 0 } });
}
},
[tableRef, page.display, server, playlistId, queryClient, setPagination],
);
const handleRefresh = () => {
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
handleFilterChange({ ...page?.table.id[playlistId].filter, ...filters });
};
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
const updatedFilters = setFilter(playlistId, {
sortBy: e.currentTarget.value as SongListSort,
sortOrder: sortOrder || SortOrder.ASC,
});
handleFilterChange(updatedFilters);
},
[handleFilterChange, playlistId, server?.type, setFilter],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter(playlistId, { sortOrder: newSortOrder });
handleFilterChange(updatedFilters);
}, [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 } });
},
[page, setPage],
);
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = page.table.columns;
if (values.length === 0) {
return setTable({
columns: [],
});
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
setTable({ columns: [...existingColumns, newColumn] });
} else {
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
setTable({ columns: newColumns });
}
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ autoFit: e.currentTarget.checked });
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
const handlePlay = async (playType: Play) => {
handlePlayQueueAdd?.({
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
playType,
});
};
const deletePlaylistMutation = useDeletePlaylist({});
const handleDeletePlaylist = useCallback(() => {
if (!detailQuery.data) return;
deletePlaylistMutation?.mutate(
{ query: { id: detailQuery.data.id }, serverId: detailQuery.data.id },
{
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
});
},
onSuccess: () => {
toast.success({
message: `Playlist has been deleted`,
});
},
},
[tableRef, page.display, server, playlistId, queryClient, setPagination],
);
closeAllModals();
}, [deletePlaylistMutation, detailQuery.data]);
const openDeletePlaylistModal = () => {
openModal({
children: (
<ConfirmModal onConfirm={handleDeletePlaylist}>
<Text>Are you sure you want to delete this playlist?</Text>
</ConfirmModal>
),
title: 'Delete playlist(s)',
});
};
const handleRefresh = () => {
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
handleFilterChange({ ...page?.table.id[playlistId].filter, ...filters });
};
return (
<Flex justify="space-between">
<Group
ref={cq.ref}
spacing="sm"
w="100%"
>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
const updatedFilters = setFilter(playlistId, {
sortBy: e.currentTarget.value as SongListSort,
sortOrder: sortOrder || SortOrder.ASC,
});
handleFilterChange(updatedFilters);
},
[handleFilterChange, playlistId, server?.type, setFilter],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter(playlistId, { sortOrder: newSortOrder });
handleFilterChange(updatedFilters);
}, [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 } });
},
[page, setPage],
);
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = page.table.columns;
if (values.length === 0) {
return setTable({
columns: [],
});
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
setTable({ columns: [...existingColumns, newColumn] });
} else {
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
setTable({ columns: newColumns });
}
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ autoFit: e.currentTarget.checked });
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
const handlePlay = async (playType: Play) => {
handlePlayQueueAdd?.({
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
playType,
});
};
const deletePlaylistMutation = useDeletePlaylist({});
const handleDeletePlaylist = useCallback(() => {
if (!detailQuery.data) return;
deletePlaylistMutation?.mutate(
{ query: { id: detailQuery.data.id }, serverId: detailQuery.data.id },
{
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
});
},
onSuccess: () => {
toast.success({
message: `Playlist has been deleted`,
});
},
},
);
closeAllModals();
}, [deletePlaylistMutation, detailQuery.data]);
const openDeletePlaylistModal = () => {
openModal({
children: (
<ConfirmModal onConfirm={handleDeletePlaylist}>
<Text>Are you sure you want to delete this playlist?</Text>
</ConfirmModal>
),
title: 'Delete playlist(s)',
});
};
return (
<Flex justify="space-between">
<Group
ref={cq.ref}
spacing="sm"
w="100%"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
key={`filter-${filter.name}`}
$isActive={filter.value === filters.sortBy}
value={filter.value}
onClick={handleSetSortBy}
>
{filter.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
fw="600"
size="md"
variant="subtle"
onClick={handleToggleSortOrder}
>
{cq.isSm ? (
sortOrderLabel
) : (
<>
{filters.sortOrder === SortOrder.ASC ? (
<RiSortAsc size="1.3rem" />
) : (
<RiSortDesc size="1.3rem" />
)}
</>
)}
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
<RiMoreFill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiPlayFill />}
onClick={() => handlePlay(Play.NOW)}
>
Play
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay(Play.LAST)}
>
Add to queue
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay(Play.NEXT)}
>
Add to queue next
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
icon={<RiEditFill />}
onClick={() =>
openUpdatePlaylistModal({
playlist: detailQuery.data!,
server: server!,
})
}
>
Edit playlist
</DropdownMenu.Item>
<DropdownMenu.Item
disabled
icon={<RiDeleteBinFill />}
onClick={openDeletePlaylistModal}
>
Delete playlist
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
</DropdownMenu.Item>
{server?.type === ServerType.NAVIDROME && !isSmartPlaylist && (
<>
<DropdownMenu.Divider />
<DropdownMenu.Item
$danger
onClick={handleToggleShowQueryBuilder}
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
key={`filter-${filter.name}`}
$isActive={filter.value === filters.sortBy}
value={filter.value}
onClick={handleSetSortBy}
>
{filter.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
fw="600"
size="md"
variant="subtle"
onClick={handleToggleSortOrder}
>
Toggle smart playlist editor
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={page.display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
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>Item size</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)}
width={300}
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={page.table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Flex>
);
{cq.isSm ? (
sortOrderLabel
) : (
<>
{filters.sortOrder === SortOrder.ASC ? (
<RiSortAsc size="1.3rem" />
) : (
<RiSortDesc size="1.3rem" />
)}
</>
)}
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
<RiMoreFill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiPlayFill />}
onClick={() => handlePlay(Play.NOW)}
>
Play
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay(Play.LAST)}
>
Add to queue
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay(Play.NEXT)}
>
Add to queue next
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
icon={<RiEditFill />}
onClick={() =>
openUpdatePlaylistModal({
playlist: detailQuery.data!,
server: server!,
})
}
>
Edit playlist
</DropdownMenu.Item>
<DropdownMenu.Item
disabled
icon={<RiDeleteBinFill />}
onClick={openDeletePlaylistModal}
>
Delete playlist
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
</DropdownMenu.Item>
{server?.type === ServerType.NAVIDROME && !isSmartPlaylist && (
<>
<DropdownMenu.Divider />
<DropdownMenu.Item
$danger
onClick={handleToggleShowQueryBuilder}
>
Toggle smart playlist editor
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={page.display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
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>Item size</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,
)}
width={300}
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={page.table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Flex>
);
};

View file

@ -13,56 +13,60 @@ import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
interface PlaylistDetailHeaderProps {
handleToggleShowQueryBuilder: () => void;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
handleToggleShowQueryBuilder: () => void;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistDetailSongListHeader = ({
tableRef,
itemCount,
handleToggleShowQueryBuilder,
tableRef,
itemCount,
handleToggleShowQueryBuilder,
}: PlaylistDetailHeaderProps) => {
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const handlePlayQueueAdd = usePlayQueueAdd();
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const handlePlayQueueAdd = usePlayQueueAdd();
const handlePlay = async (playType: Play) => {
handlePlayQueueAdd?.({
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
playType,
});
};
const handlePlay = async (playType: Play) => {
handlePlayQueueAdd?.({
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
playType,
});
};
const playButtonBehavior = usePlayButtonBehavior();
const playButtonBehavior = usePlayButtonBehavior();
if (detailQuery.isLoading) return null;
const isSmartPlaylist = detailQuery?.data?.rules;
if (detailQuery.isLoading) return null;
const isSmartPlaylist = detailQuery?.data?.rules;
return (
<Stack spacing={0}>
<PageHeader backgroundColor="var(--titlebar-bg)">
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
py="0.3rem"
radius="sm"
>
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
</Paper>
{isSmartPlaylist && <Badge size="lg">Smart playlist</Badge>}
</LibraryHeaderBar>
</PageHeader>
<Paper p="1rem">
<PlaylistDetailSongListHeaderFilters
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
tableRef={tableRef}
/>
</Paper>
</Stack>
);
return (
<Stack spacing={0}>
<PageHeader backgroundColor="var(--titlebar-bg)">
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
py="0.3rem"
radius="sm"
>
{itemCount === null || itemCount === undefined ? (
<SpinnerIcon />
) : (
itemCount
)}
</Paper>
{isSmartPlaylist && <Badge size="lg">Smart playlist</Badge>}
</LibraryHeaderBar>
</PageHeader>
<Paper p="1rem">
<PlaylistDetailSongListHeaderFilters
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
tableRef={tableRef}
/>
</Paper>
</Stack>
);
};

View file

@ -1,11 +1,11 @@
import { MutableRefObject, useCallback, useMemo } from 'react';
import type {
BodyScrollEvent,
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
RowDoubleClickedEvent,
BodyScrollEvent,
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
RowDoubleClickedEvent,
} from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
@ -13,11 +13,11 @@ import { useQueryClient } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
useCurrentServer,
usePlaylistListStore,
usePlaylistTablePagination,
useSetPlaylistTable,
useSetPlaylistTablePagination,
useCurrentServer,
usePlaylistListStore,
usePlaylistTablePagination,
useSetPlaylistTable,
useSetPlaylistTablePagination,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { AnimatePresence } from 'framer-motion';
@ -31,198 +31,203 @@ import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-gr
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
interface PlaylistListContentProps {
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistListContent = ({ tableRef, itemCount }: PlaylistListContentProps) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const server = useCurrentServer();
const page = usePlaylistListStore();
const navigate = useNavigate();
const queryClient = useQueryClient();
const server = useCurrentServer();
const page = usePlaylistListStore();
const pagination = usePlaylistTablePagination();
const setPagination = useSetPlaylistTablePagination();
const setTable = useSetPlaylistTable();
const pagination = usePlaylistTablePagination();
const setPagination = useSetPlaylistTablePagination();
const setTable = useSetPlaylistTable();
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns),
[page.table.columns],
);
const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns),
[page.table.columns],
);
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
};
}, []);
const onGridReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.playlists.list(server?.id || '', {
limit,
startIndex,
...page.filter,
});
const playlistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...page.filter,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
playlistsRes?.items || [],
playlistsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
params.api.ensureIndexVisible(page.table.scrollOffset, 'top');
},
[page.filter, page.table.scrollOffset, queryClient, server],
);
const onPaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
try {
// Scroll to top of page on pagination change
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) {
console.log(err);
}
setPagination({
data: {
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
},
});
},
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
);
const handleGridSizeChange = () => {
if (page.table.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
}, []);
const onGridReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const handleColumnChange = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
const queryKey = queryKeys.playlists.list(server?.id || '', {
limit,
startIndex,
...page.filter,
});
if (!columnsOrder) return;
const playlistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...page.filter,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
const columnsInSettings = page.table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find(
(c) => c.column === column.getColDef().colId,
);
params.successCallback(playlistsRes?.items || [], playlistsRes?.totalRecordCount || 0);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
params.api.ensureIndexVisible(page.table.scrollOffset, 'top');
},
[page.filter, page.table.scrollOffset, queryClient, server],
);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!page.table.autoFit && {
width: column.getActualWidth(),
}),
});
}
}
const onPaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
setTable({ columns: updatedColumns });
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
try {
// Scroll to top of page on pagination change
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) {
console.log(err);
}
const debouncedColumnChange = debounce(handleColumnChange, 200);
setPagination({
data: {
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
},
});
},
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
);
const handleScroll = (e: BodyScrollEvent) => {
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
setTable({ scrollOffset });
};
const handleGridSizeChange = () => {
if (page.table.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const handleContextMenu = useHandleTableContextMenu(
LibraryItem.PLAYLIST,
PLAYLIST_CONTEXT_MENU_ITEMS,
);
const handleColumnChange = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
if (!e.data) return;
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
};
if (!columnsOrder) return;
const columnsInSettings = page.table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!page.table.autoFit && {
width: column.getActualWidth(),
}),
});
}
}
setTable({ columns: updatedColumns });
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
const debouncedColumnChange = debounce(handleColumnChange, 200);
const handleScroll = (e: BodyScrollEvent) => {
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
setTable({ scrollOffset });
};
const handleContextMenu = useHandleTableContextMenu(
LibraryItem.PLAYLIST,
PLAYLIST_CONTEXT_MENU_ITEMS,
);
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
if (!e.data) return;
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
};
return (
<Stack
h="100%"
spacing={0}
>
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
suppressRowDrag
autoFitColumns={page.table.autoFit}
blockLoadDebounceMillis={200}
cacheBlockSize={200}
cacheOverflowSize={1}
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.id}
infiniteInitialRowCount={itemCount || 100}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={page.table.pagination.itemsPerPage || 100}
rowBuffer={20}
rowHeight={page.table.rowHeight || 40}
rowModelType="infinite"
rowSelection="multiple"
onBodyScrollEnd={handleScroll}
onCellContextMenu={handleContextMenu}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onGridReady={onGridReady}
onGridSizeChanged={handleGridSizeChange}
onPaginationChanged={onPaginationChanged}
onRowDoubleClicked={handleRowDoubleClick}
/>
</VirtualGridAutoSizerContainer>
<AnimatePresence
presenceAffectsLayout
initial={false}
mode="wait"
>
{page.display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pageKey=""
pagination={pagination}
setPagination={setPagination}
tableRef={tableRef}
/>
)}
</AnimatePresence>
</Stack>
);
return (
<Stack
h="100%"
spacing={0}
>
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
suppressRowDrag
autoFitColumns={page.table.autoFit}
blockLoadDebounceMillis={200}
cacheBlockSize={200}
cacheOverflowSize={1}
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.id}
infiniteInitialRowCount={itemCount || 100}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={page.table.pagination.itemsPerPage || 100}
rowBuffer={20}
rowHeight={page.table.rowHeight || 40}
rowModelType="infinite"
rowSelection="multiple"
onBodyScrollEnd={handleScroll}
onCellContextMenu={handleContextMenu}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onGridReady={onGridReady}
onGridSizeChanged={handleGridSizeChange}
onPaginationChanged={onPaginationChanged}
onRowDoubleClicked={handleRowDoubleClick}
/>
</VirtualGridAutoSizerContainer>
<AnimatePresence
presenceAffectsLayout
initial={false}
mode="wait"
>
{page.display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pageKey=""
pagination={pagination}
setPagination={setPagination}
tableRef={tableRef}
/>
)}
</AnimatePresence>
</Stack>
);
};

View file

@ -10,319 +10,327 @@ import { SortOrder, PlaylistListSort } from '/@/renderer/api/types';
import { DropdownMenu, Text, Button, Slider, MultiSelect, Switch } from '/@/renderer/components';
import { useContainerQuery } from '/@/renderer/hooks';
import {
PlaylistListFilter,
useCurrentServer,
usePlaylistListStore,
useSetPlaylistFilters,
useSetPlaylistStore,
useSetPlaylistTable,
useSetPlaylistTablePagination,
PlaylistListFilter,
useCurrentServer,
usePlaylistListStore,
useSetPlaylistFilters,
useSetPlaylistStore,
useSetPlaylistTable,
useSetPlaylistTablePagination,
} from '/@/renderer/store';
import { ListDisplayType, TableColumn } from '/@/renderer/types';
import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
const FILTERS = {
jellyfin: [
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
],
navidrome: [
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Owner', value: PlaylistListSort.OWNER },
{ defaultOrder: SortOrder.DESC, name: 'Public', value: PlaylistListSort.PUBLIC },
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
{ defaultOrder: SortOrder.DESC, name: 'Updated At', value: PlaylistListSort.UPDATED_AT },
],
jellyfin: [
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
],
navidrome: [
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Owner', value: PlaylistListSort.OWNER },
{ defaultOrder: SortOrder.DESC, name: 'Public', value: PlaylistListSort.PUBLIC },
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
{ defaultOrder: SortOrder.DESC, name: 'Updated At', value: PlaylistListSort.UPDATED_AT },
],
};
const ORDER = [
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
];
interface PlaylistListHeaderFiltersProps {
tableRef: MutableRefObject<AgGridReactType | null>;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistListHeaderFilters = ({ tableRef }: PlaylistListHeaderFiltersProps) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const page = usePlaylistListStore();
const setPage = useSetPlaylistStore();
const setFilter = useSetPlaylistFilters();
const setTable = useSetPlaylistTable();
const setPagination = useSetPlaylistTablePagination();
const cq = useContainerQuery();
const queryClient = useQueryClient();
const server = useCurrentServer();
const page = usePlaylistListStore();
const setPage = useSetPlaylistStore();
const setFilter = useSetPlaylistFilters();
const setTable = useSetPlaylistTable();
const setPagination = useSetPlaylistTablePagination();
const cq = useContainerQuery();
const sortByLabel =
(server?.type &&
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
(f) => f.value === page.filter.sortBy,
)?.name) ||
'Unknown';
const sortByLabel =
(server?.type &&
(
FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]
).find((f) => f.value === page.filter.sortBy)?.name) ||
'Unknown';
const sortOrderLabel = ORDER.find((s) => s.value === page.filter.sortOrder)?.name;
const sortOrderLabel = ORDER.find((s) => s.value === page.filter.sortOrder)?.name;
const handleFilterChange = useCallback(
async (filters?: PlaylistListFilter) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const handleFilterChange = useCallback(
async (filters?: PlaylistListFilter) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const pageFilters = filters || page.filter;
const pageFilters = filters || page.filter;
const queryKey = queryKeys.playlists.list(server?.id || '', {
limit,
startIndex,
...pageFilters,
});
const queryKey = queryKeys.playlists.list(server?.id || '', {
limit,
startIndex,
...pageFilters,
});
const playlistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistList({
apiClientProps: {
server,
signal,
const playlistsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...pageFilters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
playlistsRes?.items || [],
playlistsRes?.totalRecordCount || 0,
);
},
query: {
limit,
startIndex,
...pageFilters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(playlistsRes?.items || [], playlistsRes?.totalRecordCount || 0);
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
setPagination({ data: { currentPage: 0 } });
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
setPagination({ data: { currentPage: 0 } });
},
[page.filter, queryClient, server, setPagination, tableRef],
);
[page.filter, queryClient, server, setPagination, tableRef],
);
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
const updatedFilters = setFilter({
sortBy: e.currentTarget.value as PlaylistListSort,
sortOrder: sortOrder || SortOrder.ASC,
});
const updatedFilters = setFilter({
sortBy: e.currentTarget.value as PlaylistListSort,
sortOrder: sortOrder || SortOrder.ASC,
});
handleFilterChange(updatedFilters);
},
[handleFilterChange, server?.type, setFilter],
);
handleFilterChange(updatedFilters);
},
[handleFilterChange, server?.type, setFilter],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = page.filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({ sortOrder: newSortOrder });
handleFilterChange(updatedFilters);
}, [page.filter.sortOrder, handleFilterChange, setFilter]);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder =
page.filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({ sortOrder: newSortOrder });
handleFilterChange(updatedFilters);
}, [page.filter.sortOrder, handleFilterChange, setFilter]);
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
const display = e.currentTarget.value as ListDisplayType;
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
const display = e.currentTarget.value as ListDisplayType;
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
if (display === ListDisplayType.TABLE) {
tableRef.current?.api.paginationSetPageSize(tableRef.current.props.infiniteInitialRowCount);
setPagination({ data: { currentPage: 0 } });
} else if (display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ data: { currentPage: 0 } });
}
},
[page, setPage, setPagination, tableRef],
);
if (display === ListDisplayType.TABLE) {
tableRef.current?.api.paginationSetPageSize(
tableRef.current.props.infiniteInitialRowCount,
);
setPagination({ data: { currentPage: 0 } });
} else if (display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ data: { currentPage: 0 } });
}
},
[page, setPage, setPagination, tableRef],
);
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = page.table.columns;
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = page.table.columns;
if (values.length === 0) {
return setTable({
columns: [],
});
}
if (values.length === 0) {
return setTable({
columns: [],
});
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
return setTable({ columns: [...existingColumns, newColumn] });
}
return setTable({ columns: [...existingColumns, newColumn] });
}
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
return setTable({ columns: newColumns });
};
return setTable({ columns: newColumns });
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ autoFit: e.currentTarget.checked });
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ autoFit: e.currentTarget.checked });
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
const handleRowHeight = (e: number) => {
setTable({ rowHeight: e });
};
const handleRowHeight = (e: number) => {
setTable({ rowHeight: e });
};
const handleRefresh = () => {
tableRef?.current?.api?.purgeInfiniteCache();
};
const handleRefresh = () => {
tableRef?.current?.api?.purgeInfiniteCache();
};
return (
<Flex justify="space-between">
<Group
ref={cq.ref}
spacing="sm"
w="100%"
>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
return (
<Flex justify="space-between">
<Group
ref={cq.ref}
spacing="sm"
w="100%"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
key={`filter-${filter.name}`}
$isActive={filter.value === page.filter.sortBy}
value={filter.value}
onClick={handleSetSortBy}
>
{filter.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
fw="600"
size="md"
variant="subtle"
onClick={handleToggleSortOrder}
>
{cq.isSm ? (
sortOrderLabel
) : (
<>
{page.filter.sortOrder === SortOrder.ASC ? (
<RiSortAsc size="1.3rem" />
) : (
<RiSortDesc size="1.3rem" />
)}
</>
)}
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
<RiMoreFill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={page.display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
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>Item Size</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={page.table.rowHeight || 0}
label={null}
max={100}
min={25}
onChangeEnd={handleRowHeight}
/>
</DropdownMenu.Item>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Stack>
<MultiSelect
clearable
data={PLAYLIST_TABLE_COLUMNS}
defaultValue={page.table?.columns.map((column) => column.column)}
width={300}
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={page.table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Flex>
);
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
key={`filter-${filter.name}`}
$isActive={filter.value === page.filter.sortBy}
value={filter.value}
onClick={handleSetSortBy}
>
{filter.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
fw="600"
size="md"
variant="subtle"
onClick={handleToggleSortOrder}
>
{cq.isSm ? (
sortOrderLabel
) : (
<>
{page.filter.sortOrder === SortOrder.ASC ? (
<RiSortAsc size="1.3rem" />
) : (
<RiSortDesc size="1.3rem" />
)}
</>
)}
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
<RiMoreFill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={page.display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
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>Item Size</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={page.table.rowHeight || 0}
label={null}
max={100}
min={25}
onChangeEnd={handleRowHeight}
/>
</DropdownMenu.Item>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Stack>
<MultiSelect
clearable
data={PLAYLIST_TABLE_COLUMNS}
defaultValue={page.table?.columns.map(
(column) => column.column,
)}
width={300}
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={page.table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Flex>
);
};

View file

@ -11,53 +11,57 @@ import { useCurrentServer } from '/@/renderer/store';
import { ServerType } from '/@/renderer/types';
interface PlaylistListHeaderProps {
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistListHeader = ({ itemCount, tableRef }: PlaylistListHeaderProps) => {
const cq = useContainerQuery();
const server = useCurrentServer();
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: 'Create Playlist',
});
};
const handleCreatePlaylistModal = () => {
openModal({
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
onClose: () => {
tableRef?.current?.api?.purgeInfiniteCache();
},
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
title: 'Create Playlist',
});
};
return (
<Stack
ref={cq.ref}
spacing={0}
>
<PageHeader backgroundColor="var(--titlebar-bg)">
<Flex
align="center"
justify="space-between"
w="100%"
return (
<Stack
ref={cq.ref}
spacing={0}
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Playlists</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
py="0.3rem"
radius="sm"
>
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
<PageHeader backgroundColor="var(--titlebar-bg)">
<Flex
align="center"
justify="space-between"
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Playlists</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
py="0.3rem"
radius="sm"
>
{itemCount === null || itemCount === undefined ? (
<SpinnerIcon />
) : (
itemCount
)}
</Paper>
</LibraryHeaderBar>
<Button onClick={handleCreatePlaylistModal}>Create playlist</Button>
</Flex>
</PageHeader>
<Paper p="1rem">
<PlaylistListHeaderFilters tableRef={tableRef} />
</Paper>
</LibraryHeaderBar>
<Button onClick={handleCreatePlaylistModal}>Create playlist</Button>
</Flex>
</PageHeader>
<Paper p="1rem">
<PlaylistListHeaderFilters tableRef={tableRef} />
</Paper>
</Stack>
);
</Stack>
);
};

View file

@ -6,468 +6,470 @@ import get from 'lodash/get';
import setWith from 'lodash/setWith';
import { nanoid } from 'nanoid';
import {
Button,
DropdownMenu,
MotionFlex,
NumberInput,
QueryBuilder,
ScrollArea,
Select,
Button,
DropdownMenu,
MotionFlex,
NumberInput,
QueryBuilder,
ScrollArea,
Select,
} from '/@/renderer/components';
import {
convertNDQueryToQueryGroup,
convertQueryGroupToNDQuery,
convertNDQueryToQueryGroup,
convertQueryGroupToNDQuery,
} from '/@/renderer/features/playlists/utils';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
import { RiMore2Fill, RiSaveLine } from 'react-icons/ri';
import { SongListSort } from '/@/renderer/api/types';
import {
NDSongQueryBooleanOperators,
NDSongQueryDateOperators,
NDSongQueryFields,
NDSongQueryNumberOperators,
NDSongQueryStringOperators,
NDSongQueryBooleanOperators,
NDSongQueryDateOperators,
NDSongQueryFields,
NDSongQueryNumberOperators,
NDSongQueryStringOperators,
} from '/@/renderer/api/navidrome.types';
type AddArgs = {
groupIndex: number[];
level: number;
groupIndex: number[];
level: number;
};
type DeleteArgs = {
groupIndex: number[];
level: number;
uniqueId: string;
groupIndex: number[];
level: number;
uniqueId: string;
};
interface PlaylistQueryBuilderProps {
isSaving?: boolean;
limit?: number;
onSave?: (
parsedFilter: any,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => void;
onSaveAs?: (
parsedFilter: any,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => void;
query: any;
sortBy: SongListSort;
sortOrder: 'asc' | 'desc';
isSaving?: boolean;
limit?: number;
onSave?: (
parsedFilter: any,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => void;
onSaveAs?: (
parsedFilter: any,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => void;
query: any;
sortBy: SongListSort;
sortOrder: 'asc' | 'desc';
}
const DEFAULT_QUERY = {
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: '',
},
],
type: 'all' as 'all' | 'any',
uniqueId: nanoid(),
};
export type PlaylistQueryBuilderRef = {
getFilters: () => {
extraFilters: {
limit?: number;
sortBy?: string;
sortOrder?: string;
};
filters: QueryBuilderGroup;
};
};
export const PlaylistQueryBuilder = forwardRef(
(
{ sortOrder, sortBy, limit, isSaving, query, onSave, onSaveAs }: PlaylistQueryBuilderProps,
ref: Ref<PlaylistQueryBuilderRef>,
) => {
const [filters, setFilters] = useState<QueryBuilderGroup>(
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
);
const extraFiltersForm = useForm({
initialValues: {
limit,
sortBy,
sortOrder,
},
});
useImperativeHandle(ref, () => ({
getFilters: () => ({
extraFilters: extraFiltersForm.values,
filters,
}),
}));
const handleResetFilters = () => {
if (query) {
setFilters(convertNDQueryToQueryGroup(query));
} else {
setFilters(DEFAULT_QUERY);
}
};
const handleClearFilters = () => {
setFilters(DEFAULT_QUERY);
};
const setFilterHandler = (newFilters: QueryBuilderGroup) => {
setFilters(newFilters);
};
const handleSave = () => {
onSave?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
};
const handleSaveAs = () => {
onSaveAs?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
};
const handleAddRuleGroup = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = clone(filters);
const getPath = (level: number) => {
if (level === 0) return 'group';
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.group`;
};
const path = getPath(level);
const updatedFilters = setWith(
filtersCopy,
path,
[
...get(filtersCopy, path),
{
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: '',
},
],
type: 'any',
uniqueId: nanoid(),
},
],
clone,
);
setFilterHandler(updatedFilters);
};
const handleDeleteRuleGroup = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = clone(filters);
const getPath = (level: number) => {
if (level === 0) return 'group';
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
if (i !== groupIndex.length - 1) {
str.push(`group[${groupIndex[i]}]`);
} else {
str.push(`group`);
}
}
return `${str.join('.')}`;
};
const path = getPath(level);
const updatedFilters = setWith(
filtersCopy,
path,
[
...get(filtersCopy, path).filter(
(group: QueryBuilderGroup) => group.uniqueId !== uniqueId,
),
],
clone,
);
setFilterHandler(updatedFilters);
};
const getRulePath = (level: number, groupIndex: number[]) => {
if (level === 0) return 'rules';
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.rules`;
};
const handleAddRule = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
[
...get(filtersCopy, path),
{
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: null,
},
],
clone,
);
setFilterHandler(updatedFilters);
};
const handleDeleteRule = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).filter((rule: QueryBuilderRule) => rule.uniqueId !== uniqueId),
clone,
);
setFilterHandler(updatedFilters);
};
const handleChangeField = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).map((rule: QueryBuilderGroup) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
field: value,
operator: '',
value: '',
};
}),
clone,
);
setFilterHandler(updatedFilters);
};
const handleChangeType = (args: any) => {
const { level, groupIndex, value } = args;
const filtersCopy = clone(filters);
if (level === 0) {
return setFilterHandler({ ...filtersCopy, type: value });
}
const getTypePath = () => {
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
str.push(`group[${groupIndex[i]}]`);
}
return `${str.join('.')}`;
};
const path = getTypePath();
const updatedFilters = setWith(
filtersCopy,
path,
{
...get(filtersCopy, path),
type: value,
},
clone,
);
],
type: 'all' as 'all' | 'any',
uniqueId: nanoid(),
};
return setFilterHandler(updatedFilters);
export type PlaylistQueryBuilderRef = {
getFilters: () => {
extraFilters: {
limit?: number;
sortBy?: string;
sortOrder?: string;
};
filters: QueryBuilderGroup;
};
};
const handleChangeOperator = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
export const PlaylistQueryBuilder = forwardRef(
(
{ sortOrder, sortBy, limit, isSaving, query, onSave, onSaveAs }: PlaylistQueryBuilderProps,
ref: Ref<PlaylistQueryBuilderRef>,
) => {
const [filters, setFilters] = useState<QueryBuilderGroup>(
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
operator: value,
};
}),
clone,
);
const extraFiltersForm = useForm({
initialValues: {
limit,
sortBy,
sortOrder,
},
});
setFilterHandler(updatedFilters);
};
useImperativeHandle(ref, () => ({
getFilters: () => ({
extraFilters: extraFiltersForm.values,
filters,
}),
}));
const handleChangeValue = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const handleResetFilters = () => {
if (query) {
setFilters(convertNDQueryToQueryGroup(query));
} else {
setFilters(DEFAULT_QUERY);
}
};
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
value,
};
}),
clone,
);
const handleClearFilters = () => {
setFilters(DEFAULT_QUERY);
};
setFilterHandler(updatedFilters);
};
const setFilterHandler = (newFilters: QueryBuilderGroup) => {
setFilters(newFilters);
};
const sortOptions = [
{ label: 'Random', type: 'string', value: 'random' },
...NDSongQueryFields,
];
const handleSave = () => {
onSave?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
};
return (
<MotionFlex
direction="column"
h="calc(100% - 2.5rem)"
justify="space-between"
>
<ScrollArea
h="100%"
p="1rem"
>
<QueryBuilder
data={filters}
filters={NDSongQueryFields}
groupIndex={[]}
level={0}
operators={{
boolean: NDSongQueryBooleanOperators,
date: NDSongQueryDateOperators,
number: NDSongQueryNumberOperators,
string: NDSongQueryStringOperators,
}}
uniqueId={filters.uniqueId}
onAddRule={handleAddRule}
onAddRuleGroup={handleAddRuleGroup}
onChangeField={handleChangeField}
onChangeOperator={handleChangeOperator}
onChangeType={handleChangeType}
onChangeValue={handleChangeValue}
onClearFilters={handleClearFilters}
onDeleteRule={handleDeleteRule}
onDeleteRuleGroup={handleDeleteRuleGroup}
onResetFilters={handleResetFilters}
/>
</ScrollArea>
<Group
noWrap
align="flex-end"
m="1rem"
position="apart"
>
<Group
noWrap
spacing="sm"
w="100%"
>
<Select
searchable
data={sortOptions}
label="Sort"
maxWidth="20%"
width={150}
{...extraFiltersForm.getInputProps('sortBy')}
/>
<Select
data={[
const handleSaveAs = () => {
onSaveAs?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
};
const handleAddRuleGroup = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = clone(filters);
const getPath = (level: number) => {
if (level === 0) return 'group';
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.group`;
};
const path = getPath(level);
const updatedFilters = setWith(
filtersCopy,
path,
[
...get(filtersCopy, path),
{
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: '',
},
],
type: 'any',
uniqueId: nanoid(),
},
],
clone,
);
setFilterHandler(updatedFilters);
};
const handleDeleteRuleGroup = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = clone(filters);
const getPath = (level: number) => {
if (level === 0) return 'group';
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
if (i !== groupIndex.length - 1) {
str.push(`group[${groupIndex[i]}]`);
} else {
str.push(`group`);
}
}
return `${str.join('.')}`;
};
const path = getPath(level);
const updatedFilters = setWith(
filtersCopy,
path,
[
...get(filtersCopy, path).filter(
(group: QueryBuilderGroup) => group.uniqueId !== uniqueId,
),
],
clone,
);
setFilterHandler(updatedFilters);
};
const getRulePath = (level: number, groupIndex: number[]) => {
if (level === 0) return 'rules';
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.rules`;
};
const handleAddRule = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
[
...get(filtersCopy, path),
{
field: '',
operator: '',
uniqueId: nanoid(),
value: null,
},
],
clone,
);
setFilterHandler(updatedFilters);
};
const handleDeleteRule = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).filter(
(rule: QueryBuilderRule) => rule.uniqueId !== uniqueId,
),
clone,
);
setFilterHandler(updatedFilters);
};
const handleChangeField = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).map((rule: QueryBuilderGroup) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
field: value,
operator: '',
value: '',
};
}),
clone,
);
setFilterHandler(updatedFilters);
};
const handleChangeType = (args: any) => {
const { level, groupIndex, value } = args;
const filtersCopy = clone(filters);
if (level === 0) {
return setFilterHandler({ ...filtersCopy, type: value });
}
const getTypePath = () => {
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
str.push(`group[${groupIndex[i]}]`);
}
return `${str.join('.')}`;
};
const path = getTypePath();
const updatedFilters = setWith(
filtersCopy,
path,
{
label: 'Ascending',
value: 'asc',
...get(filtersCopy, path),
type: value,
},
{
label: 'Descending',
value: 'desc',
},
]}
label="Order"
maxWidth="20%"
width={125}
{...extraFiltersForm.getInputProps('sortOrder')}
/>
<NumberInput
label="Limit"
maxWidth="20%"
width={75}
{...extraFiltersForm.getInputProps('limit')}
/>
</Group>
{onSave && onSaveAs && (
<Group
noWrap
spacing="sm"
clone,
);
return setFilterHandler(updatedFilters);
};
const handleChangeOperator = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
operator: value,
};
}),
clone,
);
setFilterHandler(updatedFilters);
};
const handleChangeValue = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = clone(filters);
const path = getRulePath(level, groupIndex);
const updatedFilters = setWith(
filtersCopy,
path,
get(filtersCopy, path).map((rule: QueryBuilderRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
value,
};
}),
clone,
);
setFilterHandler(updatedFilters);
};
const sortOptions = [
{ label: 'Random', type: 'string', value: 'random' },
...NDSongQueryFields,
];
return (
<MotionFlex
direction="column"
h="calc(100% - 2.5rem)"
justify="space-between"
>
<Button
loading={isSaving}
variant="filled"
onClick={handleSaveAs}
>
Save as
</Button>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
disabled={isSaving}
p="0.5em"
variant="default"
>
<RiMore2Fill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
$danger
icon={<RiSaveLine color="var(--danger-color)" />}
onClick={handleSave}
>
Save and replace
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
)}
</Group>
</MotionFlex>
);
},
<ScrollArea
h="100%"
p="1rem"
>
<QueryBuilder
data={filters}
filters={NDSongQueryFields}
groupIndex={[]}
level={0}
operators={{
boolean: NDSongQueryBooleanOperators,
date: NDSongQueryDateOperators,
number: NDSongQueryNumberOperators,
string: NDSongQueryStringOperators,
}}
uniqueId={filters.uniqueId}
onAddRule={handleAddRule}
onAddRuleGroup={handleAddRuleGroup}
onChangeField={handleChangeField}
onChangeOperator={handleChangeOperator}
onChangeType={handleChangeType}
onChangeValue={handleChangeValue}
onClearFilters={handleClearFilters}
onDeleteRule={handleDeleteRule}
onDeleteRuleGroup={handleDeleteRuleGroup}
onResetFilters={handleResetFilters}
/>
</ScrollArea>
<Group
noWrap
align="flex-end"
m="1rem"
position="apart"
>
<Group
noWrap
spacing="sm"
w="100%"
>
<Select
searchable
data={sortOptions}
label="Sort"
maxWidth="20%"
width={150}
{...extraFiltersForm.getInputProps('sortBy')}
/>
<Select
data={[
{
label: 'Ascending',
value: 'asc',
},
{
label: 'Descending',
value: 'desc',
},
]}
label="Order"
maxWidth="20%"
width={125}
{...extraFiltersForm.getInputProps('sortOrder')}
/>
<NumberInput
label="Limit"
maxWidth="20%"
width={75}
{...extraFiltersForm.getInputProps('limit')}
/>
</Group>
{onSave && onSaveAs && (
<Group
noWrap
spacing="sm"
>
<Button
loading={isSaving}
variant="filled"
onClick={handleSaveAs}
>
Save as
</Button>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
disabled={isSaving}
p="0.5em"
variant="default"
>
<RiMore2Fill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
$danger
icon={<RiSaveLine color="var(--danger-color)" />}
onClick={handleSave}
>
Save and replace
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
)}
</Group>
</MotionFlex>
);
},
);

View file

@ -6,90 +6,90 @@ import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/crea
import { useCurrentServer } from '/@/renderer/store';
interface SaveAsPlaylistFormProps {
body: Partial<CreatePlaylistBody>;
onCancel: () => void;
onSuccess: (data: CreatePlaylistResponse) => void;
serverId: string | undefined;
body: Partial<CreatePlaylistBody>;
onCancel: () => void;
onSuccess: (data: CreatePlaylistResponse) => void;
serverId: string | undefined;
}
export const SaveAsPlaylistForm = ({
body,
serverId,
onSuccess,
onCancel,
body,
serverId,
onSuccess,
onCancel,
}: SaveAsPlaylistFormProps) => {
const mutation = useCreatePlaylist({});
const server = useCurrentServer();
const mutation = useCreatePlaylist({});
const server = useCurrentServer();
const form = useForm<CreatePlaylistBody>({
initialValues: {
_custom: {
navidrome: {
public: false,
rules: undefined,
...body?._custom?.navidrome,
const form = useForm<CreatePlaylistBody>({
initialValues: {
_custom: {
navidrome: {
public: false,
rules: undefined,
...body?._custom?.navidrome,
},
},
comment: body.comment || '',
name: body.name || '',
},
},
comment: body.comment || '',
name: body.name || '',
},
});
});
const handleSubmit = form.onSubmit((values) => {
mutation.mutate(
{ body: values, serverId },
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error creating playlist' });
},
onSuccess: (data) => {
toast.success({ message: `Playlist has been created` });
onSuccess(data);
onCancel();
},
},
const handleSubmit = form.onSubmit((values) => {
mutation.mutate(
{ body: values, serverId },
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error creating playlist' });
},
onSuccess: (data) => {
toast.success({ message: `Playlist has been created` });
onSuccess(data);
onCancel();
},
},
);
});
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
const isSubmitDisabled = !form.values.name || mutation.isLoading;
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
data-autofocus
required
label="Name"
{...form.getInputProps('name')}
/>
<TextInput
label="Description"
{...form.getInputProps('comment')}
/>
{isPublicDisplayed && (
<Switch
label="Is Public?"
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
/>
)}
<Group position="right">
<Button
variant="subtle"
onClick={onCancel}
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
loading={mutation.isLoading}
type="submit"
variant="filled"
>
Save
</Button>
</Group>
</Stack>
</form>
);
});
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
const isSubmitDisabled = !form.values.name || mutation.isLoading;
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
data-autofocus
required
label="Name"
{...form.getInputProps('name')}
/>
<TextInput
label="Description"
{...form.getInputProps('comment')}
/>
{isPublicDisplayed && (
<Switch
label="Is Public?"
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
/>
)}
<Group position="right">
<Button
variant="subtle"
onClick={onCancel}
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
loading={mutation.isLoading}
type="submit"
variant="filled"
>
Save
</Button>
</Group>
</Stack>
</form>
);
};

View file

@ -4,15 +4,15 @@ import { openModal, closeAllModals } from '@mantine/modals';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
PlaylistDetailResponse,
ServerListItem,
ServerType,
SortOrder,
UpdatePlaylistBody,
UpdatePlaylistQuery,
User,
UserListQuery,
UserListSort,
PlaylistDetailResponse,
ServerListItem,
ServerType,
SortOrder,
UpdatePlaylistBody,
UpdatePlaylistQuery,
User,
UserListQuery,
UserListSort,
} from '/@/renderer/api/types';
import { Button, Select, Switch, TextInput, toast } from '/@/renderer/components';
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
@ -20,146 +20,146 @@ import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
interface UpdatePlaylistFormProps {
body: Partial<UpdatePlaylistBody>;
onCancel: () => void;
query: UpdatePlaylistQuery;
users?: User[];
body: Partial<UpdatePlaylistBody>;
onCancel: () => void;
query: UpdatePlaylistQuery;
users?: User[];
}
export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlaylistFormProps) => {
const mutation = useUpdatePlaylist({});
const server = useCurrentServer();
const mutation = useUpdatePlaylist({});
const server = useCurrentServer();
const userList = users?.map((user) => ({
label: user.name,
value: user.id,
}));
const userList = users?.map((user) => ({
label: user.name,
value: user.id,
}));
const form = useForm<UpdatePlaylistBody>({
initialValues: {
_custom: {
navidrome: {
owner: body?._custom?.navidrome?.owner || '',
ownerId: body?._custom?.navidrome?.ownerId || '',
public: body?._custom?.navidrome?.public || false,
rules: undefined,
sync: body?._custom?.navidrome?.sync || false,
const form = useForm<UpdatePlaylistBody>({
initialValues: {
_custom: {
navidrome: {
owner: body?._custom?.navidrome?.owner || '',
ownerId: body?._custom?.navidrome?.ownerId || '',
public: body?._custom?.navidrome?.public || false,
rules: undefined,
sync: body?._custom?.navidrome?.sync || false,
},
},
comment: body?.comment || '',
name: body?.name || '',
},
},
comment: body?.comment || '',
name: body?.name || '',
},
});
});
const handleSubmit = form.onSubmit((values) => {
mutation.mutate(
{
body: values,
query,
serverId: server?.id,
},
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error updating playlist' });
},
onSuccess: () => {
toast.success({ message: `Playlist has been saved` });
onCancel();
},
},
const handleSubmit = form.onSubmit((values) => {
mutation.mutate(
{
body: values,
query,
serverId: server?.id,
},
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error updating playlist' });
},
onSuccess: () => {
toast.success({ message: `Playlist has been saved` });
onCancel();
},
},
);
});
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
const isSubmitDisabled = !form.values.name || mutation.isLoading;
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
data-autofocus
required
label="Name"
{...form.getInputProps('name')}
/>
<TextInput
label="Description"
{...form.getInputProps('comment')}
/>
<Select
data={userList || []}
{...form.getInputProps('_custom.navidrome.ownerId')}
label="Owner"
/>
{isPublicDisplayed && (
<Switch
label="Is Public?"
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
/>
)}
<Group position="right">
<Button
variant="subtle"
onClick={onCancel}
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
loading={mutation.isLoading}
type="submit"
variant="filled"
>
Save
</Button>
</Group>
</Stack>
</form>
);
});
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
const isSubmitDisabled = !form.values.name || mutation.isLoading;
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
data-autofocus
required
label="Name"
{...form.getInputProps('name')}
/>
<TextInput
label="Description"
{...form.getInputProps('comment')}
/>
<Select
data={userList || []}
{...form.getInputProps('_custom.navidrome.ownerId')}
label="Owner"
/>
{isPublicDisplayed && (
<Switch
label="Is Public?"
{...form.getInputProps('_custom.navidrome.public', { type: 'checkbox' })}
/>
)}
<Group position="right">
<Button
variant="subtle"
onClick={onCancel}
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
loading={mutation.isLoading}
type="submit"
variant="filled"
>
Save
</Button>
</Group>
</Stack>
</form>
);
};
export const openUpdatePlaylistModal = async (args: {
playlist: PlaylistDetailResponse;
server: ServerListItem;
playlist: PlaylistDetailResponse;
server: ServerListItem;
}) => {
const { playlist, server } = args;
const { playlist, server } = args;
const query: UserListQuery = {
sortBy: UserListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const query: UserListQuery = {
sortBy: UserListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
if (!server) return;
if (!server) return;
const users = await queryClient.fetchQuery({
queryFn: ({ signal }) =>
api.controller.getUserList({ apiClientProps: { server, signal }, query }),
queryKey: queryKeys.users.list(server?.id || '', query),
});
const users = await queryClient.fetchQuery({
queryFn: ({ signal }) =>
api.controller.getUserList({ apiClientProps: { server, signal }, query }),
queryKey: queryKeys.users.list(server?.id || '', query),
});
openModal({
children: (
<UpdatePlaylistForm
body={{
_custom: {
navidrome: {
owner: playlist?.owner || undefined,
ownerId: playlist?.ownerId || undefined,
public: playlist?.public || false,
rules: playlist?.rules || undefined,
sync: playlist?.sync || undefined,
},
},
comment: playlist?.description || undefined,
genres: playlist?.genres,
name: playlist?.name,
}}
query={{ id: playlist?.id }}
users={users?.items}
onCancel={closeAllModals}
/>
),
title: 'Edit playlist',
});
openModal({
children: (
<UpdatePlaylistForm
body={{
_custom: {
navidrome: {
owner: playlist?.owner || undefined,
ownerId: playlist?.ownerId || undefined,
public: playlist?.public || false,
rules: playlist?.rules || undefined,
sync: playlist?.sync || undefined,
},
},
comment: playlist?.description || undefined,
genres: playlist?.genres,
name: playlist?.name,
}}
query={{ id: playlist?.id }}
users={users?.items}
onCancel={closeAllModals}
/>
),
title: 'Edit playlist',
});
};

View file

@ -7,31 +7,31 @@ import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useAddToPlaylist = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<
AddToPlaylistResponse,
AxiosError,
Omit<AddToPlaylistArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.addToPlaylist({ ...args, apiClientProps: { server } });
},
onSuccess: (_data, variables) => {
const { serverId } = variables;
return useMutation<
AddToPlaylistResponse,
AxiosError,
Omit<AddToPlaylistArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.addToPlaylist({ ...args, apiClientProps: { server } });
},
onSuccess: (_data, variables) => {
const { serverId } = variables;
if (!serverId) return;
if (!serverId) return;
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
queryClient.invalidateQueries(
queryKeys.playlists.detailSongList(serverId, variables.query.id),
);
},
...options,
});
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
queryClient.invalidateQueries(
queryKeys.playlists.detailSongList(serverId, variables.query.id),
);
},
...options,
});
};

View file

@ -7,26 +7,26 @@ import { AxiosError } from 'axios';
import { queryKeys } from '../../../api/query-keys';
export const useCreatePlaylist = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<
CreatePlaylistResponse,
AxiosError,
Omit<CreatePlaylistArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.createPlaylist({ ...args, apiClientProps: { server } });
},
onSuccess: (_args, variables) => {
const server = getServerById(variables.serverId);
if (server) {
queryClient.invalidateQueries(queryKeys.playlists.list(server.id));
}
},
...options,
});
return useMutation<
CreatePlaylistResponse,
AxiosError,
Omit<CreatePlaylistArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.createPlaylist({ ...args, apiClientProps: { server } });
},
onSuccess: (_args, variables) => {
const server = getServerById(variables.serverId);
if (server) {
queryClient.invalidateQueries(queryKeys.playlists.list(server.id));
}
},
...options,
});
};

View file

@ -7,28 +7,28 @@ import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { getServerById, useCurrentServer } from '/@/renderer/store';
export const useDeletePlaylist = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
const server = useCurrentServer();
const { options } = args || {};
const queryClient = useQueryClient();
const server = useCurrentServer();
return useMutation<
DeletePlaylistResponse,
AxiosError,
Omit<DeletePlaylistArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.deletePlaylist({ ...args, apiClientProps: { server } });
},
onMutate: () => {
queryClient.cancelQueries(queryKeys.playlists.list(server?.id || ''));
return null;
},
onSuccess: () => {
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''));
},
...options,
});
return useMutation<
DeletePlaylistResponse,
AxiosError,
Omit<DeletePlaylistArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.deletePlaylist({ ...args, apiClientProps: { server } });
},
onMutate: () => {
queryClient.cancelQueries(queryKeys.playlists.list(server?.id || ''));
return null;
},
onSuccess: () => {
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''));
},
...options,
});
};

View file

@ -7,30 +7,30 @@ import { MutationOptions } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useRemoveFromPlaylist = (options?: MutationOptions) => {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation<
RemoveFromPlaylistResponse,
AxiosError,
Omit<RemoveFromPlaylistArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.removeFromPlaylist({ ...args, apiClientProps: { server } });
},
onSuccess: (_data, variables) => {
const { serverId } = variables;
return useMutation<
RemoveFromPlaylistResponse,
AxiosError,
Omit<RemoveFromPlaylistArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.removeFromPlaylist({ ...args, apiClientProps: { server } });
},
onSuccess: (_data, variables) => {
const { serverId } = variables;
if (!serverId) return;
if (!serverId) return;
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
queryClient.invalidateQueries(
queryKeys.playlists.detailSongList(serverId, variables.query.id),
);
},
...options,
});
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
queryClient.invalidateQueries(
queryKeys.playlists.detailSongList(serverId, variables.query.id),
);
},
...options,
});
};

View file

@ -7,31 +7,31 @@ import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useUpdatePlaylist = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<
UpdatePlaylistResponse,
AxiosError,
Omit<UpdatePlaylistArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.updatePlaylist({ ...args, apiClientProps: { server } });
},
onSuccess: (_data, variables) => {
const { query, serverId } = variables;
return useMutation<
UpdatePlaylistResponse,
AxiosError,
Omit<UpdatePlaylistArgs, 'server' | 'apiClientProps'>,
null
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.updatePlaylist({ ...args, apiClientProps: { server } });
},
onSuccess: (_data, variables) => {
const { query, serverId } = variables;
if (!serverId) return;
if (!serverId) return;
queryClient.invalidateQueries(queryKeys.playlists.list(serverId));
queryClient.invalidateQueries(queryKeys.playlists.list(serverId));
if (query?.id) {
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, query.id));
}
},
...options,
});
if (query?.id) {
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, query.id));
}
},
...options,
});
};

View file

@ -6,16 +6,16 @@ import { getServerById } from '/@/renderer/store';
import { api } from '/@/renderer/api';
export const usePlaylistDetail = (args: QueryHookArgs<PlaylistDetailQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getPlaylistDetail({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.playlists.detail(server?.id || '', query.id, query),
...options,
});
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getPlaylistDetail({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.playlists.detail(server?.id || '', query.id, query),
...options,
});
};

View file

@ -6,21 +6,21 @@ import { getServerById } from '/@/renderer/store';
import { api } from '/@/renderer/api';
export const usePlaylistList = (args: {
options?: QueryOptions;
query: PlaylistListQuery;
serverId?: string;
options?: QueryOptions;
query: PlaylistListQuery;
serverId?: string;
}) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
cacheTime: 1000 * 60 * 60,
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getPlaylistList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.playlists.list(server?.id || '', query),
...options,
});
return useQuery({
cacheTime: 1000 * 60 * 60,
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getPlaylistList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.playlists.list(server?.id || '', query),
...options,
});
};

View file

@ -6,41 +6,48 @@ import { getServerById } from '/@/renderer/store';
import { api } from '/@/renderer/api';
export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
enabled: !!server,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getPlaylistSongList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.playlists.songList(server?.id || '', query.id, query),
...options,
});
return useQuery({
enabled: !!server,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getPlaylistSongList({
apiClientProps: { server, signal },
query,
});
},
queryKey: queryKeys.playlists.songList(server?.id || '', query.id, query),
...options,
});
};
export const usePlaylistSongListInfinite = (args: QueryHookArgs<PlaylistSongListQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useInfiniteQuery({
enabled: !!server,
getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => {
if (!lastPage?.items) return undefined;
if (lastPage?.items?.length >= (query?.limit || 50)) {
return pages?.length;
}
return useInfiniteQuery({
enabled: !!server,
getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => {
if (!lastPage?.items) return undefined;
if (lastPage?.items?.length >= (query?.limit || 50)) {
return pages?.length;
}
return undefined;
},
queryFn: ({ pageParam = 0, signal }) => {
return api.controller.getPlaylistSongList({
apiClientProps: { server, signal },
query: { ...query, limit: query.limit || 50, startIndex: pageParam * (query.limit || 50) },
});
},
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
...options,
});
return undefined;
},
queryFn: ({ pageParam = 0, signal }) => {
return api.controller.getPlaylistSongList({
apiClientProps: { server, signal },
query: {
...query,
limit: query.limit || 50,
startIndex: pageParam * (query.limit || 50),
},
});
},
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
...options,
});
};

View file

@ -13,59 +13,61 @@ import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useCurrentServer } from '../../../store/auth.store';
const PlaylistDetailRoute = () => {
const tableRef = useRef<AgGridReactType | null>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const tableRef = useRef<AgGridReactType | null>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const background = useFastAverageColor(
detailQuery?.data?.imageUrl,
!detailQuery?.isLoading,
'sqrt',
);
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const background = useFastAverageColor(
detailQuery?.data?.imageUrl,
!detailQuery?.isLoading,
'sqrt',
);
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
playType: playButtonBehavior,
});
};
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
playType: playButtonBehavior,
});
};
if (!background) return null;
if (!background) return null;
return (
<AnimatedPage key={`playlist-detail-${playlistId}`}>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: background,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
target: headerRef,
}}
>
<PlaylistDetailHeader
ref={headerRef}
background={background}
imagePlaceholderUrl={detailQuery?.data?.imageUrl}
imageUrl={detailQuery?.data?.imageUrl}
/>
<PlaylistDetailContent tableRef={tableRef} />
</NativeScrollArea>
</AnimatedPage>
);
return (
<AnimatedPage key={`playlist-detail-${playlistId}`}>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: background,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
<LibraryHeaderBar.Title>
{detailQuery?.data?.name}
</LibraryHeaderBar.Title>
</LibraryHeaderBar>
),
target: headerRef,
}}
>
<PlaylistDetailHeader
ref={headerRef}
background={background}
imagePlaceholderUrl={detailQuery?.data?.imageUrl}
imageUrl={detailQuery?.data?.imageUrl}
/>
<PlaylistDetailContent tableRef={tableRef} />
</NativeScrollArea>
</AnimatedPage>
);
};
export default PlaylistDetailRoute;

View file

@ -20,217 +20,228 @@ import { PlaylistSongListQuery, ServerType, SongListSort, SortOrder } from '/@/r
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
const PlaylistDetailSongListRoute = () => {
const navigate = useNavigate();
const tableRef = useRef<AgGridReactType | null>(null);
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const navigate = useNavigate();
const tableRef = useRef<AgGridReactType | null>(null);
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const createPlaylistMutation = useCreatePlaylist({});
const deletePlaylistMutation = useDeletePlaylist({});
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const createPlaylistMutation = useCreatePlaylist({});
const deletePlaylistMutation = useDeletePlaylist({});
const handleSave = (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => {
const rules = {
...filter,
limit: extraFilters.limit || undefined,
order: extraFilters.sortOrder || 'desc',
sort: extraFilters.sortBy || 'dateAdded',
const handleSave = (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => {
const rules = {
...filter,
limit: extraFilters.limit || undefined,
order: extraFilters.sortOrder || 'desc',
sort: extraFilters.sortBy || 'dateAdded',
};
if (!detailQuery?.data) return;
createPlaylistMutation.mutate(
{
body: {
_custom: {
navidrome: {
owner: detailQuery?.data?.owner || '',
ownerId: detailQuery?.data?.ownerId || '',
public: detailQuery?.data?.public || false,
rules,
sync: detailQuery?.data?.sync || false,
},
},
comment: detailQuery?.data?.description || '',
name: detailQuery?.data?.name,
},
serverId: detailQuery?.data?.serverId,
},
{
onSuccess: (data) => {
toast.success({ message: 'Playlist has been saved' });
navigate(
generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, {
playlistId: data?.id || '',
}),
{
replace: true,
},
);
deletePlaylistMutation.mutate({
query: { id: playlistId },
serverId: detailQuery?.data?.serverId,
});
},
},
);
};
if (!detailQuery?.data) return;
const handleSaveAs = (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => {
openModal({
children: (
<SaveAsPlaylistForm
body={{
_custom: {
navidrome: {
owner: detailQuery?.data?.owner || '',
ownerId: detailQuery?.data?.ownerId || '',
public: detailQuery?.data?.public || false,
rules: {
...filter,
limit: extraFilters.limit || undefined,
order: extraFilters.sortOrder || 'desc',
sort: extraFilters.sortBy || 'dateAdded',
},
sync: detailQuery?.data?.sync || false,
},
},
comment: detailQuery?.data?.description || '',
name: detailQuery?.data?.name,
}}
serverId={detailQuery?.data?.serverId}
onCancel={closeAllModals}
onSuccess={(data) =>
navigate(
generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, {
playlistId: data?.id || '',
}),
)
}
/>
),
title: 'Save as',
});
};
createPlaylistMutation.mutate(
{
body: {
_custom: {
navidrome: {
owner: detailQuery?.data?.owner || '',
ownerId: detailQuery?.data?.ownerId || '',
public: detailQuery?.data?.public || false,
rules,
sync: detailQuery?.data?.sync || false,
},
},
comment: detailQuery?.data?.description || '',
name: detailQuery?.data?.name,
},
serverId: detailQuery?.data?.serverId,
},
{
onSuccess: (data) => {
toast.success({ message: 'Playlist has been saved' });
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data?.id || '' }), {
replace: true,
});
deletePlaylistMutation.mutate({
query: { id: playlistId },
serverId: detailQuery?.data?.serverId,
});
},
},
);
};
const handleSaveAs = (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => {
openModal({
children: (
<SaveAsPlaylistForm
body={{
_custom: {
navidrome: {
owner: detailQuery?.data?.owner || '',
ownerId: detailQuery?.data?.ownerId || '',
public: detailQuery?.data?.public || false,
rules: {
...filter,
limit: extraFilters.limit || undefined,
order: extraFilters.sortOrder || 'desc',
sort: extraFilters.sortBy || 'dateAdded',
const smartPlaylistVariants: Variants = {
animate: (custom: { isQueryBuilderExpanded: boolean }) => {
return {
maxHeight: custom.isQueryBuilderExpanded ? '35vh' : '3.5em',
opacity: 1,
transition: {
duration: 0.3,
ease: 'easeInOut',
},
sync: detailQuery?.data?.sync || false,
},
},
comment: detailQuery?.data?.description || '',
name: detailQuery?.data?.name,
}}
serverId={detailQuery?.data?.serverId}
onCancel={closeAllModals}
onSuccess={(data) =>
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data?.id || '' }))
}
/>
),
title: 'Save as',
});
};
const smartPlaylistVariants: Variants = {
animate: (custom: { isQueryBuilderExpanded: boolean }) => {
return {
maxHeight: custom.isQueryBuilderExpanded ? '35vh' : '3.5em',
opacity: 1,
transition: {
duration: 0.3,
ease: 'easeInOut',
y: 0,
};
},
y: 0,
};
},
exit: {
opacity: 0,
y: -25,
},
initial: {
opacity: 0,
y: -25,
},
};
exit: {
opacity: 0,
y: -25,
},
initial: {
opacity: 0,
y: -25,
},
};
const isSmartPlaylist =
!detailQuery?.isLoading && detailQuery?.data?.rules && server?.type === ServerType.NAVIDROME;
const isSmartPlaylist =
!detailQuery?.isLoading &&
detailQuery?.data?.rules &&
server?.type === ServerType.NAVIDROME;
const [showQueryBuilder, setShowQueryBuilder] = useState(false);
const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false);
const [showQueryBuilder, setShowQueryBuilder] = useState(false);
const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false);
const handleToggleExpand = () => {
setIsQueryBuilderExpanded((prev) => !prev);
};
const handleToggleExpand = () => {
setIsQueryBuilderExpanded((prev) => !prev);
};
const handleToggleShowQueryBuilder = () => {
setShowQueryBuilder((prev) => !prev);
setIsQueryBuilderExpanded(true);
};
const handleToggleShowQueryBuilder = () => {
setShowQueryBuilder((prev) => !prev);
setIsQueryBuilderExpanded(true);
};
const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
};
const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
};
const itemCountCheck = usePlaylistSongList({
options: {
cacheTime: 1000 * 60 * 60 * 2,
staleTime: 1000 * 60 * 60 * 2,
},
query: {
id: playlistId,
limit: 1,
startIndex: 0,
...filters,
},
serverId: server?.id,
});
const itemCountCheck = usePlaylistSongList({
options: {
cacheTime: 1000 * 60 * 60 * 2,
staleTime: 1000 * 60 * 60 * 2,
},
query: {
id: playlistId,
limit: 1,
startIndex: 0,
...filters,
},
serverId: server?.id,
});
const itemCount =
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const itemCount =
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
<PlaylistDetailSongListHeader
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
itemCount={itemCount}
tableRef={tableRef}
/>
<AnimatePresence
custom={{ isQueryBuilderExpanded }}
initial={false}
>
{(isSmartPlaylist || showQueryBuilder) && (
<motion.div
animate="animate"
custom={{ isQueryBuilderExpanded }}
exit="exit"
initial="initial"
transition={{ duration: 0.2, ease: 'easeInOut' }}
variants={smartPlaylistVariants}
>
<Paper
h="100%"
pos="relative"
w="100%"
return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
<PlaylistDetailSongListHeader
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
itemCount={itemCount}
tableRef={tableRef}
/>
<AnimatePresence
custom={{ isQueryBuilderExpanded }}
initial={false}
>
<Group
pt="1rem"
px="1rem"
>
<Button
compact
variant="default"
onClick={handleToggleExpand}
>
{isQueryBuilderExpanded ? (
<RiArrowUpSLine size={20} />
) : (
<RiArrowDownSLine size={20} />
)}
</Button>
<Text>Query Editor</Text>
</Group>
<PlaylistQueryBuilder
key={JSON.stringify(detailQuery?.data?.rules)}
isSaving={createPlaylistMutation?.isLoading}
limit={detailQuery?.data?.rules?.limit}
query={detailQuery?.data?.rules}
sortBy={detailQuery?.data?.rules?.sort || SongListSort.ALBUM}
sortOrder={detailQuery?.data?.rules?.order || 'asc'}
onSave={handleSave}
onSaveAs={handleSaveAs}
/>
</Paper>
</motion.div>
)}
</AnimatePresence>
<PlaylistDetailSongListContent tableRef={tableRef} />
</AnimatedPage>
);
{(isSmartPlaylist || showQueryBuilder) && (
<motion.div
animate="animate"
custom={{ isQueryBuilderExpanded }}
exit="exit"
initial="initial"
transition={{ duration: 0.2, ease: 'easeInOut' }}
variants={smartPlaylistVariants}
>
<Paper
h="100%"
pos="relative"
w="100%"
>
<Group
pt="1rem"
px="1rem"
>
<Button
compact
variant="default"
onClick={handleToggleExpand}
>
{isQueryBuilderExpanded ? (
<RiArrowUpSLine size={20} />
) : (
<RiArrowDownSLine size={20} />
)}
</Button>
<Text>Query Editor</Text>
</Group>
<PlaylistQueryBuilder
key={JSON.stringify(detailQuery?.data?.rules)}
isSaving={createPlaylistMutation?.isLoading}
limit={detailQuery?.data?.rules?.limit}
query={detailQuery?.data?.rules}
sortBy={detailQuery?.data?.rules?.sort || SongListSort.ALBUM}
sortOrder={detailQuery?.data?.rules?.order || 'asc'}
onSave={handleSave}
onSaveAs={handleSaveAs}
/>
</Paper>
</motion.div>
)}
</AnimatePresence>
<PlaylistDetailSongListContent tableRef={tableRef} />
</AnimatedPage>
);
};
export default PlaylistDetailSongListRoute;

Some files were not shown because too many files have changed in this diff Show more