diff --git a/src/renderer/features/albums/components/album-list-content.tsx b/src/renderer/features/albums/components/album-list-content.tsx index 9526075c0..97208d4c2 100644 --- a/src/renderer/features/albums/components/album-list-content.tsx +++ b/src/renderer/features/albums/components/album-list-content.tsx @@ -1,42 +1,128 @@ -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { lazy, Suspense } from 'react'; -import { lazy, MutableRefObject, Suspense } from 'react'; - -import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid'; -import { useListContext } from '/@/renderer/context/list-context'; -import { useListStoreByKey } from '/@/renderer/store'; +import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; +import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store'; import { Spinner } from '/@/shared/components/spinner/spinner'; -import { ListDisplayType } from '/@/shared/types/types'; +import { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types'; -const AlbumListGridView = lazy(() => - import('/@/renderer/features/albums/components/album-list-grid-view').then((module) => ({ - default: module.AlbumListGridView, +const AlbumListInfiniteGrid = lazy(() => + import('/@/renderer/features/albums/components/album-list-infinite-grid').then((module) => ({ + default: module.AlbumListInfiniteGrid, })), ); -const AlbumListTableView = lazy(() => - import('/@/renderer/features/albums/components/album-list-table-view').then((module) => ({ - default: module.AlbumListTableView, +const AlbumListPaginatedGrid = lazy(() => + import('/@/renderer/features/albums/components/album-list-paginated-grid').then((module) => ({ + default: module.AlbumListPaginatedGrid, })), ); -interface AlbumListContentProps { - gridRef: MutableRefObject; - itemCount?: number; - tableRef: MutableRefObject; -} +const AlbumListInfiniteTable = lazy(() => + import('/@/renderer/features/albums/components/album-list-infinite-table').then((module) => ({ + default: module.AlbumListInfiniteTable, + })), +); -export const AlbumListContent = ({ gridRef, itemCount, tableRef }: AlbumListContentProps) => { - const { pageKey } = useListContext(); - const { display } = useListStoreByKey({ key: pageKey }); +const AlbumListPaginatedTable = lazy(() => + import('/@/renderer/features/albums/components/album-list-paginated-table').then((module) => ({ + default: module.AlbumListPaginatedTable, + })), +); + +export const AlbumListContent = () => { + const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM); return ( }> - {display === ListDisplayType.CARD || display === ListDisplayType.GRID ? ( - - ) : ( - - )} + ); }; + +export const AlbumListView = ({ + display, + grid, + itemsPerPage, + pagination, + table, +}: ItemListSettings) => { + const server = useCurrentServer(); + + const filters = useAlbumListFilters(); + const query = filters.query; + + switch (display) { + case ListDisplayType.GRID: { + switch (pagination) { + case ListPaginationType.INFINITE: { + return ( + + ); + } + case ListPaginationType.PAGINATED: { + return ( + + ); + } + default: + return null; + } + } + case ListDisplayType.TABLE: { + switch (pagination) { + case ListPaginationType.INFINITE: { + return ( + + ); + } + case ListPaginationType.PAGINATED: { + return ( + + ); + } + default: + return null; + } + } + } + + return null; +}; diff --git a/src/renderer/features/albums/components/album-list-grid-view.tsx b/src/renderer/features/albums/components/album-list-grid-view.tsx deleted file mode 100644 index 7adae523d..000000000 --- a/src/renderer/features/albums/components/album-list-grid-view.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { QueryKey, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import AutoSizer, { Size } from 'react-virtualized-auto-sizer'; -import { ListOnScrollProps } from 'react-window'; - -import { controller } from '/@/renderer/api/controller'; -import { queryKeys } from '/@/renderer/api/query-keys'; -import { ALBUM_CARD_ROWS } from '/@/renderer/components/card/card-rows'; -import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper'; -import { VirtualInfiniteGrid } from '/@/renderer/components/virtual-grid/virtual-infinite-grid'; -import { useListContext } from '/@/renderer/context/list-context'; -import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add'; -import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite'; -import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer, useListStoreActions, useListStoreByKey } from '/@/renderer/store'; -import { - Album, - AlbumListQuery, - AlbumListResponse, - AlbumListSort, - LibraryItem, -} from '/@/shared/types/domain-types'; -import { CardRow, ListDisplayType } from '/@/shared/types/types'; - -export const AlbumListGridView = ({ gridRef, itemCount }: any) => { - const queryClient = useQueryClient(); - const server = useCurrentServer(); - const handlePlayQueueAdd = usePlayQueueAdd(); - const { customFilters, id, pageKey } = useListContext(); - const { display, filter, grid } = useListStoreByKey({ key: pageKey }); - const { setGrid } = useListStoreActions(); - - const [searchParams, setSearchParams] = useSearchParams(); - const scrollOffset = searchParams.get('scrollOffset'); - const initialScrollOffset = Number(id ? scrollOffset : grid?.scrollOffset) || 0; - - const handleFavorite = useHandleFavorite({ gridRef }); - - const cardRows = useMemo(() => { - const rows: CardRow[] = [ALBUM_CARD_ROWS.name]; - - switch (filter.sortBy) { - case AlbumListSort.ALBUM_ARTIST: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.releaseYear); - break; - case AlbumListSort.ARTIST: - rows.push(ALBUM_CARD_ROWS.artists); - rows.push(ALBUM_CARD_ROWS.releaseYear); - break; - case AlbumListSort.COMMUNITY_RATING: - rows.push(ALBUM_CARD_ROWS.albumArtists); - break; - case AlbumListSort.DURATION: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.duration); - break; - case AlbumListSort.EXPLICIT_STATUS: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.explicitStatus); - break; - case AlbumListSort.FAVORITED: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.releaseYear); - break; - case AlbumListSort.NAME: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.releaseYear); - break; - case AlbumListSort.PLAY_COUNT: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.playCount); - break; - case AlbumListSort.RANDOM: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.releaseYear); - break; - case AlbumListSort.RATING: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.rating); - break; - case AlbumListSort.RECENTLY_ADDED: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.createdAt); - break; - case AlbumListSort.RECENTLY_PLAYED: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.lastPlayedAt); - break; - case AlbumListSort.SONG_COUNT: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.songCount); - break; - case AlbumListSort.YEAR: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_CARD_ROWS.releaseYear); - break; - case AlbumListSort.RELEASE_DATE: - rows.push(ALBUM_CARD_ROWS.albumArtists); - rows.push(ALBUM_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: AlbumListQuery = { - ...filter, - ...customFilters, - }; - - const queryKey = queryKeys.albums.list(server?.id || '', query, id); - - const queriesFromCache: [QueryKey, AlbumListResponse | undefined][] = - queryClient.getQueriesData({ - exact: false, - fetchStatus: 'idle', - queryKey, - stale: false, - }); - - const itemData: Album[] = []; - - 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: AlbumListQuery = { - limit: take, - ...filter, - ...customFilters, - startIndex: skip, - }; - - const queryKey = queryKeys.albums.list(server?.id || '', query, id); - - const albums = await queryClient.fetchQuery({ - queryFn: async ({ signal }) => - controller.getAlbumList({ - apiClientProps: { - serverId: server?.id || '', - signal, - }, - query, - }), - queryKey, - }); - - return albums; - }, - [customFilters, filter, id, queryClient, server], - ); - - return ( - - - {({ height, width }: Size) => ( - - )} - - - ); -}; diff --git a/src/renderer/features/albums/components/album-list-header-filters.tsx b/src/renderer/features/albums/components/album-list-header-filters.tsx index c8102cbed..a14f708fb 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -1,551 +1,37 @@ -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; - -import { openModal } from '@mantine/modals'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import debounce from 'lodash/debounce'; -import { MouseEvent, MutableRefObject, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import i18n from '/@/i18n/i18n'; -import { queryKeys } from '/@/renderer/api/query-keys'; -import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid'; import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; -import { useListContext } from '/@/renderer/context/list-context'; -import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters'; -import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters'; -import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters'; -import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; -import { FilterButton } from '/@/renderer/features/shared/components/filter-button'; -import { FolderButton } from '/@/renderer/features/shared/components/folder-button'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; -import { MoreButton } from '/@/renderer/features/shared/components/more-button'; -import { OrderToggleButton } from '/@/renderer/features/shared/components/order-toggle-button'; -import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button'; +import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; +import { ListMusicFolderDropdown } from '/@/renderer/features/shared/components/list-music-folder-dropdown'; +import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; +import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; +import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; import { useContainerQuery } from '/@/renderer/hooks'; -import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; -import { - AlbumListFilter, - useCurrentServer, - useListStoreActions, - useListStoreByKey, -} from '/@/renderer/store'; -import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; -import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; -import { Icon } from '/@/shared/components/icon/icon'; -import { - AlbumListQuery, - AlbumListSort, - LibraryItem, - ServerType, - SortOrder, -} from '/@/shared/types/domain-types'; -import { ListDisplayType, Play, TableColumn } from '/@/shared/types/types'; +import { AlbumListSort, LibraryItem } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; -const FILTERS = { - jellyfin: [ - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), - value: AlbumListSort.ALBUM_ARTIST, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.communityRating', { postProcess: 'titleCase' }), - value: AlbumListSort.COMMUNITY_RATING, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.criticRating', { postProcess: 'titleCase' }), - value: AlbumListSort.CRITIC_RATING, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: AlbumListSort.NAME, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.playCount', { postProcess: 'titleCase' }), - value: AlbumListSort.PLAY_COUNT, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.random', { postProcess: 'titleCase' }), - value: AlbumListSort.RANDOM, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), - value: AlbumListSort.RECENTLY_ADDED, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.releaseDate', { postProcess: 'titleCase' }), - value: AlbumListSort.RELEASE_DATE, - }, - ], - navidrome: [ - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), - value: AlbumListSort.ALBUM_ARTIST, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.artist', { postProcess: 'titleCase' }), - value: AlbumListSort.ARTIST, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.duration', { postProcess: 'titleCase' }), - value: AlbumListSort.DURATION, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.explicitStatus', { postProcess: 'titleCase' }), - value: AlbumListSort.EXPLICIT_STATUS, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }), - value: AlbumListSort.PLAY_COUNT, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: AlbumListSort.NAME, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.random', { postProcess: 'titleCase' }), - value: AlbumListSort.RANDOM, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.rating', { postProcess: 'titleCase' }), - value: AlbumListSort.RATING, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), - value: AlbumListSort.RECENTLY_ADDED, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }), - value: AlbumListSort.RECENTLY_PLAYED, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.songCount', { postProcess: 'titleCase' }), - value: AlbumListSort.SONG_COUNT, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.favorited', { postProcess: 'titleCase' }), - value: AlbumListSort.FAVORITED, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }), - value: AlbumListSort.YEAR, - }, - ], - subsonic: [ - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }), - value: AlbumListSort.ALBUM_ARTIST, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }), - value: AlbumListSort.PLAY_COUNT, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: AlbumListSort.NAME, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.random', { postProcess: 'titleCase' }), - value: AlbumListSort.RANDOM, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), - value: AlbumListSort.RECENTLY_ADDED, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }), - value: AlbumListSort.RECENTLY_PLAYED, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.favorited', { postProcess: 'titleCase' }), - value: AlbumListSort.FAVORITED, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }), - value: AlbumListSort.YEAR, - }, - ], -}; - -interface AlbumListHeaderFiltersProps { - gridRef: MutableRefObject; - itemCount: number | undefined; - tableRef: MutableRefObject; -} - -export const AlbumListHeaderFilters = ({ - gridRef, - itemCount, - tableRef, -}: AlbumListHeaderFiltersProps) => { - const { t } = useTranslation(); - const queryClient = useQueryClient(); - const { customFilters, handlePlay, pageKey } = useListContext(); - const server = useCurrentServer(); - const { setDisplayType, setFilter, setGrid, setTable } = useListStoreActions(); - const { display, filter, grid, table } = useListStoreByKey({ - filter: customFilters, - key: pageKey, - }); +export const AlbumListHeaderFilters = () => { const cq = useContainerQuery(); - const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ - itemCount, - itemType: LibraryItem.ALBUM, - server, - }); - - const musicFoldersQuery = useQuery( - sharedQueries.musicFolders({ query: null, serverId: server?.id }), - ); - - const sortByLabel = - (server?.type && - FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy) - ?.name) || - 'Unknown'; - - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; - - const onFilterChange = useCallback( - (filter: AlbumListFilter) => { - if (isGrid) { - handleRefreshGrid(gridRef, { - ...filter, - ...customFilters, - }); - } else { - handleRefreshTable(tableRef, { - ...filter, - ...customFilters, - }); - } - }, - [customFilters, gridRef, handleRefreshGrid, handleRefreshTable, isGrid, tableRef], - ); - - const handleOpenFiltersModal = () => { - let FilterComponent; - - switch (server?.type) { - case ServerType.JELLYFIN: - FilterComponent = JellyfinAlbumFilters; - break; - case ServerType.NAVIDROME: - FilterComponent = NavidromeAlbumFilters; - break; - case ServerType.SUBSONIC: - FilterComponent = SubsonicAlbumFilters; - break; - default: - break; - } - - if (!FilterComponent) { - return; - } - - openModal({ - children: ( - - ), - title: 'Album Filters', - }); - }; - - const handleRefresh = useCallback(() => { - queryClient.invalidateQueries({ queryKey: queryKeys.albums.list(server?.id || '') }); - onFilterChange(filter); - }, [filter, onFilterChange, queryClient, server?.id]); - - 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; - - const updatedFilters = setFilter({ - customFilters, - data: { - sortBy: e.currentTarget.value as AlbumListSort, - sortOrder: sortOrder || SortOrder.ASC, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - - onFilterChange(updatedFilters); - }, - [customFilters, onFilterChange, pageKey, server?.type, setFilter], - ); - - const handleSetMusicFolder = useCallback( - (e: MouseEvent) => { - if (!e.currentTarget?.value) return; - - let updatedFilters: AlbumListFilter | null = null; - if (e.currentTarget.value === String(filter.musicFolderId)) { - updatedFilters = setFilter({ - customFilters, - data: { musicFolderId: undefined }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - } else { - updatedFilters = setFilter({ - customFilters, - data: { musicFolderId: e.currentTarget.value }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - } - - onFilterChange(updatedFilters); - }, - [filter.musicFolderId, onFilterChange, setFilter, customFilters, pageKey], - ); - - const handleToggleSortOrder = useCallback(() => { - const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; - const updatedFilters = setFilter({ - customFilters, - data: { sortOrder: newSortOrder }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); - }, [customFilters, filter.sortOrder, onFilterChange, pageKey, setFilter]); - - const handleItemSize = (e: number) => { - if (isGrid) { - setGrid({ data: { itemSize: e }, key: pageKey }); - } else { - setTable({ data: { rowHeight: e }, key: pageKey }); - } - }; - - const debouncedHandleItemSize = debounce(handleItemSize, 20); - - const handleItemGap = (e: number) => { - setGrid({ data: { itemGap: e }, key: pageKey }); - }; - - const handleSetViewType = useCallback( - (displayType: ListDisplayType) => { - setDisplayType({ data: displayType, key: pageKey }); - }, - [pageKey, setDisplayType], - ); - - const handleTableColumns = (values: string[]) => { - const existingColumns = table.columns; - - if (values.length === 0) { - return setTable({ - data: { columns: [] }, - key: pageKey, - }); - } - - // If adding a column - if (values.length > existingColumns.length) { - const newColumn = { column: values[values.length - 1] as TableColumn, width: 100 }; - - setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey }); - } else { - // If removing a column - const removed = existingColumns.filter((column) => !values.includes(column.column)); - const newColumns = existingColumns.filter((column) => !removed.includes(column)); - - setTable({ data: { columns: newColumns }, key: pageKey }); - } - - return tableRef.current?.api.sizeColumnsToFit(); - }; - - const handleAutoFitColumns = (autoFitColumns: boolean) => { - setTable({ data: { autoFit: autoFitColumns }, key: pageKey }); - - if (autoFitColumns) { - tableRef.current?.api.sizeColumnsToFit(); - } - }; - - const isFilterApplied = useMemo(() => { - const isNavidromeFilterApplied = - server?.type === ServerType.NAVIDROME && - ((filter?._custom?.navidrome && - Object.values(filter?._custom?.navidrome).some((value) => value !== undefined)) || - // Compilation is always valid - filter.compilation !== undefined); - - const isJellyfinFilterApplied = - server?.type === ServerType.JELLYFIN && - ((filter?._custom?.jellyfin && - Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined)) || - // Compilation filter is only valid when on the artist page - (filter.compilation !== undefined && customFilters?.artistIds)); - - const isSubsonicFilterApplied = - server?.type === ServerType.SUBSONIC && (filter.maxYear || filter.minYear); - - return ( - isNavidromeFilterApplied || - isJellyfinFilterApplied || - isSubsonicFilterApplied || - filter.genres?.length || - filter.favorite !== undefined || - // If we are on the artist page, the artist id filter should not be active - (filter.artistIds?.length && !(customFilters?.artistIds as any | undefined)?.length) - ); - }, [ - customFilters?.artistIds, - filter?._custom?.jellyfin, - filter?._custom?.navidrome, - filter.artistIds?.length, - filter.compilation, - filter.favorite, - filter.genres?.length, - filter.maxYear, - filter.minYear, - server?.type, - ]); - - const isFolderFilterApplied = useMemo(() => { - return filter.musicFolderId !== undefined; - }, [filter.musicFolderId]); - return ( - - - - - - {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( - - {f.name} - - ))} - - + - - {server?.type === ServerType.JELLYFIN && ( - <> - - - - - - - {musicFoldersQuery.data?.items.map((folder) => ( - - {folder.name} - - ))} - - - - )} - - - - - - - - } - onClick={() => handlePlay?.({ playType: Play.NOW })} - > - {t('player.play', { postProcess: 'sentenceCase' })} - - } - onClick={() => handlePlay?.({ playType: Play.LAST })} - > - {t('player.addLast', { postProcess: 'sentenceCase' })} - - } - onClick={() => handlePlay?.({ playType: Play.NEXT })} - > - {t('player.addNext', { postProcess: 'sentenceCase' })} - - - } - onClick={handleRefresh} - > - {t('common.refresh', { postProcess: 'sentenceCase' })} - - - + + + + column.column)} + listKey={ItemListKey.ALBUM} tableColumnsData={ALBUM_TABLE_COLUMNS} /> diff --git a/src/renderer/features/albums/components/album-list-header.tsx b/src/renderer/features/albums/components/album-list-header.tsx index 8acb43d42..4a5cfa3c7 100644 --- a/src/renderer/features/albums/components/album-list-header.tsx +++ b/src/renderer/features/albums/components/album-list-header.tsx @@ -1,95 +1,40 @@ -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; - -import debounce from 'lodash/debounce'; -import { type ChangeEvent, type MutableRefObject, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { PageHeader } from '/@/renderer/components/page-header/page-header'; -import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid'; +import { useListContext } from '/@/renderer/context/list-context'; import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters'; import { FilterBar } from '/@/renderer/features/shared/components/filter-bar'; import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar'; import { SearchInput } from '/@/renderer/features/shared/components/search-input'; -import { useContainerQuery } from '/@/renderer/hooks'; -import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; -import { AlbumListFilter, useCurrentServer, usePlayButtonBehavior } from '/@/renderer/store'; -import { titleCase } from '/@/renderer/utils'; -import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; import { Stack } from '/@/shared/components/stack/stack'; -import { AlbumListQuery, LibraryItem } from '/@/shared/types/domain-types'; interface AlbumListHeaderProps { - genreId?: string; - gridRef: MutableRefObject; - itemCount?: number; - tableRef: MutableRefObject; title?: string; } -export const AlbumListHeader = ({ - genreId, - gridRef, - itemCount, - tableRef, - title, -}: AlbumListHeaderProps) => { +export const AlbumListHeader = ({ title }: AlbumListHeaderProps) => { const { t } = useTranslation(); - const server = useCurrentServer(); - const cq = useContainerQuery(); - const playButtonBehavior = usePlayButtonBehavior(); - const genreRef = useRef(undefined); - const { filter, handlePlay, refresh, search } = useDisplayRefresh({ - gridRef, - itemCount, - itemType: LibraryItem.ALBUM, - server, - tableRef, - }); - const handleSearch = debounce((e: ChangeEvent) => { - const updatedFilters = search(e) as AlbumListFilter; - - refresh(updatedFilters); - }, 500); - - useEffect(() => { - if (genreRef.current && genreRef.current !== genreId) { - refresh(filter); - } - - genreRef.current = genreId; - }, [filter, genreId, refresh, tableRef]); + const { itemCount } = useListContext(); + const pageTitle = title || t('page.albumList.title', { postProcess: 'titleCase' }); return ( - + - - - handlePlay?.({ playType: playButtonBehavior })} - /> - - {title || - titleCase(t('page.albumList.title', { postProcess: 'titleCase' }))} - - - {itemCount} - - - - - - + + + {pageTitle} + + {itemCount} + + + + + - + ); diff --git a/src/renderer/features/albums/components/album-list-infinite-grid.tsx b/src/renderer/features/albums/components/album-list-infinite-grid.tsx index 0b86c6cf1..efd34bd7a 100644 --- a/src/renderer/features/albums/components/album-list-infinite-grid.tsx +++ b/src/renderer/features/albums/components/album-list-infinite-grid.tsx @@ -2,11 +2,10 @@ import { UseSuspenseQueryOptions } from '@tanstack/react-query'; import { forwardRef } from 'react'; import { api } from '/@/renderer/api'; -import { - InfiniteListProps, - useItemListInfiniteLoader, -} from '/@/renderer/components/item-list/helpers/item-list-infinite-loader'; +import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader'; +import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list'; +import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { AlbumListQuery, @@ -14,16 +13,20 @@ import { LibraryItem, SortOrder, } from '/@/shared/types/domain-types'; - -interface AlbumListInfiniteGridProps extends InfiniteListProps {} +import { ItemListKey } from '/@/shared/types/types'; +interface AlbumListInfiniteGridProps extends ItemListGridComponentProps {} export const AlbumListInfiniteGrid = forwardRef( ( { + gap = 'md', + itemsPerPage = 100, + itemsPerRow, query = { sortBy: AlbumListSort.NAME, sortOrder: SortOrder.ASC, }, + saveScrollOffset = true, serverId, }, ref, @@ -36,18 +39,31 @@ export const AlbumListInfiniteGrid = forwardRef const listQueryFn = api.controller.getAlbumList; const { data, onRangeChanged } = useItemListInfiniteLoader({ - itemsPerPage: 100, + eventKey: ItemListKey.ALBUM, + itemsPerPage, + itemType: LibraryItem.ALBUM, listCountQuery, listQueryFn, query, serverId, }); + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + return ( ); diff --git a/src/renderer/features/albums/components/album-list-infinite-table.tsx b/src/renderer/features/albums/components/album-list-infinite-table.tsx new file mode 100644 index 000000000..10ce03302 --- /dev/null +++ b/src/renderer/features/albums/components/album-list-infinite-table.tsx @@ -0,0 +1,82 @@ +import { UseSuspenseQueryOptions } from '@tanstack/react-query'; +import { forwardRef } from 'react'; + +import { api } from '/@/renderer/api'; +import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader'; +import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; +import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; +import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types'; +import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { + AlbumListQuery, + AlbumListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface AlbumListInfiniteTableProps extends ItemListTableComponentProps {} + +export const AlbumListInfiniteTable = forwardRef( + ( + { + columns, + enableAlternateRowColors = false, + enableHorizontalBorders = false, + enableRowHoverHighlight = true, + enableVerticalBorders = false, + itemsPerPage = 100, + query = { + sortBy: AlbumListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, + size = 'default', + }, + ref, + ) => { + const listCountQuery = albumQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getAlbumList; + + const { data, onRangeChanged } = useItemListInfiniteLoader({ + eventKey: ItemListKey.ALBUM, + itemsPerPage, + itemType: LibraryItem.ALBUM, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + return ( + + ); + }, +); diff --git a/src/renderer/features/albums/components/album-list-paginated-grid.tsx b/src/renderer/features/albums/components/album-list-paginated-grid.tsx index 9f0bdc522..88e342e13 100644 --- a/src/renderer/features/albums/components/album-list-paginated-grid.tsx +++ b/src/renderer/features/albums/components/album-list-paginated-grid.tsx @@ -2,13 +2,12 @@ import { UseSuspenseQueryOptions } from '@tanstack/react-query'; import { forwardRef } from 'react'; import { api } from '/@/renderer/api'; -import { - PaginatedListProps, - useItemListPaginatedLoader, -} from '/@/renderer/components/item-list/helpers/item-list-paginated-loader'; +import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader'; +import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list'; import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination'; +import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { AlbumListQuery, @@ -17,17 +16,18 @@ import { SortOrder, } from '/@/shared/types/domain-types'; -interface AlbumListPaginatedGridProps extends PaginatedListProps {} +interface AlbumListPaginatedGridProps extends ItemListGridComponentProps {} export const AlbumListPaginatedGrid = forwardRef( ( { - initialPage, + gap = 'md', itemsPerPage = 100, query = { sortBy: AlbumListSort.NAME, sortOrder: SortOrder.ASC, }, + saveScrollOffset = true, serverId, }, ref, @@ -39,7 +39,7 @@ export const AlbumListPaginatedGrid = forwardRef - + ); }, diff --git a/src/renderer/features/albums/components/album-list-paginated-table.tsx b/src/renderer/features/albums/components/album-list-paginated-table.tsx new file mode 100644 index 000000000..15c6f4280 --- /dev/null +++ b/src/renderer/features/albums/components/album-list-paginated-table.tsx @@ -0,0 +1,91 @@ +import { UseSuspenseQueryOptions } from '@tanstack/react-query'; +import { forwardRef } from 'react'; + +import { api } from '/@/renderer/api'; +import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader'; +import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; +import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; +import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination'; +import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; +import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types'; +import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { + AlbumListQuery, + AlbumListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; + +interface AlbumListPaginatedTableProps extends ItemListTableComponentProps {} + +export const AlbumListPaginatedTable = forwardRef( + ( + { + columns, + enableAlternateRowColors = false, + enableHorizontalBorders = false, + enableRowHoverHighlight = true, + enableVerticalBorders = false, + itemsPerPage = 100, + query = { + sortBy: AlbumListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, + size = 'default', + }, + ref, + ) => { + const listCountQuery = albumQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getAlbumList; + + const { currentPage, onChange } = useItemListPagination(); + + const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({ + currentPage, + itemsPerPage, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + return ( + + + + ); + }, +); diff --git a/src/renderer/features/albums/components/album-list-table-view.tsx b/src/renderer/features/albums/components/album-list-table-view.tsx deleted file mode 100644 index 593913c7e..000000000 --- a/src/renderer/features/albums/components/album-list-table-view.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper'; -import { VirtualTable } from '/@/renderer/components/virtual-table'; -import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table'; -import { useListContext } from '/@/renderer/context/list-context'; -import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; -import { useCurrentServer } from '/@/renderer/store'; -import { LibraryItem } from '/@/shared/types/domain-types'; - -export const AlbumListTableView = ({ itemCount, tableRef }: any) => { - const server = useCurrentServer(); - const { customFilters, id, pageKey } = useListContext(); - - const tableProps = useVirtualTable({ - contextMenu: ALBUM_CONTEXT_MENU_ITEMS, - customFilters, - isSearchParams: Boolean(id), - itemCount, - itemType: LibraryItem.ALBUM, - pageKey, - server, - tableRef, - }); - - return ( - - - - ); -}; diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index ca0031235..0ef1f8d5d 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -4,10 +4,11 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; +import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; -import { AlbumListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; +import { AlbumListFilter } from '/@/renderer/store'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { NumberInput } from '/@/shared/components/number-input/number-input'; @@ -17,7 +18,6 @@ import { Text } from '/@/shared/components/text/text'; import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select'; import { AlbumArtistListSort, - AlbumListQuery, GenreListSort, LibraryItem, SortOrder, @@ -27,30 +27,31 @@ interface JellyfinAlbumFiltersProps { customFilters?: Partial; disableArtistFilter?: boolean; onFilterChange: (filters: AlbumListFilter) => void; - pageKey: string; serverId: string; } export const JellyfinAlbumFilters = ({ - customFilters, disableArtistFilter, - onFilterChange, - pageKey, serverId, }: JellyfinAlbumFiltersProps) => { const { t } = useTranslation(); - const filter = useListFilterByKey({ key: pageKey }); - const { setFilter } = useListStoreActions(); + + const { + query, + setAlbumArtist, + setAlbumCompilation, + setAlbumFavorite, + setAlbumGenre, + setCustom, + setMaxAlbumYear, + setMinAlbumYear, + } = useAlbumListFilters(); // TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library const genreListQuery = useQuery( genresQueries.list({ - options: { - gcTime: 1000 * 60 * 2, - staleTime: 1000 * 60 * 1, - }, query: { - musicFolderId: filter?.musicFolderId, + musicFolderId: query.musicFolderId, sortBy: GenreListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0, @@ -74,106 +75,57 @@ export const JellyfinAlbumFilters = ({ staleTime: 1000 * 60 * 1, }, query: { - folder: filter?.musicFolderId, + folder: query.musicFolderId, type: LibraryItem.ALBUM, }, serverId, }), ); - const selectedTags = useMemo(() => { - return filter?._custom?.jellyfin?.Tags?.split('|'); - }, [filter?._custom?.jellyfin?.Tags]); - const yesNoFilter = useMemo(() => { const filters = [ { label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), - onChange: (favorite?: boolean) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: filter?._custom, - favorite, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + onChange: (favoriteValue?: boolean) => { + setAlbumFavorite(favoriteValue ?? null); }, - value: filter?.favorite, + value: query.favorite, }, ]; - if (customFilters?.artistIds) { + if (query.artistIds?.length) { filters.push({ label: t('filter.isCompilation', { postProcess: 'sentenceCase' }), - onChange: (compilation?: boolean) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: filter._custom, - compilation, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + onChange: (compilationValue?: boolean) => { + setAlbumCompilation(compilationValue ?? null); }, - value: filter.compilation, + value: query.compilation, }); } return filters; }, [ - customFilters, - filter._custom, - filter.compilation, - filter?.favorite, - onFilterChange, - pageKey, - setFilter, t, + query.favorite, + query.artistIds?.length, + query.compilation, + setAlbumFavorite, + setAlbumCompilation, ]); const handleMinYearFilter = debounce((e: number | string) => { if (typeof e === 'number' && (e < 1700 || e > 2300)) return; - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: filter?._custom, - minYear: e === '' ? undefined : (e as number), - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + const year = e === '' ? undefined : (e as number); + setMinAlbumYear(year ?? null); }, 500); const handleMaxYearFilter = debounce((e: number | string) => { if (typeof e === 'number' && (e < 1700 || e > 2300)) return; - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: filter?._custom, - maxYear: e === '' ? undefined : (e as number), - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + const year = e === '' ? undefined : (e as number); + setMaxAlbumYear(year ?? null); }, 500); const handleGenresFilter = debounce((e: string[] | undefined) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: filter?._custom, - genres: e, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + setAlbumGenre(e ?? null); }, 250); const albumArtistListQuery = useQuery( @@ -201,70 +153,54 @@ export const JellyfinAlbumFilters = ({ }, [albumArtistListQuery?.data?.items]); const handleAlbumArtistFilter = (e: null | string[]) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: filter?._custom, - artistIds: e?.length ? e : undefined, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + setAlbumArtist(e ?? null); }; const handleTagFilter = debounce((e: string[] | undefined) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: { - ...filter?._custom, - jellyfin: { - ...filter?._custom?.jellyfin, - Tags: e?.join('|') || undefined, - }, - }, - }, - itemType: LibraryItem.SONG, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + setCustom((prev) => ({ + ...prev, + [e?.join('|') || '']: e?.join('|') || undefined, + })); }, 250); return ( {yesNoFilter.map((filter) => ( - + {filter.label} - + ))} handleMinYearFilter(e)} - required={!!filter?.maxYear} + required={!!query.minYear} /> handleMaxYearFilter(e)} - required={!!filter?.minYear} + required={!!query.minYear} /> ; disableArtistFilter?: boolean; - onFilterChange: (filters: AlbumListFilter) => void; - pageKey: string; - serverId: string; } -export const NavidromeAlbumFilters = ({ - customFilters, - disableArtistFilter, - onFilterChange, - pageKey, - serverId, -}: NavidromeAlbumFiltersProps) => { +export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFiltersProps) => { const { t } = useTranslation(); - const { filter } = useListStoreByKey({ key: pageKey }); - const { setFilter } = useListStoreActions(); const server = useCurrentServer(); + const serverId = server.id; + + const { + query, + setAlbumArtist, + setAlbumCompilation, + setAlbumFavorite, + setAlbumGenre, + setAlbumHasRating, + setAlbumRecentlyPlayed, + setCustom, + setMaxAlbumYear, + setMinAlbumYear, + } = useAlbumListFilters(); const genreListQuery = useQuery( genresQueries.list({ @@ -78,21 +75,6 @@ export const NavidromeAlbumFilters = ({ })); }, [genreListQuery.data]); - const hasBrf = hasFeature(server, ServerFeature.BFR); - - const handleGenresFilter = debounce((e: null | string[]) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: filter._custom, - genres: e ? e : undefined, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); - }, 250); - const tagsQuery = useQuery( sharedQueries.tags({ options: { @@ -110,34 +92,16 @@ export const NavidromeAlbumFilters = ({ { label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), onChange: (favorite?: boolean) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: filter._custom, - favorite, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + setAlbumFavorite(favorite ?? null); }, - value: filter.favorite, + value: query.favorite, }, { label: t('filter.isCompilation', { postProcess: 'sentenceCase' }), onChange: (compilation?: boolean) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: filter._custom, - compilation, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + setAlbumCompilation(compilation ?? null); }, - value: filter.compilation, + value: query.compilation, }, ]; @@ -145,63 +109,25 @@ export const NavidromeAlbumFilters = ({ { label: t('filter.isRated', { postProcess: 'sentenceCase' }), onChange: (e: ChangeEvent) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: { - ...filter._custom, - navidrome: { - ...filter._custom?.navidrome, - has_rating: e.currentTarget.checked ? true : undefined, - }, - }, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + const hasRating = e.currentTarget.checked ? true : undefined; + setAlbumHasRating(hasRating ?? null); }, - value: filter._custom?.navidrome?.has_rating, + value: query.hasRating, }, { label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }), onChange: (e: ChangeEvent) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: { - ...filter._custom, - navidrome: { - ...filter._custom?.navidrome, - recently_played: e.currentTarget.checked ? true : undefined, - }, - }, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + const recentlyPlayed = e.currentTarget.checked ? true : undefined; + setAlbumRecentlyPlayed(recentlyPlayed ?? null); }, - value: filter._custom?.navidrome?.recently_played, + value: query.recentlyPlayed, }, ]; const handleYearFilter = debounce((e: number | string) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: { - ...filter._custom, - navidrome: { - ...filter._custom?.navidrome, - year: e === '' ? undefined : (e as number), - }, - }, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + const year = e === '' ? undefined : (e as number); + setMinAlbumYear(year ?? null); + setMaxAlbumYear(year ?? null); }, 500); const albumArtistListQuery = useQuery( @@ -211,7 +137,6 @@ export const NavidromeAlbumFilters = ({ staleTime: 1000 * 60 * 1, }, query: { - // searchTerm: debouncedSearchTerm, sortBy: AlbumArtistListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0, @@ -229,85 +154,60 @@ export const NavidromeAlbumFilters = ({ })); }, [albumArtistListQuery?.data?.items]); - const handleAlbumArtistFilter = (e: null | string) => { - const updatedFilters = setFilter({ - data: { - _custom: { - ...filter._custom, - navidrome: { - ...filter._custom?.navidrome, - artist_id: e || undefined, - }, - }, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); - }; - const handleTagFilter = debounce((tag: string, e: null | string) => { - const updatedFilters = setFilter({ - customFilters, - data: { - _custom: { - ...filter._custom, - navidrome: { - ...filter._custom?.navidrome, - [tag]: e || undefined, - }, - }, - }, - itemType: LibraryItem.SONG, - key: pageKey, - }) as AlbumListFilter; - - onFilterChange(updatedFilters); + setCustom((prev) => ({ + ...prev, + [tag]: e || undefined, + })); }, 250); + const hasBFR = hasFeature(server, ServerFeature.BFR); + return ( {yesNoUndefinedFilters.map((filter) => ( {filter.label} - + ))} {toggleFilters.map((filter) => ( {filter.label} - + ))} handleYearFilter(e)} /> - {!hasBrf && ( - handleGenresFilter(value !== null ? [value] : null)} - searchable - /> - )} + (e ? setAlbumGenre([e]) : undefined)} + searchable + /> - {hasBrf && ( + {hasBFR && ( (e ? setAlbumGenre(e) : undefined)} searchable /> @@ -316,11 +216,11 @@ export const NavidromeAlbumFilters = ({ setAlbumArtist(e ? [e] : null)} rightSection={albumArtistListQuery.isFetching ? : undefined} searchable /> @@ -332,9 +232,7 @@ export const NavidromeAlbumFilters = ({ i.value === tag.name)?.label || tag.name diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx index 045171882..9ba9e8f24 100644 --- a/src/renderer/features/albums/components/subsonic-album-filters.tsx +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -1,12 +1,14 @@ import { useQuery } from '@tanstack/react-query'; import debounce from 'lodash/debounce'; +import { parseAsArrayOf, parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; import { ChangeEvent, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; -import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store'; +import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; +import { AlbumListFilter } from '/@/renderer/store'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { NumberInput } from '/@/shared/components/number-input/number-input'; @@ -15,30 +17,34 @@ import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; import { Text } from '/@/shared/components/text/text'; -import { - AlbumArtistListSort, - AlbumListQuery, - GenreListSort, - LibraryItem, - SortOrder, -} from '/@/shared/types/domain-types'; +import { AlbumArtistListSort, GenreListSort, SortOrder } from '/@/shared/types/domain-types'; interface SubsonicAlbumFiltersProps { disableArtistFilter?: boolean; onFilterChange: (filters: AlbumListFilter) => void; - pageKey: string; serverId: string; } export const SubsonicAlbumFilters = ({ disableArtistFilter, onFilterChange, - pageKey, serverId, }: SubsonicAlbumFiltersProps) => { const { t } = useTranslation(); - const { filter } = useListStoreByKey({ key: pageKey }); - const { setFilter } = useListStoreActions(); + + const [favorite, setFavorite] = useQueryState(FILTER_KEYS.ALBUM.FAVORITE, parseAsBoolean); + + const [minYear, setMinYear] = useQueryState(FILTER_KEYS.ALBUM.MIN_YEAR, parseAsInteger); + + const [maxYear, setMaxYear] = useQueryState(FILTER_KEYS.ALBUM.MAX_YEAR, parseAsInteger); + + const [genres, setGenres] = useQueryState(FILTER_KEYS.ALBUM.GENRES, parseAsString); + + const [artistIds, setArtistIds] = useQueryState( + FILTER_KEYS.ALBUM.ARTIST_IDS, + parseAsArrayOf(parseAsString), + ); + const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState(''); const albumArtistListQuery = useQuery( @@ -66,14 +72,11 @@ export const SubsonicAlbumFilters = ({ }, [albumArtistListQuery?.data?.items]); const handleAlbumArtistFilter = (e: null | string[]) => { - const updatedFilters = setFilter({ - data: { - artistIds: e?.length ? e : undefined, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + setArtistIds(e ?? null); + const updatedFilters: Partial = { + artistIds: e?.length ? e : undefined, + }; + onFilterChange(updatedFilters as AlbumListFilter); }; const genreListQuery = useQuery( @@ -100,54 +103,41 @@ export const SubsonicAlbumFilters = ({ }, [genreListQuery.data]); const handleGenresFilter = debounce((e: null | string) => { - const updatedFilters = setFilter({ - data: { - genres: e ? [e] : undefined, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - - onFilterChange(updatedFilters); + setGenres(e ?? null); + const updatedFilters: Partial = { + genres: e ? [e] : undefined, + }; + onFilterChange(updatedFilters as AlbumListFilter); }, 250); const toggleFilters = [ { label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), onChange: (e: ChangeEvent) => { - const updatedFilters = setFilter({ - data: { - favorite: e.target.checked ? true : undefined, - }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - onFilterChange(updatedFilters); + const favoriteValue = e.target.checked ? true : undefined; + setFavorite(favoriteValue ?? null); + const updatedFilters: Partial = { + favorite: favoriteValue, + }; + onFilterChange(updatedFilters as AlbumListFilter); }, - value: filter.favorite, + value: favorite, }, ]; const handleYearFilter = debounce((e: number | string, type: 'max' | 'min') => { - let data: Partial = {}; + const year = e ? Number(e) : undefined; if (type === 'min') { - data = { - minYear: e ? Number(e) : undefined, - }; + setMinYear(year ?? null); } else { - data = { - maxYear: e ? Number(e) : undefined, - }; + setMaxYear(year ?? null); } - const updatedFilters = setFilter({ - data, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; - - onFilterChange(updatedFilters); + const updatedFilters: Partial = { + [type === 'min' ? 'minYear' : 'maxYear']: year, + }; + onFilterChange(updatedFilters as AlbumListFilter); }, 500); return ( @@ -161,8 +151,8 @@ export const SubsonicAlbumFilters = ({ handleYearFilter(e, 'min')} /> { + const { sortBy } = useSortByFilter(AlbumListSort.NAME); + + const { sortOrder } = useSortOrderFilter(SortOrder.ASC); + + const { musicFolderId } = useMusicFolderIdFilter(''); + + const { searchTerm, setSearchTerm } = useSearchTermFilter(''); + + const [albumGenre, setAlbumGenre] = useQueryState( + FILTER_KEYS.ALBUM.GENRES, + parseAsArrayOf(parseAsString), + ); + + const [albumArtist, setAlbumArtist] = useQueryState( + FILTER_KEYS.ALBUM.ARTIST_IDS, + parseAsArrayOf(parseAsString), + ); + + const [minAlbumYear, setMinAlbumYear] = useQueryState( + FILTER_KEYS.ALBUM.MIN_YEAR, + parseAsInteger, + ); + + const [maxAlbumYear, setMaxAlbumYear] = useQueryState( + FILTER_KEYS.ALBUM.MAX_YEAR, + parseAsInteger, + ); + + const [albumFavorite, setAlbumFavorite] = useQueryState( + FILTER_KEYS.ALBUM.FAVORITE, + parseAsBoolean, + ); + + const [albumCompilation, setAlbumCompilation] = useQueryState( + FILTER_KEYS.ALBUM.COMPILATION, + parseAsBoolean, + ); + + const [albumHasRating, setAlbumHasRating] = useQueryState( + FILTER_KEYS.ALBUM.HAS_RATING, + parseAsBoolean, + ); + + const [albumRecentlyPlayed, setAlbumRecentlyPlayed] = useQueryState( + FILTER_KEYS.ALBUM.RECENTLY_PLAYED, + parseAsBoolean, + ); + + const [custom, setCustom] = useQueryState( + FILTER_KEYS.ALBUM._CUSTOM, + parseAsJson(customFiltersSchema), + ); + + const query = { + [FILTER_KEYS.ALBUM._CUSTOM]: custom ?? undefined, + [FILTER_KEYS.ALBUM.ARTIST_IDS]: albumArtist ?? undefined, + [FILTER_KEYS.ALBUM.COMPILATION]: albumCompilation ?? undefined, + [FILTER_KEYS.ALBUM.FAVORITE]: albumFavorite ?? undefined, + [FILTER_KEYS.ALBUM.GENRES]: albumGenre ?? undefined, + [FILTER_KEYS.ALBUM.HAS_RATING]: albumHasRating ?? undefined, + [FILTER_KEYS.ALBUM.MAX_YEAR]: maxAlbumYear ?? undefined, + [FILTER_KEYS.ALBUM.MIN_YEAR]: minAlbumYear ?? undefined, + [FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: albumRecentlyPlayed ?? undefined, + [FILTER_KEYS.SHARED.MUSIC_FOLDER_ID]: musicFolderId ?? undefined, + [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, + [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined, + [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined, + }; + + return { + query, + setAlbumArtist, + setAlbumCompilation, + setAlbumFavorite, + setAlbumGenre, + setAlbumHasRating, + setAlbumRecentlyPlayed, + setCustom, + setMaxAlbumYear, + setMinAlbumYear, + setSearchTerm, + }; +}; diff --git a/src/renderer/features/albums/routes/album-list-route.tsx b/src/renderer/features/albums/routes/album-list-route.tsx index 76d2ded61..9c7c10b12 100644 --- a/src/renderer/features/albums/routes/album-list-route.tsx +++ b/src/renderer/features/albums/routes/album-list-route.tsx @@ -1,155 +1,31 @@ -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; -import isEmpty from 'lodash/isEmpty'; -import { useCallback, useMemo, useRef } from 'react'; -import { useParams, useSearchParams } from 'react-router-dom'; - -import { api } from '/@/renderer/api'; -import { queryKeys } from '/@/renderer/api/query-keys'; -import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid'; import { ListContext } from '/@/renderer/context/list-context'; -import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content'; import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header'; -import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; -import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; -import { queryClient } from '/@/renderer/lib/react-query'; -import { useCurrentServer, useListFilterByKey } from '/@/renderer/store'; -import { - AlbumListQuery, - GenreListSort, - LibraryItem, - SortOrder, -} from '/@/shared/types/domain-types'; -import { Play } from '/@/shared/types/types'; const AlbumListRoute = () => { - const gridRef = useRef(null); - const tableRef = useRef(null); - const server = useCurrentServer(); - const [searchParams] = useSearchParams(); const { albumArtistId, genreId } = useParams(); const pageKey = albumArtistId ? `albumArtistAlbum` : 'album'; - const handlePlayQueueAdd = usePlayQueueAdd(); - const customFilters = useMemo(() => { - const value = { - ...(albumArtistId && { artistIds: [albumArtistId] }), - ...(genreId && { - genres: [genreId], - }), - }; - - if (isEmpty(value)) { - return undefined; - } - - return value; - }, [albumArtistId, genreId]); - - const albumListFilter = useListFilterByKey({ - filter: customFilters, - key: pageKey, - }); - - const genreList = useQuery( - genresQueries.list({ - options: { - enabled: !!genreId, - gcTime: 1000 * 60 * 60, - }, - query: { - sortBy: GenreListSort.NAME, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, - serverId: server?.id, - }), - ); - - const genreTitle = useMemo(() => { - if (!genreList.data) return ''; - const genre = genreList.data.items.find((g) => g.id === genreId); - - if (!genre) return 'Unknown'; - - return genre?.name; - }, [genreId, genreList.data]); - - const itemCountCheck = useQuery( - albumQueries.listCount({ - options: { - gcTime: 1000 * 60, - staleTime: 1000 * 60, - }, - query: { - ...albumListFilter, - }, - serverId: server?.id, - }), - ); - - const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data; - - const handlePlay = useCallback( - async (args: { initialSongId?: string; playType: Play }) => { - if (!itemCount || itemCount === 0) return; - const { playType } = args; - const query = { - ...albumListFilter, - ...customFilters, - startIndex: 0, - }; - const queryKey = queryKeys.albums.list(server?.id || '', query); - - const albumListRes = await queryClient.fetchQuery({ - queryFn: ({ signal }) => { - return api.controller.getAlbumList({ - apiClientProps: { serverId: server?.id || '', signal }, - query, - }); - }, - queryKey, - }); - - const albumIds = albumListRes?.items?.map((a) => a.id) || []; - - handlePlayQueueAdd?.({ - byItemType: { - id: albumIds, - type: LibraryItem.ALBUM, - }, - playType, - }); - }, - [albumListFilter, customFilters, handlePlayQueueAdd, itemCount, server], - ); + const [itemCount, setItemCount] = useState(undefined); const providerValue = useMemo(() => { return { - customFilters, - handlePlay, id: albumArtistId ?? genreId, + itemCount, pageKey, + setItemCount, }; - }, [albumArtistId, customFilters, genreId, handlePlay, pageKey]); - - const artist = searchParams.get('artistName'); - const title = artist ? artist : genreId ? genreTitle : undefined; + }, [albumArtistId, genreId, itemCount, pageKey, setItemCount]); return ( - - + + );