Update album list implementation

This commit is contained in:
jeffvli 2023-07-20 00:34:07 -07:00
parent 55937e71db
commit 6dd9333dbb
9 changed files with 311 additions and 589 deletions

View file

@ -2,8 +2,8 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { lazy, MutableRefObject, Suspense } from 'react';
import { Spinner } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
import { useAlbumListStore } from '/@/renderer/store';
import { useListContext } from '/@/renderer/context/list-context';
import { useListStoreByKey } from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
const AlbumListGridView = lazy(() =>
@ -25,8 +25,8 @@ interface AlbumListContentProps {
}
export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListContentProps) => {
const { id, pageKey } = useAlbumListContext();
const { display } = useAlbumListStore({ id, key: pageKey });
const { pageKey } = useListContext();
const { display } = useListStoreByKey({ key: pageKey });
return (
<Suspense fallback={<Spinner container />}>

View file

@ -1,35 +1,35 @@
import { QueryKey, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import AutoSizer 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, AlbumListQuery, AlbumListSort, LibraryItem } from '/@/renderer/api/types';
import { queryKeys, splitPaginatedQuery } from '/@/renderer/api/query-keys';
import {
Album,
AlbumListQuery,
AlbumListResponse,
AlbumListSort,
LibraryItem,
} from '/@/renderer/api/types';
import { ALBUM_CARD_ROWS } from '/@/renderer/components';
import {
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,
} from '/@/renderer/components/virtual-grid';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AppRoute } from '/@/renderer/router/routes';
import {
useAlbumListFilter,
useAlbumListStore,
useCurrentServer,
useListStoreActions,
} from '/@/renderer/store';
import { CardRow, ListDisplayType } from '/@/renderer/types';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
import { CardRow, ListDisplayType } from '/@/renderer/types';
export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd();
const { id, pageKey } = useAlbumListContext();
const { grid, display } = useAlbumListStore({ id, key: pageKey });
const { pageKey, customFilters } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
const { setGrid } = useListStoreActions();
const filter = useAlbumListFilter({ id, key: pageKey });
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
@ -129,27 +129,56 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
[pageKey, setGrid],
);
const fetchInitialData = useCallback(() => {
const query: Omit<AlbumListQuery, 'startIndex' | 'limit'> = {
...filter,
...customFilters,
};
const queriesFromCache: [QueryKey, AlbumListResponse][] = queryClient.getQueriesData({
exact: false,
fetchStatus: 'idle',
queryKey: queryKeys.albums.list(server?.id || '', query),
stale: false,
});
const itemData = [];
for (const [, data] of queriesFromCache) {
const { items, startIndex } = data || {};
if (items && 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, queryClient, server?.id]);
const fetch = useCallback(
async ({ skip, take }: { skip: number; take: number }) => {
if (!server) {
return [];
}
const query: AlbumListQuery = {
const listQuery: AlbumListQuery = {
limit: take,
startIndex: skip,
...filter,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
},
},
...customFilters,
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const { query, pagination } = splitPaginatedQuery(listQuery);
const queryKey = queryKeys.albums.list(server?.id || '', query, pagination);
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
controller.getAlbumList({
@ -157,13 +186,13 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
server,
signal,
},
query,
query: listQuery,
}),
);
return albums;
},
[filter, queryClient, server],
[customFilters, filter, queryClient, server],
);
return (
@ -176,6 +205,7 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
cardRows={cardRows}
display={display || ListDisplayType.CARD}
fetchFn={fetch}
fetchInitialData={fetchInitialData}
handleFavorite={handleFavorite}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}

View file

