From 51e20a81b7780dffd961b7125623dbab858b6533 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 13 Jan 2023 01:44:47 -0800 Subject: [PATCH] Add artist song list page --- .../album-artist-detail-content.tsx | 21 + .../components/album-artist-detail-header.tsx | 2 - .../album-artist-detail-song-list-content.tsx | 239 +++++++++ .../album-artist-detail-song-list-header.tsx | 471 ++++++++++++++++++ .../album-artist-detail-song-list-route.tsx | 65 +++ 5 files changed, 796 insertions(+), 2 deletions(-) create mode 100644 src/renderer/features/artists/components/album-artist-detail-song-list-content.tsx create mode 100644 src/renderer/features/artists/components/album-artist-detail-song-list-header.tsx create mode 100644 src/renderer/features/artists/routes/album-artist-detail-song-list-route.tsx diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index c9c313ed..186fe6e3 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -279,9 +279,30 @@ export const AlbumArtistDetailContent = () => { Add to playlist + + + {showGenres && ( diff --git a/src/renderer/features/artists/components/album-artist-detail-header.tsx b/src/renderer/features/artists/components/album-artist-detail-header.tsx index dd79fdac..3415ef02 100644 --- a/src/renderer/features/artists/components/album-artist-detail-header.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-header.tsx @@ -37,8 +37,6 @@ export const AlbumArtistDetailHeader = forwardRef( }, ]; - console.log('detailQuery?.data', detailQuery?.data); - return ( ; +} + +export const AlbumArtistDetailSongListContent = ({ + itemCount, + filter, + tableRef, +}: AlbumArtistSongListContentProps) => { + const queryClient = useQueryClient(); + const server = useCurrentServer(); + const page = useSongListStore(); + + const pagination = useSongTablePagination(); + const setPagination = useSetSongTablePagination(); + const setTable = useSetSongTable(); + const handlePlayQueueAdd = usePlayQueueAdd(); + const playButtonBehavior = usePlayButtonBehavior(); + + const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED; + + const columnDefs: ColDef[] = useMemo( + () => getColumnDefs(page.table.columns), + [page.table.columns], + ); + + 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.songs.list(server?.id || '', { + ...filter, + // artistIds: [albumArtistId], + limit, + // sortBy: SongListSort.ALBUM, + // sortOrder: SortOrder.ASC, + startIndex, + }); + + const songsRes = await queryClient.fetchQuery( + queryKey, + async ({ signal }) => + api.controller.getSongList({ + query: { + // artistIds: [albumArtistId], + ...filter, + limit, + // sortBy: SongListSort.ALBUM, + // sortOrder: SortOrder.ASC, + startIndex, + }, + server, + signal, + }), + { cacheTime: 1000 * 60 * 1 }, + ); + + const songs = api.normalize.songList(songsRes, server); + params.successCallback(songs?.items || [], songsRes?.totalRecordCount); + }, + rowCount: undefined, + }; + params.api.setDatasource(dataSource); + params.api.ensureIndexVisible(page.table.scrollOffset, 'top'); + }, + [filter, page.table.scrollOffset, queryClient, server], + ); + + const onPaginationChanged = useCallback( + (event: PaginationChangedEvent) => { + if (!isPaginationEnabled || !event.api) return; + + // Scroll to top of page on pagination change + const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage; + event.api?.ensureIndexVisible(currentPageStartIndex, 'top'); + + setPagination({ + 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 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)); + setTable({ scrollOffset }); + }; + + const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS); + + const handleRowDoubleClick = (e: RowDoubleClickedEvent) => { + if (!e.data) return; + handlePlayQueueAdd?.({ + byData: [e.data], + play: playButtonBehavior, + }); + }; + + return ( + <> + + 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} + /> + + + {page.display === ListDisplayType.TABLE_PAGINATED && ( + + )} + + + ); +}; diff --git a/src/renderer/features/artists/components/album-artist-detail-song-list-header.tsx b/src/renderer/features/artists/components/album-artist-detail-song-list-header.tsx new file mode 100644 index 00000000..f1e0a38d --- /dev/null +++ b/src/renderer/features/artists/components/album-artist-detail-song-list-header.tsx @@ -0,0 +1,471 @@ +import type { IDatasource } from '@ag-grid-community/core'; +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { Flex, Group, Stack } from '@mantine/core'; +import debounce from 'lodash/debounce'; +import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react'; +import { RiArrowDownSLine, RiFolder2Line, RiMoreFill, RiSortAsc, RiSortDesc } from 'react-icons/ri'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { + LibraryItem, + ServerType, + SongListQuery, + SongListSort, + SortOrder, +} from '/@/renderer/api/types'; +import { + Button, + DropdownMenu, + PageHeader, + SearchInput, + Slider, + TextTitle, + Switch, + MultiSelect, + Text, + SONG_TABLE_COLUMNS, + Badge, + SpinnerIcon, +} from '/@/renderer/components'; +import { usePlayQueueAdd } from '/@/renderer/features/player'; +import { useMusicFolders } from '/@/renderer/features/shared'; +import { useContainerQuery } from '/@/renderer/hooks'; +import { queryClient } from '/@/renderer/lib/react-query'; +import { + SongListFilter, + useCurrentServer, + useSetSongStore, + useSetSongTable, + useSetSongTablePagination, + useSongListStore, +} from '/@/renderer/store'; +import { ListDisplayType, Play, TableColumn } from '/@/renderer/types'; + +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: '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 }, +]; + +interface AlbumArtistDetailSongListHeaderProps { + filter: SongListFilter; + itemCount?: number; + setFilter: (filter: Partial) => void; + tableRef: MutableRefObject; + title: string; +} + +export const AlbumArtistDetailSongListHeader = ({ + filter, + setFilter, + title, + itemCount, + tableRef, +}: AlbumArtistDetailSongListHeaderProps) => { + const server = useCurrentServer(); + const page = useSongListStore(); + const setPage = useSetSongStore(); + // const setFilter = useSetSongFilters(); + const setTable = useSetSongTable(); + const setPagination = useSetSongTablePagination(); + const handlePlayQueueAdd = usePlayQueueAdd(); + const cq = useContainerQuery(); + + const musicFoldersQuery = useMusicFolders(); + + const sortByLabel = + (server?.type && + (FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find( + (f) => f.value === filter.sortBy, + )?.name) || + 'Unknown'; + + const sortOrderLabel = ORDER.find((s) => s.value === filter.sortOrder)?.name; + + const handleFilterChange = useCallback( + async (filters?: SongListFilter) => { + const dataSource: IDatasource = { + getRows: async (params) => { + const limit = params.endRow - params.startRow; + const startIndex = params.startRow; + + const pageFilters = filters || filter; + + const queryKey = queryKeys.songs.list(server?.id || '', { + limit, + startIndex, + ...pageFilters, + }); + + const songsRes = await queryClient.fetchQuery( + queryKey, + async ({ signal }) => + api.controller.getSongList({ + query: { + limit, + startIndex, + ...pageFilters, + }, + server, + signal, + }), + { cacheTime: 1000 * 60 * 1 }, + ); + + const songs = api.normalize.songList(songsRes, server); + params.successCallback(songs?.items || [], songsRes?.totalRecordCount); + }, + rowCount: undefined, + }; + tableRef.current?.api.setDatasource(dataSource); + tableRef.current?.api.purgeInfiniteCache(); + tableRef.current?.api.ensureIndexVisible(0, 'top'); + setPagination({ currentPage: 0 }); + }, + [filter, server, setPagination, tableRef], + ); + + const handleSetSortBy = useCallback( + (e: MouseEvent) => { + if (!e.currentTarget?.value || !server?.type) return; + + const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find( + (f) => f.value === e.currentTarget.value, + )?.defaultOrder; + + setFilter({ + sortBy: e.currentTarget.value as SongListSort, + sortOrder: sortOrder || SortOrder.ASC, + }); + + handleFilterChange({ + ...filter, + sortBy: e.currentTarget.value as SongListSort, + sortOrder: sortOrder || SortOrder.ASC, + }); + }, + [filter, handleFilterChange, server?.type, setFilter], + ); + + const handleSetMusicFolder = useCallback( + (e: MouseEvent) => { + if (!e.currentTarget?.value) return; + + let updatedFilters = null; + if (e.currentTarget.value === String(page.filter.musicFolderId)) { + updatedFilters = { musicFolderId: undefined }; + setFilter(updatedFilters); + } else { + updatedFilters = { musicFolderId: e.currentTarget.value }; + setFilter(updatedFilters); + } + + handleFilterChange({ ...filter, ...updatedFilters }); + }, + [filter, handleFilterChange, page.filter.musicFolderId, setFilter], + ); + + const handleToggleSortOrder = useCallback(() => { + const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; + setFilter({ sortOrder: newSortOrder }); + handleFilterChange({ ...filter, sortOrder: newSortOrder }); + }, [filter, handleFilterChange, setFilter]); + + const handleSetViewType = useCallback( + (e: MouseEvent) => { + 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({ currentPage: 0 }); + } else if (display === ListDisplayType.TABLE_PAGINATED) { + setPagination({ currentPage: 0 }); + } + }, + [page, setPage, setPagination, tableRef], + ); + + const handleSearch = debounce((e: ChangeEvent) => { + const previousSearchTerm = filter.searchTerm; + const searchTerm = e.target.value === '' ? undefined : e.target.value; + setFilter({ searchTerm }); + if (previousSearchTerm !== searchTerm) handleFilterChange({ ...filter, searchTerm }); + }, 500); + + 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 }; + + 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)); + + return setTable({ columns: newColumns }); + }; + + const handleAutoFitColumns = (e: ChangeEvent) => { + setTable({ autoFit: e.currentTarget.checked }); + + if (e.currentTarget.checked) { + tableRef.current?.api.sizeColumnsToFit(); + } + }; + + const handleRowHeight = (e: number) => { + setTable({ rowHeight: e }); + }; + + const handleRefresh = () => { + queryClient.invalidateQueries(queryKeys.songs.list(server?.id || '')); + handleFilterChange(filter); + }; + + const handlePlay = async (play: Play) => { + const query: SongListQuery = { startIndex: 0, ...filter }; + + handlePlayQueueAdd?.({ + byItemType: { + id: query, + type: LibraryItem.SONG, + }, + play, + }); + }; + + return ( + + + + + + + + + Display type + + Table + + + Table (paginated) + + + Item Size + + + + Table Columns + + + column.column)} + width={300} + onChange={handleTableColumns} + /> + + Auto Fit Columns + + + + + + + + + + + + + {FILTERS[server?.type as keyof typeof FILTERS].map((filter) => ( + + {filter.name} + + ))} + + + + {server?.type === ServerType.JELLYFIN && ( + + + + + + {musicFoldersQuery.data?.map((folder) => ( + + {folder.name} + + ))} + + + )} + + + + + + handlePlay(Play.NOW)}>Play + handlePlay(Play.LAST)}> + Add to queue + + handlePlay(Play.NEXT)}> + Add to queue next + + + Refresh + + + + + + + + + ); +}; diff --git a/src/renderer/features/artists/routes/album-artist-detail-song-list-route.tsx b/src/renderer/features/artists/routes/album-artist-detail-song-list-route.tsx new file mode 100644 index 00000000..a6b60754 --- /dev/null +++ b/src/renderer/features/artists/routes/album-artist-detail-song-list-route.tsx @@ -0,0 +1,65 @@ +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { useSetState } from '@mantine/hooks'; +import { useRef } from 'react'; +import { useParams } from 'react-router'; +import { SongListSort, SortOrder } from '/@/renderer/api/types'; +import { VirtualGridContainer } from '/@/renderer/components'; +import { AlbumArtistDetailSongListContent } from '/@/renderer/features/artists/components/album-artist-detail-song-list-content'; +import { AlbumArtistDetailSongListHeader } from '/@/renderer/features/artists/components/album-artist-detail-song-list-header'; +import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query'; +import { AnimatedPage } from '/@/renderer/features/shared'; +import { useSongList } from '/@/renderer/features/songs'; +import { SongListFilter } from '/@/renderer/store'; + +const AlbumArtistDetailSongListRoute = () => { + const tableRef = useRef(null); + const { albumArtistId } = useParams() as { albumArtistId: string }; + + const detailQuery = useAlbumArtistDetail({ id: albumArtistId }); + + const [filter, setFilter] = useSetState({ + artistIds: [albumArtistId], + sortBy: SongListSort.ALBUM, + sortOrder: SortOrder.ASC, + }); + + const itemCountCheck = useSongList( + { + limit: 1, + startIndex: 0, + ...filter, + }, + { + cacheTime: 1000 * 60 * 60 * 2, + staleTime: 1000 * 60 * 60 * 2, + }, + ); + + const itemCount = + itemCountCheck.data?.totalRecordCount === null + ? undefined + : itemCountCheck.data?.totalRecordCount; + + if (detailQuery.isLoading) return null; + + return ( + + + + + + + ); +}; + +export default AlbumArtistDetailSongListRoute;