diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index eaf14aa4..2ddfb67f 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -439,7 +439,9 @@ const getSongList = async (args: SongListArgs): Promise => { } return { - items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + items: res.body.Items.map((item) => + jfNormalize.song(item, apiClientProps.server, '', query.imageSize), + ), startIndex: query.startIndex, totalRecordCount: res.body.TotalRecordCount, }; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 50c78f6c..486cb1bc 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -267,7 +267,9 @@ const getSongList = async (args: SongListArgs): Promise => { } return { - items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')), + items: res.body.data.map((song) => + ndNormalize.song(song, apiClientProps.server, '', query.imageSize), + ), startIndex: query?.startIndex || 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 551f5cbe..52bf1bc5 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -481,6 +481,7 @@ export type SongListQuery = { }; albumIds?: string[]; artistIds?: string[]; + imageSize?: number; limit?: number; musicFolderId?: string; searchTerm?: string; diff --git a/src/renderer/components/card/card-rows.tsx b/src/renderer/components/card/card-rows.tsx index 81bec599..40b1fa99 100644 --- a/src/renderer/components/card/card-rows.tsx +++ b/src/renderer/components/card/card-rows.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { generatePath } from 'react-router'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; -import { Album, AlbumArtist, Artist, Playlist } from '/@/renderer/api/types'; +import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/renderer/api/types'; import { Text } from '/@/renderer/components/text'; import { AppRoute } from '/@/renderer/router/routes'; import { CardRow } from '/@/renderer/types'; @@ -183,6 +183,60 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow } = { }, }; +export const SONG_CARD_ROWS: { [key: string]: CardRow } = { + album: { + property: 'album', + route: { + route: AppRoute.LIBRARY_ALBUMS_DETAIL, + slugs: [{ idProperty: 'albumId', slugProperty: 'albumId' }], + }, + }, + albumArtists: { + arrayProperty: 'name', + property: 'albumArtists', + route: { + route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, + slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }], + }, + }, + artists: { + arrayProperty: 'name', + property: 'artists', + route: { + route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, + slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }], + }, + }, + createdAt: { + property: 'createdAt', + }, + duration: { + property: 'duration', + }, + lastPlayedAt: { + property: 'lastPlayedAt', + }, + name: { + property: 'name', + route: { + route: AppRoute.LIBRARY_ALBUMS_DETAIL, + slugs: [{ idProperty: 'albumId', slugProperty: 'albumId' }], + }, + }, + playCount: { + property: 'playCount', + }, + rating: { + property: 'userRating', + }, + releaseDate: { + property: 'releaseDate', + }, + releaseYear: { + property: 'releaseYear', + }, +}; + export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow } = { albumCount: { property: 'albumCount', diff --git a/src/renderer/features/songs/components/song-list-content.tsx b/src/renderer/features/songs/components/song-list-content.tsx index 30f1dc74..af04a9f5 100644 --- a/src/renderer/features/songs/components/song-list-content.tsx +++ b/src/renderer/features/songs/components/song-list-content.tsx @@ -4,6 +4,7 @@ import { Spinner } from '/@/renderer/components'; import { useListContext } from '/@/renderer/context/list-context'; import { useListStoreByKey } from '/@/renderer/store'; import { ListDisplayType } from '/@/renderer/types'; +import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; const SongListTableView = lazy(() => import('/@/renderer/features/songs/components/song-list-table-view').then((module) => ({ @@ -11,12 +12,19 @@ const SongListTableView = lazy(() => })), ); +const SongListGridView = lazy(() => + import('/@/renderer/features/songs/components/song-list-grid-view').then((module) => ({ + default: module.SongListGridView, + })), +); + interface SongListContentProps { + gridRef: MutableRefObject; itemCount?: number; tableRef: MutableRefObject; } -export const SongListContent = ({ itemCount, tableRef }: SongListContentProps) => { +export const SongListContent = ({ itemCount, gridRef, tableRef }: SongListContentProps) => { const { pageKey } = useListContext(); const { display } = useListStoreByKey({ key: pageKey }); @@ -25,7 +33,10 @@ export const SongListContent = ({ itemCount, tableRef }: SongListContentProps) = return ( }> {isGrid ? ( - <> + ) : ( { + const queryClient = useQueryClient(); + const server = useCurrentServer(); + const handlePlayQueueAdd = usePlayQueueAdd(); + const { pageKey, customFilters, id } = useListContext(); + const { grid, display, filter } = useListStoreByKey({ key: pageKey }); + const { setGrid } = useListStoreActions(); + + const [searchParams, setSearchParams] = useSearchParams(); + const scrollOffset = searchParams.get('scrollOffset'); + const initialScrollOffset = Number(id ? scrollOffset : grid?.scrollOffset) || 0; + + 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, + }, + serverId: server?.id, + }); + } else { + createFavoriteMutation.mutate({ + query: { + id, + type: itemType, + }, + serverId: server?.id, + }); + } + }; + + const cardRows = useMemo(() => { + const rows: CardRow[] = [ + SONG_CARD_ROWS.name, + SONG_CARD_ROWS.album, + SONG_CARD_ROWS.albumArtists, + ]; + + switch (filter.sortBy) { + case SongListSort.ALBUM: + break; + case SongListSort.ARTIST: + break; + case SongListSort.DURATION: + rows.push(SONG_CARD_ROWS.duration); + break; + case SongListSort.FAVORITED: + break; + case SongListSort.NAME: + break; + case SongListSort.PLAY_COUNT: + rows.push(SONG_CARD_ROWS.playCount); + break; + case SongListSort.RANDOM: + break; + case SongListSort.RATING: + rows.push(SONG_CARD_ROWS.rating); + break; + case SongListSort.RECENTLY_ADDED: + rows.push(SONG_CARD_ROWS.createdAt); + break; + case SongListSort.RECENTLY_PLAYED: + rows.push(SONG_CARD_ROWS.lastPlayedAt); + break; + case SongListSort.YEAR: + rows.push(SONG_CARD_ROWS.releaseYear); + break; + case SongListSort.RELEASE_DATE: + rows.push(SONG_CARD_ROWS.releaseDate); + } + + return rows; + }, [filter.sortBy]); + + const handleGridScroll = useCallback( + (e: ListOnScrollProps) => { + if (id) { + setSearchParams( + (params) => { + params.set('scrollOffset', String(e.scrollOffset)); + return params; + }, + { replace: true }, + ); + } else { + setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey }); + } + }, + [id, pageKey, setGrid, setSearchParams], + ); + + const fetchInitialData = useCallback(() => { + const query: SongListQuery = { + ...filter, + ...customFilters, + }; + + const queryKey = queryKeys.songs.list(server?.id || '', query, id); + + const queriesFromCache: [QueryKey, SongListResponse][] = queryClient.getQueriesData({ + exact: false, + fetchStatus: 'idle', + queryKey, + stale: false, + }); + + const itemData = []; + + for (const [, data] of queriesFromCache) { + const { items, startIndex } = data || {}; + + if (items && items.length !== 1 && startIndex !== undefined) { + let itemIndex = 0; + for ( + let rowIndex = startIndex; + rowIndex < startIndex + items.length; + rowIndex += 1 + ) { + itemData[rowIndex] = items[itemIndex]; + itemIndex += 1; + } + } + } + + return itemData; + }, [customFilters, filter, id, queryClient, server?.id]); + + const fetch = useCallback( + async ({ skip, take }: { skip: number; take: number }) => { + if (!server) { + return []; + } + + const query: SongListQuery = { + imageSize: 250, + limit: take, + startIndex: skip, + ...filter, + ...customFilters, + }; + + const queryKey = queryKeys.songs.list(server?.id || '', query, id); + + const songs = await queryClient.fetchQuery(queryKey, async ({ signal }) => + controller.getSongList({ + apiClientProps: { + server, + signal, + }, + query, + }), + ); + + return songs; + }, + [customFilters, filter, id, queryClient, server], + ); + + return ( + + + {({ height, width }: Size) => ( + + )} + + + ); +}; diff --git a/src/renderer/features/songs/components/song-list-header-filters.tsx b/src/renderer/features/songs/components/song-list-header-filters.tsx index 774c24db..49c82aad 100644 --- a/src/renderer/features/songs/components/song-list-header-filters.tsx +++ b/src/renderer/features/songs/components/song-list-header-filters.tsx @@ -1,7 +1,7 @@ -import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { Divider, Flex, Group, Stack } from '@mantine/core'; import { openModal } from '@mantine/modals'; +import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react'; import { RiAddBoxFill, RiAddCircleFill, @@ -16,6 +16,7 @@ import { useListStoreByKey } from '../../../store/list.store'; import { queryKeys } from '/@/renderer/api/query-keys'; import { LibraryItem, SongListSort, SortOrder } from '/@/renderer/api/types'; import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; +import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { useListContext } from '/@/renderer/context/list-context'; import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared'; @@ -72,16 +73,21 @@ const FILTERS = { }; interface SongListHeaderFiltersProps { + gridRef: MutableRefObject; tableRef: MutableRefObject; } -export const SongListHeaderFilters = ({ tableRef }: SongListHeaderFiltersProps) => { +export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFiltersProps) => { const server = useCurrentServer(); const { pageKey, handlePlay, customFilters } = useListContext(); - const { display, table, filter } = useListStoreByKey({ filter: customFilters, key: pageKey }); - const { setFilter, setTable, setTablePagination, setDisplayType } = useListStoreActions(); + const { display, table, filter, grid } = useListStoreByKey({ + filter: customFilters, + key: pageKey, + }); + const { setFilter, setGrid, setTable, setTablePagination, setDisplayType } = + useListStoreActions(); - const { handleRefreshTable } = useListFilterRefresh({ + const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ itemType: LibraryItem.SONG, server, }); @@ -97,10 +103,11 @@ export const SongListHeaderFilters = ({ tableRef }: SongListHeaderFiltersProps) ).find((f) => f.value === filter.sortBy)?.name) || 'Unknown'; + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + 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; @@ -115,9 +122,23 @@ export const SongListHeaderFilters = ({ tableRef }: SongListHeaderFiltersProps) key: pageKey, }) as SongListFilter; - handleRefreshTable(tableRef, updatedFilters); + if (isGrid) { + handleRefreshGrid(gridRef, updatedFilters); + } else { + handleRefreshTable(tableRef, updatedFilters); + } }, - [customFilters, handleRefreshTable, pageKey, server?.type, setFilter, tableRef], + [ + customFilters, + gridRef, + handleRefreshGrid, + handleRefreshTable, + isGrid, + pageKey, + server?.type, + setFilter, + tableRef, + ], ); const handleSetMusicFolder = useCallback( @@ -141,9 +162,23 @@ export const SongListHeaderFilters = ({ tableRef }: SongListHeaderFiltersProps) }) as SongListFilter; } - handleRefreshTable(tableRef, updatedFilters); + if (isGrid) { + handleRefreshGrid(gridRef, updatedFilters); + } else { + handleRefreshTable(tableRef, updatedFilters); + } }, - [filter.musicFolderId, handleRefreshTable, tableRef, setFilter, customFilters, pageKey], + [ + filter.musicFolderId, + isGrid, + setFilter, + customFilters, + pageKey, + handleRefreshGrid, + gridRef, + handleRefreshTable, + tableRef, + ], ); const handleToggleSortOrder = useCallback(() => { @@ -154,8 +189,23 @@ export const SongListHeaderFilters = ({ tableRef }: SongListHeaderFiltersProps) itemType: LibraryItem.SONG, key: pageKey, }) as SongListFilter; - handleRefreshTable(tableRef, updatedFilters); - }, [customFilters, filter.sortOrder, handleRefreshTable, pageKey, setFilter, tableRef]); + + if (isGrid) { + handleRefreshGrid(gridRef, updatedFilters); + } else { + handleRefreshTable(tableRef, updatedFilters); + } + }, [ + customFilters, + filter.sortOrder, + gridRef, + handleRefreshGrid, + handleRefreshTable, + isGrid, + pageKey, + setFilter, + tableRef, + ]); const handleSetViewType = useCallback( (e: MouseEvent) => { @@ -212,19 +262,37 @@ export const SongListHeaderFilters = ({ tableRef }: SongListHeaderFiltersProps) } }; - const handleRowHeight = (e: number) => { - setTable({ data: { rowHeight: e }, key: pageKey }); + const handleItemSize = (e: number) => { + if (isGrid) { + setGrid({ data: { itemSize: e }, key: pageKey }); + } else { + setTable({ data: { rowHeight: e }, key: pageKey }); + } + }; + + const handleItemGap = (e: number) => { + setGrid({ data: { itemGap: e }, key: pageKey }); }; const handleRefresh = () => { queryClient.invalidateQueries(queryKeys.songs.list(server?.id || '')); - handleRefreshTable(tableRef, filter); + if (isGrid) { + handleRefreshGrid(gridRef, filter); + } else { + handleRefreshTable(tableRef, filter); + } }; const onFilterChange = (filter: SongListFilter) => { - handleRefreshTable(tableRef, { - ...filter, - }); + if (isGrid) { + handleRefreshGrid(gridRef, { + ...filter, + }); + } else { + handleRefreshTable(tableRef, { + ...filter, + }); + } }; const handleOpenFiltersModal = () => { @@ -429,6 +497,20 @@ export const SongListHeaderFilters = ({ tableRef }: SongListHeaderFiltersProps) Display type + + Card + + + Poster + Item Size + {isGrid && ( + <> + Item gap + + + + + )} Table Columns ; itemCount?: number; tableRef: MutableRefObject; title?: string; } -export const SongListHeader = ({ title, itemCount, tableRef }: SongListHeaderProps) => { +export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongListHeaderProps) => { const server = useCurrentServer(); const { pageKey, handlePlay, customFilters } = useListContext(); const { setFilter, setTablePagination } = useListStoreActions(); @@ -86,7 +88,10 @@ export const SongListHeader = ({ title, itemCount, tableRef }: SongListHeaderPro - + ); diff --git a/src/renderer/features/songs/routes/song-list-route.tsx b/src/renderer/features/songs/routes/song-list-route.tsx index d828d227..61a8f070 100644 --- a/src/renderer/features/songs/routes/song-list-route.tsx +++ b/src/renderer/features/songs/routes/song-list-route.tsx @@ -13,8 +13,10 @@ import { useSongList } from '/@/renderer/features/songs/queries/song-list-query' import { useCurrentServer, useListFilterByKey } from '/@/renderer/store'; import { Play } from '/@/renderer/types'; import { titleCase } from '/@/renderer/utils'; +import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; const TrackListRoute = () => { + const gridRef = useRef(null); const tableRef = useRef(null); const server = useCurrentServer(); const [searchParams] = useSearchParams(); @@ -134,6 +136,7 @@ const TrackListRoute = () => { { } />