@ -1,38 +1,36 @@
import { MutableRefObject, useCallback, MouseEvent, ChangeEvent, useMemo } from 'react';
import { IDatasource } from '@ag-grid-community/core';
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 { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import {
RiAddBoxFill,
RiAddCircleFill,
RiFilterFill,
RiFolder2Line,
RiMoreFill,
RiAddBoxFill,
RiPlayFill,
RiAddCircleFill,
RiRefreshLine,
RiSettings3Fill,
RiFilterFill,
} from 'react-icons/ri';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListQuery, AlbumListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { AlbumListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { useContainerQuery } from '/@/renderer/hooks';
import {
AlbumListFilter,
useAlbumListStore,
useCurrentServer,
useListStoreActions,
} from '/@/renderer/store';
import { ServerType, Play, ListDisplayType, TableColumn } from '/@/renderer/types';
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-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 { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
import {
AlbumListFilter,
useCurrentServer,
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
const FILTERS = {
jellyfin: [
@ -77,26 +75,23 @@ const FILTERS = {
};
interface AlbumListHeaderFiltersProps {
customFilters?: Partial<AlbumListFilter>;
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const AlbumListHeaderFilters = ({
customFilters,
gridRef,
tableRef,
itemCount,
}: AlbumListHeaderFiltersProps) => {
export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => {
const queryClient = useQueryClient();
const { id, pageKey } = useAlbumListContext();
const { pageKey, customFilters, handlePlay } = useListContext();
const server = useCurrentServer();
const { setFilter, setTablePagination, setTable, setGrid, setDisplayType } =
useListStoreActions();
const { display, filter, table, grid } = useAlbumListStore({ id, key: pageKey });
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
const { display, filter, table, grid } = useListStoreByKey({ key: pageKey });
const cq = useContainerQuery();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemType: LibraryItem.ALBUM,
server,
});
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
const sortByLabel =
@ -107,123 +102,21 @@ export const AlbumListHeaderFilters = ({
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
const fetch = useCallback(
async (skip: number, take: number, filters: AlbumListFilter) => {
const query: AlbumListQuery = {
limit: take,
startIndex: skip,
...filters,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
...customFilters,
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albums = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return albums;
},
[customFilters, queryClient, server],
);
const handleFilterChange = useCallback(
async (filters: AlbumListFilter) => {
const onFilterChange = useCallback(
(filter: AlbumListFilter) => {
if (isGrid) {
gridRef.current?.scrollTo(0);
gridRef.current?.resetLoadMoreItemsCache();
// Refetching within the virtualized grid may be inconsistent due to it refetching
// using an outdated set of filters. To avoid this, we fetch using the updated filters
// and then set the grid's data here.
const data = await fetch(0, 200, filters);
if (!data?.items) return;
gridRef.current?.setItemData(data.items);
} else {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: AlbumListQuery = {
limit,
startIndex,
...filters,
...customFilters,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return params.successCallback(
albumsRes?.items || [],
albumsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
if (display === ListDisplayType.TABLE_PAGINATED) {
setTablePagination({ data: { currentPage: 0 }, key: 'album' });
}
handleRefreshGrid(gridRef, {
...filter,
...customFilters,
});
}
handleRefreshTable(tableRef, {
...filter,
...customFilters,
});
},
[
isGrid,
gridRef,
fetch,
tableRef,
display,
customFilters,
server,
queryClient,
setTablePagination,
],
[customFilters, gridRef, handleRefreshGrid, handleRefreshTable, isGrid, tableRef],
);
const handleOpenFiltersModal = () => {
@ -232,19 +125,19 @@ export const AlbumListHeaderFilters = ({
<>
{server?.type === ServerType.NAVIDROME ? (
<NavidromeAlbumFilters
customFilters={customFilters}
disableArtistFilter={!!customFilters}
handleFilterChange={handleFilterChange}
id={id}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
) : (
<JellyfinAlbumFilters
customFilters={customFilters}
disableArtistFilter={!!customFilters}
handleFilterChange={handleFilterChange}
id={id}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
)}
</>
@ -255,8 +148,8 @@ export const AlbumListHeaderFilters = ({
const handleRefresh = useCallback(() => {
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
handleFilterChange(filter);
}, [filter, handleFilterChange, queryClient, server?.id]);
onFilterChange(filter);
}, [filter, onFilterChange, queryClient, server?.id]);
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
@ -267,17 +160,18 @@ export const AlbumListHeaderFilters = ({
)?.defaultOrder;
const updatedFilters = setFilter({
customFilters,
data: {
sortBy: e.currentTarget.value as AlbumListSort,
sortOrder: sortOrder || SortOrder.ASC,
},
itemType: LibraryItem.ALBUM,
key: 'album',
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
},
[handleFilterChange, server?.type, setFilter],
[customFilters, onFilterChange, pageKey, server?.type, setFilter],
);
const handleSetMusicFolder = useCallback(
@ -287,86 +181,50 @@ export const AlbumListHeaderFilters = ({
let updatedFilters = null;
if (e.currentTarget.value === String(filter.musicFolderId)) {
updatedFilters = setFilter({
customFilters,
data: { musicFolderId: undefined },
itemType: LibraryItem.ALBUM,
key: 'album',
key: pageKey,
}) as AlbumListFilter;
} else {
updatedFilters = setFilter({
customFilters,
data: { musicFolderId: e.currentTarget.value },
itemType: LibraryItem.ALBUM,
key: 'album',
key: pageKey,
}) as AlbumListFilter;
}
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
},
[handleFilterChange, filter.musicFolderId, setFilter],
[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: 'album',
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, [filter.sortOrder, handleFilterChange, setFilter]);
const handlePlayQueueAdd = usePlayQueueAdd();
const handlePlay = async (playType: Play) => {
if (!itemCount || itemCount === 0 || !server) return;
const query = {
startIndex: 0,
...filter,
...customFilters,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumListRes = await queryClient.fetchQuery({
queryFn: ({ signal }) =>
api.controller.getAlbumList({ apiClientProps: { server, signal }, query }),
queryKey,
});
const albumIds = albumListRes?.items?.map((a) => a.id) || [];
handlePlayQueueAdd?.({
byItemType: {
id: albumIds,
type: LibraryItem.ALBUM,
},
playType,
});
};
onFilterChange(updatedFilters);
}, [customFilters, filter.sortOrder, onFilterChange, pageKey, setFilter]);
const handleItemSize = (e: number) => {
if (isGrid) {
setGrid({ data: { itemsPerRow: e }, key: 'album' });
setGrid({ data: { itemsPerRow: e }, key: pageKey });
} else {
setTable({ data: { rowHeight: e }, key: 'album' });
setTable({ data: { rowHeight: e }, key: pageKey });
}
};
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: 'album' });
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey });
},
[setDisplayType],
[pageKey, setDisplayType],
);
const handleTableColumns = (values: TableColumn[]) => {
@ -375,7 +233,7 @@ export const AlbumListHeaderFilters = ({
if (values.length === 0) {
return setTable({
data: { columns: [] },
key: 'album',
key: pageKey,
});
}
@ -383,20 +241,20 @@ export const AlbumListHeaderFilters = ({
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
setTable({ data: { columns: [...existingColumns, newColumn] }, key: 'album' });
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: 'album' });
setTable({ data: { columns: newColumns }, key: pageKey });
}
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ data: { autoFit: e.currentTarget.checked }, key: 'album' });
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
@ -511,19 +369,19 @@ export const AlbumListHeaderFilters = ({
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiPlayFill />}
onClick={() => handlePlay(Play.NOW)}
onClick={() => handlePlay?.({ playType: Play.NOW })}
>
Play
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay(Play.LAST)}
onClick={() => handlePlay?.({ playType: Play.LAST })}
>
Add to queue
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay(Play.NEXT)}
onClick={() => handlePlay?.({ playType: Play.NEXT })}
>
Add to queue next
</DropdownMenu.Item>

View file

@ -1,214 +1,60 @@
import type { ChangeEvent, MutableRefObject } from 'react';
import { useCallback } from 'react';
import { 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 { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { api } from '/@/renderer/api';
import { controller } from '/@/renderer/api/controller';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
import type { ChangeEvent, MutableRefObject } from 'react';
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
import { LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import {
AlbumListFilter,
useAlbumListFilter,
useAlbumListStore,
useCurrentServer,
useListStoreActions,
useListStoreByKey,
usePlayButtonBehavior,
} from '/@/renderer/store';
import { ListDisplayType, Play } from '/@/renderer/types';
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListDisplayType } from '/@/renderer/types';
interface AlbumListHeaderProps {
customFilters?: Partial<AlbumListFilter>;
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
title?: string;
}
export const AlbumListHeader = ({
itemCount,
gridRef,
tableRef,
title,
customFilters,
}: AlbumListHeaderProps) => {
const queryClient = useQueryClient();
export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumListHeaderProps) => {
const server = useCurrentServer();
const { setFilter, setTablePagination } = useListStoreActions();
const cq = useContainerQuery();
const { id, pageKey } = useAlbumListContext();
const { display } = useAlbumListStore({ id, key: pageKey });
const filter = useAlbumListFilter({ id, key: pageKey });
const { pageKey, handlePlay } = useListContext();
const { display, filter } = useListStoreByKey({ key: pageKey });
const playButtonBehavior = usePlayButtonBehavior();
const fetch = useCallback(
async (skip: number, take: number, filters: AlbumListFilter) => {
const query: AlbumListQuery = {
limit: take,
startIndex: skip,
...filters,
...customFilters,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albums = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return albums;
},
[customFilters, queryClient, server],
);
const handleFilterChange = useCallback(
async (filters: AlbumListFilter) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: AlbumListQuery = {
limit,
startIndex,
...filters,
...customFilters,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
albumsRes?.items || [],
albumsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
if (display === ListDisplayType.TABLE_PAGINATED) {
setTablePagination({ data: { currentPage: 0 }, key: 'album' });
}
} else {
gridRef.current?.scrollTo(0);
gridRef.current?.resetLoadMoreItemsCache();
// Refetching within the virtualized grid may be inconsistent due to it refetching
// using an outdated set of filters. To avoid this, we fetch using the updated filters
// and then set the grid's data here.
const data = await fetch(0, 200, filters);
if (!data?.items) return;
gridRef.current?.setItemData(data.items);
}
},
[display, tableRef, customFilters, server, queryClient, setTablePagination, gridRef, fetch],
);
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemType: LibraryItem.ALBUM,
server,
});
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const previousSearchTerm = filter.searchTerm;
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.ALBUM,
key: 'album',
key: pageKey,
}) as AlbumListFilter;
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
handleRefreshTable(tableRef, updatedFilters);
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
} else {
handleRefreshGrid(gridRef, updatedFilters);
}
}, 500);
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (playType: Play) => {
if (!itemCount || itemCount === 0) return;
const query = {
startIndex: 0,
...filter,
...customFilters,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumListRes = await queryClient.fetchQuery({
queryFn: ({ signal }) =>
api.controller.getAlbumList({ apiClientProps: { server, signal }, query }),
queryKey,
});
const albumIds = albumListRes?.items?.map((item) => item.id) || [];
handlePlayQueueAdd?.({
byItemType: {
id: albumIds,
type: LibraryItem.ALBUM,
},
playType,
});
};
return (
<Stack
ref={cq.ref}
@ -221,7 +67,7 @@ export const AlbumListHeader = ({
>
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton
onClick={() => handlePlay(playButtonBehavior)}
onClick={() => handlePlay?.({ playType: playButtonBehavior })}
/>
<LibraryHeaderBar.Title>{title || 'Albums'}</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
@ -241,9 +87,7 @@ export const AlbumListHeader = ({
</PageHeader>
<FilterBar>
<AlbumListHeaderFilters
customFilters={customFilters}
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</FilterBar>

View file

@ -1,68 +1,22 @@
import { useCallback } from 'react';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListQuery, AlbumListResponse, LibraryItem } from '/@/renderer/api/types';
import { VirtualTable } from '/@/renderer/components/virtual-table';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
import {
useCurrentServer,
useAlbumListFilter,
useListStoreActions,
useAlbumListStore,
} from '/@/renderer/store';
import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { useVirtualTable } from '../../../components/virtual-table/hooks/use-virtual-table';
import { LibraryItem } from '/@/renderer/api/types';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import {
useVirtualTable,
AgGridFetchFn,
} from '../../../components/virtual-table/hooks/use-virtual-table';
import { VirtualTable } from '/@/renderer/components/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';
export const AlbumListTableView = ({ tableRef, itemCount }: any) => {
const server = useCurrentServer();
const { id, pageKey } = useAlbumListContext();
const filter = useAlbumListFilter({ id, key: pageKey });
const { setTable, setTablePagination } = useListStoreActions();
const listProperties = useAlbumListStore({ id, key: pageKey });
const { pageKey, customFilters } = useListContext();
const fetchFn: AgGridFetchFn<
AlbumListResponse,
Omit<AlbumListQuery, 'startIndex'>
> = useCallback(
async ({ filter, limit, startIndex }, signal) => {
const res = api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query: {
...filter,
limit,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
startIndex,
},
});
return res;
},
[server],
);
const tableProps = useVirtualTable<AlbumListResponse, Omit<AlbumListQuery, 'startIndex'>>({
const tableProps = useVirtualTable({
contextMenu: ALBUM_CONTEXT_MENU_ITEMS,
fetch: {
filter,
fn: fetchFn,
itemCount,
queryKey: queryKeys.albums.list,
server,
},
customFilters,
itemCount,
itemType: LibraryItem.ALBUM,
pageKey,
properties: listProperties,
setTable,
setTablePagination,
server,
tableRef,
});
@ -71,7 +25,7 @@ export const AlbumListTableView = ({ tableRef, itemCount }: any) => {
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${listProperties.display}-${listProperties.table.rowHeight}-${server?.id}`}
key={`table-${tableProps.rowHeight}-${server?.id}`}
ref={tableRef}
{...tableProps}
/>

View file

@ -1,28 +1,29 @@
import { ChangeEvent, useMemo, useState } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
import { AlbumListFilter, useAlbumListFilter, useListStoreActions } from '/@/renderer/store';
import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
import { ChangeEvent, useMemo, useState } from 'react';
import { useListFilterByKey } from '../../../store/list.store';
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { useGenreList } from '/@/renderer/features/genres';
import { AlbumListFilter, useListStoreActions } from '/@/renderer/store';
interface JellyfinAlbumFiltersProps {
customFilters?: Partial<AlbumListFilter>;
disableArtistFilter?: boolean;
handleFilterChange: (filters: AlbumListFilter) => void;
id?: string;
onFilterChange: (filters: AlbumListFilter) => void;
pageKey: string;
serverId?: string;
}
export const JellyfinAlbumFilters = ({
customFilters,
disableArtistFilter,
handleFilterChange,
onFilterChange,
pageKey,
id,
serverId,
}: JellyfinAlbumFiltersProps) => {
const filter = useAlbumListFilter({ id, key: pageKey });
const filter = useListFilterByKey({ key: pageKey });
const { setFilter } = useListStoreActions();
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
@ -45,6 +46,7 @@ export const JellyfinAlbumFilters = ({
label: 'Is favorited',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
@ -57,7 +59,7 @@ export const JellyfinAlbumFilters = ({
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
},
value: filter._custom?.jellyfin?.IsFavorite,
},
@ -66,6 +68,7 @@ export const JellyfinAlbumFilters = ({
const handleMinYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
@ -78,12 +81,13 @@ export const JellyfinAlbumFilters = ({
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
}, 500);
const handleMaxYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
@ -96,12 +100,13 @@ export const JellyfinAlbumFilters = ({
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
}, 500);
const handleGenresFilter = debounce((e: string[] | undefined) => {
const genreFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
@ -114,7 +119,7 @@ export const JellyfinAlbumFilters = ({
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
}, 250);
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
@ -144,6 +149,7 @@ export const JellyfinAlbumFilters = ({
const handleAlbumArtistFilter = (e: string[] | null) => {
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
@ -156,7 +162,7 @@ export const JellyfinAlbumFilters = ({
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
};
return (

View file

@ -1,28 +1,28 @@
import { ChangeEvent, useMemo, useState } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import { NumberInput, Switch, Text, Select, SpinnerIcon } from '/@/renderer/components';
import { AlbumListFilter, useAlbumListFilter, useListStoreActions } from '/@/renderer/store';
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
interface NavidromeAlbumFiltersProps {
customFilters?: Partial<AlbumListFilter>;
disableArtistFilter?: boolean;
handleFilterChange: (filters: AlbumListFilter) => void;
id?: string;
onFilterChange: (filters: AlbumListFilter) => void;
pageKey: string;
serverId?: string;
}
export const NavidromeAlbumFilters = ({
handleFilterChange,
customFilters,
onFilterChange,
disableArtistFilter,
pageKey,
id,
serverId,
}: NavidromeAlbumFiltersProps) => {
const filter = useAlbumListFilter({ id, key: pageKey });
const { filter } = useListStoreByKey({ key: pageKey });
const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList({ query: null, serverId });
@ -37,6 +37,7 @@ export const NavidromeAlbumFilters = ({
const handleGenresFilter = debounce((e: string | null) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
@ -47,9 +48,9 @@ export const NavidromeAlbumFilters = ({
},
},
itemType: LibraryItem.ALBUM,
key: 'album',
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
@ -57,6 +58,7 @@ export const NavidromeAlbumFilters = ({
label: 'Is rated',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
@ -69,7 +71,7 @@ export const NavidromeAlbumFilters = ({
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.has_rating,
},
@ -77,6 +79,7 @@ export const NavidromeAlbumFilters = ({
label: 'Is favorited',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
@ -89,7 +92,7 @@ export const NavidromeAlbumFilters = ({
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.starred,
},
@ -97,6 +100,7 @@ export const NavidromeAlbumFilters = ({
label: 'Is compilation',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
@ -109,7 +113,7 @@ export const NavidromeAlbumFilters = ({
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.compilation,
},
@ -117,6 +121,7 @@ export const NavidromeAlbumFilters = ({
label: 'Is recently played',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
@ -129,7 +134,7 @@ export const NavidromeAlbumFilters = ({
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.recently_played,
},
@ -137,6 +142,7 @@ export const NavidromeAlbumFilters = ({
const handleYearFilter = debounce((e: number | string) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
navidrome: {
@ -149,7 +155,7 @@ export const NavidromeAlbumFilters = ({
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
}, 500);
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
@ -191,7 +197,7 @@ export const NavidromeAlbumFilters = ({
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
onFilterChange(updatedFilters);
};
return (

View file

@ -1,28 +1,39 @@
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { AnimatedPage } from '/@/renderer/features/shared';
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
import { useRef } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import { generatePageKey, useAlbumListFilter, useCurrentServer } from '/@/renderer/store';
import { useCallback, useMemo, useRef } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { AlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem } from '/@/renderer/api/types';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListContext } from '/@/renderer/context/list-context';
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AnimatedPage } from '/@/renderer/features/shared';
import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
import { Play } from '/@/renderer/types';
const AlbumListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const server = useCurrentServer();
const [searchParams] = useSearchParams();
const { albumArtistId } = useParams();
const pageKey = albumArtistId ? `albumArtistAlbum` : 'album';
const handlePlayQueueAdd = usePlayQueueAdd();
const pageKey = generatePageKey(
'album',
albumArtistId ? `${albumArtistId}_${server?.id}` : undefined,
);
const customFilters = useMemo(() => {
return {
...(albumArtistId && { artistIds: [albumArtistId] }),
};
}, [albumArtistId]);
const albumListFilter = useAlbumListFilter({ id: albumArtistId || undefined, key: pageKey });
const albumListFilter = useListFilterByKey({
filter: customFilters,
key: pageKey,
});
const itemCountCheck = useAlbumList({
options: {
@ -42,9 +53,43 @@ const AlbumListRoute = () => {
? undefined
: itemCountCheck.data?.totalRecordCount;
const handlePlay = useCallback(
async (args: { initialSongId?: string; playType: Play }) => {
if (!itemCount || itemCount === 0) return;
const { playType } = args;
const query = {
startIndex: 0,
...albumListFilter,
...customFilters,
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumListRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => {
return api.controller.getAlbumList({
apiClientProps: { server, signal },
query,
});
},
queryKey,
});
const albumIds = albumListRes?.items?.map((a) => a.id) || [];
handlePlayQueueAdd?.({
byItemType: {
id: albumIds,
type: LibraryItem.ALBUM,
},
playType,
});
},
[albumListFilter, customFilters, handlePlayQueueAdd, itemCount, server],
);
return (
<AnimatedPage>
<AlbumListContext.Provider value={{ id: albumArtistId || undefined, pageKey }}>
<ListContext.Provider value={{ customFilters, handlePlay, id: albumArtistId, pageKey }}>
<AlbumListHeader
gridRef={gridRef}
itemCount={itemCount}
@ -56,7 +101,7 @@ const AlbumListRoute = () => {
itemCount={itemCount}
tableRef={tableRef}
/>
</AlbumListContext.Provider>
</ListContext.Provider>
</AnimatedPage>
);
};

View file

@ -55,6 +55,7 @@ export interface ListState {
item: {
album: ListItemProps<AlbumListFilter>;
albumArtist: ListItemProps<AlbumArtistListFilter>;
albumArtistAlbum: ListItemProps<AlbumListFilter>;
albumArtistSong: ListItemProps<SongListFilter>;
albumDetail: ListItemProps<any>;
playlist: ListItemProps<PlaylistListFilter>;
@ -380,6 +381,47 @@ export const useListStore = create<ListSlice>()(
scrollOffset: 0,
},
},
albumArtistAlbum: {
display: ListDisplayType.POSTER,
filter: {
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.DESC,
},
grid: { itemsPerRow: 5, scrollOffset: 0 },
table: {
autoFit: true,
columns: [
{
column: TableColumn.ROW_INDEX,
width: 50,
},
{
column: TableColumn.TITLE_COMBINED,
width: 500,
},
{
column: TableColumn.DURATION,
width: 100,
},
{
column: TableColumn.ALBUM_ARTIST,
width: 300,
},
{
column: TableColumn.YEAR,
width: 100,
},
],
pagination: {
currentPage: 1,
itemsPerPage: 100,
totalItems: 1,
totalPages: 1,
},
rowHeight: 60,
scrollOffset: 0,
},
},
albumArtistSong: {
display: ListDisplayType.TABLE,
filter: {
@ -553,69 +595,6 @@ export const useListFilterByKey = <TFilter>(args: { filter?: Partial<TFilter>; k
);
};
export const useAlbumListStore = (args?: { id?: string; key?: string }) =>
useListStore((state) => {
const detail = args?.key ? state.detail[args.key] : undefined;
return {
...state.item.album,
filter: {
...state.item.album.filter,
...detail?.filter,
},
grid: {
...state.item.album.grid,
...detail?.grid,
},
table: {
...state.item.album.table,
...detail?.table,
},
};
}, shallow);
export const useSongListStore = (args?: { id?: string; key?: string }) =>
useListStore((state) => {
const detail = args?.key ? state.detail[args.key] : undefined;
return {
...state.item.song,
filter: {
...state.item.song.filter,
...detail?.filter,
},
grid: {
...state.item.song.grid,
...detail?.grid,
},
table: {
...state.item.song.table,
...detail?.table,
},
};
}, shallow);
export const usePlaylistListStore = (args?: { key?: string }) =>
useListStore((state) => {
const detail = args?.key ? state.detail[args.key] : undefined;
return {
...state.item.playlist,
filter: {
...state.item.playlist.filter,
...detail?.filter,
},
grid: {
...state.item.playlist.grid,
...detail?.grid,
},
table: {
...state.item.playlist.table,
...detail?.table,
},
};
}, shallow);
export const useAlbumListFilter = (args: { id?: string; key?: string }) =>
useListStore((state) => {
return state._actions.getFilter({