Add playlist detail page

This commit is contained in:
jeffvli 2023-01-02 02:04:23 -08:00
parent d6dc880ef4
commit 90dec929f4
7 changed files with 353 additions and 24 deletions

View File

@ -53,6 +53,11 @@ export const queryKeys = {
if (id) return [serverId, 'playlists', id, 'detail'] as const; if (id) return [serverId, 'playlists', id, 'detail'] as const;
return [serverId, 'playlists', 'detail'] as const; return [serverId, 'playlists', 'detail'] as const;
}, },
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
if (query) return [serverId, 'playlists', id, 'detailSongList', query] as const;
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
return [serverId, 'playlists', 'detailSongList'] as const;
},
list: (serverId: string, query?: PlaylistListQuery) => { list: (serverId: string, query?: PlaylistListQuery) => {
if (query) return [serverId, 'playlists', 'list', query] as const; if (query) return [serverId, 'playlists', 'list', query] as const;
return [serverId, 'playlists', 'list'] as const; return [serverId, 'playlists', 'list'] as const;

View File

@ -0,0 +1,159 @@
import { CellContextMenuEvent, ColDef } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Group } from '@mantine/core';
import { sortBy } from 'lodash';
import { MutableRefObject, useMemo } from 'react';
import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { Button, getColumnDefs, Text, VirtualTable } from '/@/renderer/components';
import { openContextMenu } from '/@/renderer/features/context-menu';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { usePlaylistSongListInfinite } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
import { AppRoute } from '/@/renderer/router/routes';
import { useSongListStore } from '/@/renderer/store';
import { LibraryItem } from '/@/renderer/types';
const ContentContainer = styled.div`
display: flex;
flex-direction: column;
max-width: 1920px;
padding: 1rem 2rem 5rem;
overflow: hidden;
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%);
}
.ag-header-container {
z-index: 1000;
}
.ag-header-cell-resize {
top: 25%;
width: 7px;
height: 50%;
background-color: rgb(70, 70, 70, 20%);
}
`;
interface PlaylistDetailContentProps {
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
const { playlistId } = useParams() as { playlistId: string };
const page = useSongListStore();
const playlistSongsQueryInfinite = usePlaylistSongListInfinite(
{
id: playlistId,
limit: 50,
startIndex: 0,
},
{ keepPreviousData: false },
);
const handleLoadMore = () => {
playlistSongsQueryInfinite.fetchNextPage();
};
const columnDefs: ColDef[] = useMemo(
() =>
getColumnDefs(page.table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
[page.table.columns],
);
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
};
}, []);
const handleContextMenu = (e: CellContextMenuEvent) => {
if (!e.event) return;
const clickEvent = e.event as MouseEvent;
clickEvent.preventDefault();
const selectedNodes = e.api.getSelectedNodes();
const selectedIds = selectedNodes.map((node) => node.data.id);
let selectedRows = sortBy(selectedNodes, ['rowIndex']).map((node) => node.data);
if (!selectedIds.includes(e.data.id)) {
e.api.deselectAll();
e.node.setSelected(true);
selectedRows = [e.data];
}
openContextMenu({
data: selectedRows,
menuItems: SONG_CONTEXT_MENU_ITEMS,
type: LibraryItem.SONG,
xPos: clickEvent.clientX,
yPos: clickEvent.clientY,
});
};
const playlistSongData = useMemo(
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p.items),
[playlistSongsQueryInfinite.data?.pages],
);
return (
<ContentContainer>
<VirtualTable
ref={tableRef}
animateRows
detailRowAutoHeight
maintainColumnOrder
suppressCellFocus
suppressCopyRowsToClipboard
suppressLoadingOverlay
suppressMoveWhenRowDragging
suppressPaginationPanel
suppressRowDrag
suppressScrollOnNewData
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
rowData={playlistSongData}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onGridReady={(params) => {
params.api.setDomLayout('autoHeight');
params.api.sizeColumnsToFit();
}}
onGridSizeChanged={(params) => {
params.api.sizeColumnsToFit();
}}
/>
<Group
p="2rem"
position="center"
>
<Button
compact
disabled={!playlistSongsQueryInfinite.hasNextPage}
loading={playlistSongsQueryInfinite.isFetchingNextPage}
variant="subtle"
onClick={handleLoadMore}
>
Load more
</Button>
<Text>or</Text>
<Button
compact
component={Link}
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
variant="subtle"
>
View full playlist
</Button>
</Group>
</ContentContainer>
);
};

View File

@ -0,0 +1,92 @@
import { Group, Stack } from '@mantine/core';
import { RiMoreFill } from 'react-icons/ri';
import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { DropdownMenu, Button } from '/@/renderer/components';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { LibraryHeader, PlayButton, PLAY_TYPES } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem, Play } from '/@/renderer/types';
interface PlaylistDetailHeaderProps {
background: string;
imagePlaceholderUrl?: string | null;
imageUrl?: string | null;
}
export const PlaylistDetailHeader = ({
background,
imageUrl,
imagePlaceholderUrl,
}: PlaylistDetailHeaderProps) => {
const { playlistId } = useParams() as { playlistId: string };
const detailQuery = usePlaylistDetail({ id: playlistId });
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = (playType?: Play) => {
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
play: playType || playButtonBehavior,
});
};
return (
<Stack>
<LibraryHeader
background={background}
imagePlaceholderUrl={imagePlaceholderUrl}
imageUrl={imageUrl}
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.PLAYLIST }}
title={detailQuery?.data?.name || ''}
>
<Group>
<Button
compact
component={Link}
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
variant="subtle"
>
View full playlist
</Button>
</Group>
</LibraryHeader>
<Group
maw="1920px"
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 disabled>Edit playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Group>
</Stack>
);
};

