From 90dec929f4a2de50e21041d8cf9ca705a3c35f80 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 2 Jan 2023 02:04:23 -0800 Subject: [PATCH] Add playlist detail page --- src/renderer/api/query-keys.ts | 5 + .../components/playlist-detail-content.tsx | 159 ++++++++++++++++++ .../components/playlist-detail-header.tsx | 92 ++++++++++ .../playlist-detail-song-list-content.tsx | 2 +- .../queries/playlist-song-list-query.ts | 43 ++++- .../routes/playlist-detail-route.tsx | 50 +++--- src/renderer/lib/react-query.ts | 26 ++- 7 files changed, 353 insertions(+), 24 deletions(-) create mode 100644 src/renderer/features/playlists/components/playlist-detail-content.tsx create mode 100644 src/renderer/features/playlists/components/playlist-detail-header.tsx diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 2e9dea15..a59e9135 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -53,6 +53,11 @@ export const queryKeys = { if (id) return [serverId, 'playlists', id, '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) => { if (query) return [serverId, 'playlists', 'list', query] as const; return [serverId, 'playlists', 'list'] as const; diff --git a/src/renderer/features/playlists/components/playlist-detail-content.tsx b/src/renderer/features/playlists/components/playlist-detail-content.tsx new file mode 100644 index 00000000..e72983a3 --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-detail-content.tsx @@ -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; +} + +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 ( + + 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(); + }} + /> + + + or + + + + ); +}; diff --git a/src/renderer/features/playlists/components/playlist-detail-header.tsx b/src/renderer/features/playlists/components/playlist-detail-header.tsx new file mode 100644 index 00000000..845c1b62 --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-detail-header.tsx @@ -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 ( + + + + + + + + + handlePlay()} /> + + + + + + {PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => ( + handlePlay(type.play)} + > + {type.label} + + ))} + + Edit playlist + + + + + + ); +}; diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx index 7b1162eb..4b61318d 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx @@ -234,7 +234,7 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten columnDefs={columnDefs} defaultColDef={defaultColumnDefs} enableCellChangeFlash={false} - getRowId={(data) => data.data.id} + getRowId={(data) => data.data.uniqueId} infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100} pagination={isPaginationEnabled} paginationAutoPageSize={isPaginationEnabled} diff --git a/src/renderer/features/playlists/queries/playlist-song-list-query.ts b/src/renderer/features/playlists/queries/playlist-song-list-query.ts index 19c96c18..d1156dc2 100644 --- a/src/renderer/features/playlists/queries/playlist-song-list-query.ts +++ b/src/renderer/features/playlists/queries/playlist-song-list-query.ts @@ -1,8 +1,8 @@ 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 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 { api } from '/@/renderer/api'; @@ -20,3 +20,42 @@ export const usePlaylistSongList = (query: PlaylistSongListQuery, options?: Quer ...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) => { + return { + ...data, + pages: data.pages.map((page) => { + return api.normalize.songList(page, server); + }), + }; + }, + [server], + ), + ...options, + }); +}; diff --git a/src/renderer/features/playlists/routes/playlist-detail-route.tsx b/src/renderer/features/playlists/routes/playlist-detail-route.tsx index e520008e..7a64871f 100644 --- a/src/renderer/features/playlists/routes/playlist-detail-route.tsx +++ b/src/renderer/features/playlists/routes/playlist-detail-route.tsx @@ -1,32 +1,42 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { useRef } from 'react'; 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 { useFastAverageColor, useShouldPadTitlebar } from '/@/renderer/hooks'; const PlaylistDetailRoute = () => { - // const tableRef = useRef(null); + const tableRef = useRef(null); const { playlistId } = useParams() as { playlistId: string }; + const padTitlebar = useShouldPadTitlebar(); - // const detailsQuery = usePlaylistDetail({ - // id: playlistId, - // }); + const detailQuery = usePlaylistDetail({ id: playlistId }); + const background = useFastAverageColor(detailQuery?.data?.imageUrl, 'dominant'); - // const playlistSongsQuery = usePlaylistSongList({ - // id: playlistId, - // limit: 50, - // startIndex: 0, - // }); - - // const imageUrl = playlistSongsQuery.data?.items?.[0]?.imageUrl; - // const background = useFastAverageColor(imageUrl); - // const containerRef = useRef(); - - // const { ref, entry } = useIntersection({ - // root: containerRef.current, - // threshold: 0.3, - // }); - - return Placeholder; + return ( + <> + + {background && ( + + + + + + + )} + + ); }; export default PlaylistDetailRoute; diff --git a/src/renderer/lib/react-query.ts b/src/renderer/lib/react-query.ts index 2186500b..b24bea96 100644 --- a/src/renderer/lib/react-query.ts +++ b/src/renderer/lib/react-query.ts @@ -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 { toast } from '/@/renderer/components'; @@ -59,3 +64,22 @@ export type MutationOptions = { retryDelay?: UseQueryOptions['retryDelay']; 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; +};