mirror of
https://github.com/jeffvli/feishin.git
synced 2024-11-20 14:37:06 +01:00
Add playlist detail page
This commit is contained in:
parent
d6dc880ef4
commit
90dec929f4
@ -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;
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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}
|
||||||
|
@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user