View File

@ -234,7 +234,7 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
columnDefs={columnDefs} columnDefs={columnDefs}
defaultColDef={defaultColumnDefs} defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false} enableCellChangeFlash={false}
getRowId={(data) => data.data.id} getRowId={(data) => data.data.uniqueId}
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100} infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
pagination={isPaginationEnabled} pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled} paginationAutoPageSize={isPaginationEnabled}

View File

@ -1,8 +1,8 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useInfiniteQuery, InfiniteData } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import type { PlaylistSongListQuery, RawSongListResponse } from '/@/renderer/api/types'; import type { PlaylistSongListQuery, RawSongListResponse } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query'; import type { InfiniteQueryOptions, QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
@ -20,3 +20,42 @@ export const usePlaylistSongList = (query: PlaylistSongListQuery, options?: Quer
...options, ...options,
}); });
}; };
export const usePlaylistSongListInfinite = (
query: PlaylistSongListQuery,
options?: InfiniteQueryOptions,
) => {
const server = useCurrentServer();
return useInfiniteQuery({
enabled: !!server?.id,
getNextPageParam: (lastPage: RawSongListResponse, allPages) => {
if (!lastPage?.items) return undefined;
if (lastPage?.items?.length >= (query?.limit || 50)) {
return allPages?.length;
}
return undefined;
},
queryFn: ({ pageParam = 0, signal }) => {
return api.controller.getPlaylistSongList({
query: { ...query, limit: query.limit || 50, startIndex: pageParam * (query.limit || 50) },
server,
signal,
});
},
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
select: useCallback(
(data: InfiniteData<RawSongListResponse | undefined>) => {
return {
...data,
pages: data.pages.map((page) => {
return api.normalize.songList(page, server);
}),
};
},
[server],
),
...options,
});
};

View File

@ -1,32 +1,42 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useRef } from 'react'; import { useRef } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { PageHeader, ScrollArea } from '/@/renderer/components';
import { PlaylistDetailContent } from '/@/renderer/features/playlists/components/playlist-detail-content';
import { PlaylistDetailHeader } from '/@/renderer/features/playlists/components/playlist-detail-header';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { AnimatedPage } from '/@/renderer/features/shared'; import { AnimatedPage } from '/@/renderer/features/shared';
import { useFastAverageColor, useShouldPadTitlebar } from '/@/renderer/hooks';
const PlaylistDetailRoute = () => { const PlaylistDetailRoute = () => {
// const tableRef = useRef<AgGridReactType | null>(null); const tableRef = useRef<AgGridReactType | null>(null);
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const padTitlebar = useShouldPadTitlebar();
// const detailsQuery = usePlaylistDetail({ const detailQuery = usePlaylistDetail({ id: playlistId });
// id: playlistId, const background = useFastAverageColor(detailQuery?.data?.imageUrl, 'dominant');
// });
// const playlistSongsQuery = usePlaylistSongList({ return (
// id: playlistId, <>
// limit: 50, <PageHeader position="absolute" />
// startIndex: 0, {background && (
// }); <AnimatedPage key={`playlist-detail-${playlistId}`}>
<ScrollArea
// const imageUrl = playlistSongsQuery.data?.items?.[0]?.imageUrl; h="100%"
// const background = useFastAverageColor(imageUrl); offsetScrollbars={false}
// const containerRef = useRef(); styles={{ scrollbar: { marginTop: padTitlebar ? '35px' : 0 } }}
>
// const { ref, entry } = useIntersection({ <PlaylistDetailHeader
// root: containerRef.current, background={background}
// threshold: 0.3, imagePlaceholderUrl={detailQuery?.data?.imageUrl}
// }); imageUrl={detailQuery?.data?.imageUrl}
/>
return <AnimatedPage key={`playlist-detail-${playlistId}`}>Placeholder</AnimatedPage>; <PlaylistDetailContent tableRef={tableRef} />
</ScrollArea>
</AnimatedPage>
)}
</>
);
}; };
export default PlaylistDetailRoute; export default PlaylistDetailRoute;

View File

@ -1,4 +1,9 @@
import type { UseQueryOptions, DefaultOptions, UseMutationOptions } from '@tanstack/react-query'; import type {
UseQueryOptions,
DefaultOptions,
UseMutationOptions,
UseInfiniteQueryOptions,
} from '@tanstack/react-query';
import { QueryClient, QueryCache } from '@tanstack/react-query'; import { QueryClient, QueryCache } from '@tanstack/react-query';
import { toast } from '/@/renderer/components'; import { toast } from '/@/renderer/components';
@ -59,3 +64,22 @@ export type MutationOptions = {
retryDelay?: UseQueryOptions['retryDelay']; retryDelay?: UseQueryOptions['retryDelay'];
useErrorBoundary?: boolean; useErrorBoundary?: boolean;
}; };
export type InfiniteQueryOptions = {
cacheTime?: UseInfiniteQueryOptions['cacheTime'];
enabled?: UseInfiniteQueryOptions['enabled'];
keepPreviousData?: UseInfiniteQueryOptions['keepPreviousData'];
meta?: UseInfiniteQueryOptions['meta'];
onError?: (err: any) => void;
onSettled?: any;
onSuccess?: any;
queryKey?: UseInfiniteQueryOptions['queryKey'];
refetchInterval?: number;
refetchIntervalInBackground?: UseInfiniteQueryOptions['refetchIntervalInBackground'];
refetchOnWindowFocus?: boolean;
retry?: UseInfiniteQueryOptions['retry'];
retryDelay?: UseInfiniteQueryOptions['retryDelay'];
staleTime?: UseInfiniteQueryOptions['staleTime'];
suspense?: UseInfiniteQueryOptions['suspense'];
useErrorBoundary?: boolean;
};