Update song list table view

This commit is contained in:
jeffvli 2023-07-16 11:44:33 -07:00
parent f09227d963
commit 1fc5e9a0e8
4 changed files with 162 additions and 268 deletions

View file

@ -402,7 +402,7 @@ export type AlbumDetailQuery = { id: string };
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
// Song List
export type SongListResponse = BasePaginatedResponse<Song[]>;
export type SongListResponse = BasePaginatedResponse<Song[]> | null | undefined;
export enum SongListSort {
ALBUM = 'album',

View file

@ -1,33 +1,15 @@
import { MutableRefObject, useCallback, useMemo } from 'react';
import type {
BodyScrollEvent,
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
RowDoubleClickedEvent,
} from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
useCurrentServer,
useListStoreActions,
useSongListFilter,
useSongListStore,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { AnimatePresence } from 'framer-motion';
import debounce from 'lodash/debounce';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem, QueueSong, SongListQuery } from '/@/renderer/api/types';
import { lazy, MutableRefObject, Suspense } from 'react';
import { Spinner } from '/@/renderer/components';
import { useSongListContext } from '/@/renderer/features/songs/context/song-list-context';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
import { useSongListStore } from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
const SongListTableView = lazy(() =>
import('/@/renderer/features/songs/components/song-list-table-view').then((module) => ({
default: module.SongListTableView,
})),
);
interface SongListContentProps {
itemCount?: number;
@ -35,184 +17,21 @@ interface SongListContentProps {
}
export const SongListContent = ({ itemCount, tableRef }: SongListContentProps) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const { id, pageKey } = useSongListContext();
const { display } = useSongListStore({ id, key: pageKey });
const { id, pageKey, handlePlay } = useSongListContext();
const filter = useSongListFilter({ id, key: pageKey });
const { display, table } = useSongListStore({ id, key: pageKey });
const { setTable, setTablePagination } = useListStoreActions();
const playButtonBehavior = usePlayButtonBehavior();
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
const onGridReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: SongListQuery = {
limit,
startIndex,
...filter,
};
const queryKey = queryKeys.songs.list(server?.id || '', query);
const songsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getSongList({
apiClientProps: {
server,
signal,
},
query,
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
params.api.ensureIndexVisible(table.scrollOffset, 'top');
},
[filter, table.scrollOffset, queryClient, server],
);
const onPaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
try {
// Scroll to top of page on pagination change
const currentPageStartIndex =
table.pagination.currentPage * table.pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) {
console.log(err);
}
setTablePagination({
data: {
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
},
key: pageKey,
});
},
[
isPaginationEnabled,
pageKey,
setTablePagination,
table.pagination.currentPage,
table.pagination.itemsPerPage,
],
);
const handleGridSizeChange = () => {
if (table.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const handleColumnChange = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
const columnsInSettings = table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find(
(c) => c.column === column.getColDef().colId,
);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!table.autoFit && {
width: column.getActualWidth(),
}),
});
}
}
setTable({ data: { columns: updatedColumns }, key: pageKey });
}, [tableRef, table.columns, table.autoFit, setTable, pageKey]);
const debouncedColumnChange = debounce(handleColumnChange, 200);
const handleScroll = (e: BodyScrollEvent) => {
const scrollOffset = Number((e.top / table.rowHeight).toFixed(0));
setTable({ data: { scrollOffset }, key: pageKey });
};
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
handlePlay?.({ initialSongId: e.data.id, playType: playButtonBehavior });
};
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
return (
<Stack
h="100%"
spacing={0}
>
<VirtualGridAutoSizerContainer>
<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-${display}-${table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
suppressRowDrag
autoFitColumns={table.autoFit}
blockLoadDebounceMillis={200}
columnDefs={columnDefs}
getRowId={(data) => data.data.id}
infiniteInitialRowCount={itemCount || 100}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={table.pagination.itemsPerPage || 100}
rowBuffer={20}
rowHeight={table.rowHeight || 40}
rowModelType="infinite"
rowSelection="multiple"
onBodyScrollEnd={handleScroll}
onCellContextMenu={handleContextMenu}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onGridReady={onGridReady}
onGridSizeChanged={handleGridSizeChange}
onPaginationChanged={onPaginationChanged}
onRowDoubleClicked={handleRowDoubleClick}
<Suspense fallback={<Spinner container />}>
{isGrid ? (
<></>
) : (
<SongListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
</VirtualGridAutoSizerContainer>
<AnimatePresence
presenceAffectsLayout
initial={false}
mode="wait"
>
{display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pageKey={pageKey}
pagination={table.pagination}
setPagination={setTablePagination}
tableRef={tableRef}
/>
)}
</AnimatePresence>
</Stack>
)}
</Suspense>
);
};

View file

@ -1,11 +1,9 @@
import { useCallback, useMemo, ChangeEvent, MutableRefObject, MouseEvent } 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 { Divider, Flex, Group, Stack } from '@mantine/core';
import { openModal } from '@mantine/modals';
import {
RiSortAsc,
RiSortDesc,
RiFolder2Line,
RiMoreFill,
RiSettings3Fill,
@ -19,7 +17,7 @@ import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
import { DropdownMenu, Button, Slider, MultiSelect, Switch, Text } from '/@/renderer/components';
import { useMusicFolders } from '/@/renderer/features/shared';
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
import { JellyfinSongFilters } from '/@/renderer/features/songs/components/jellyfin-song-filters';
import { NavidromeSongFilters } from '/@/renderer/features/songs/components/navidrome-song-filters';
import { useContainerQuery } from '/@/renderer/hooks';
@ -79,11 +77,6 @@ const FILTERS = {
],
};
const ORDER = [
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
];
interface SongListHeaderFiltersProps {
tableRef: MutableRefObject<AgGridReactType | null>;
}
@ -106,8 +99,6 @@ export const SongListHeaderFilters = ({ tableRef }: SongListHeaderFiltersProps)
).find((f) => f.value === filter.sortBy)?.name) ||
'Unknown';
const sortOrderLabel = ORDER.find((s) => s.value === filter.sortOrder)?.name;
const handleFilterChange = useCallback(
async (filters?: SongListFilter) => {
const dataSource: IDatasource = {
@ -340,51 +331,56 @@ export const SongListHeaderFilters = ({ tableRef }: SongListHeaderFiltersProps)
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
sortOrder={filter.sortOrder}
onToggle={handleToggleSortOrder}
/>
{server?.type === ServerType.JELLYFIN && (
<>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{cq.isSm ? 'Folder' : <RiFolder2Line size="1.3rem" />}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.items.map((folder) => (
<DropdownMenu.Item
key={`musicFolder-${folder.id}`}
$isActive={filter.musicFolderId === folder.id}
value={folder.id}
onClick={handleSetMusicFolder}
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
</>
)}
<Divider orientation="vertical" />
<Button
compact
fw="600"
size="md"
sx={{
svg: {
fill: isFilterApplied ? 'var(--primary-color) !important' : undefined,
},
}}
tooltip={{ label: 'Filters' }}
variant="subtle"
onClick={handleToggleSortOrder}
onClick={handleOpenFiltersModal}
>
{cq.isSm ? (
sortOrderLabel
) : (
<>
{filter.sortOrder === SortOrder.ASC ? (
<RiSortAsc size="1.3rem" />
) : (
<RiSortDesc size="1.3rem" />
)}
</>
)}
<RiFilterFill size="1.3rem" />
</Button>
{server?.type === ServerType.JELLYFIN && (
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{cq.isSm ? 'Folder' : <RiFolder2Line size="1.3rem" />}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.items.map((folder) => (
<DropdownMenu.Item
key={`musicFolder-${folder.id}`}
$isActive={filter.musicFolderId === folder.id}
value={folder.id}
onClick={handleSetMusicFolder}
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
)}
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
@ -429,20 +425,6 @@ export const SongListHeaderFilters = ({ tableRef }: SongListHeaderFiltersProps)
noWrap
spacing="sm"
>
<Button
compact
size="md"
sx={{
svg: {
fill: isFilterApplied ? 'var(--primary-color) !important' : undefined,
},
}}
tooltip={{ label: 'Filters' }}
variant="subtle"
onClick={handleOpenFiltersModal}
>
<RiFilterFill size="1.3rem" />
</Button>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button

View file

@ -0,0 +1,93 @@
import { RowDoubleClickedEvent } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { MutableRefObject, useCallback } from 'react';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, QueueSong, SongListQuery, SongListResponse } from '/@/renderer/api/types';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { VirtualTable } from '/@/renderer/components/virtual-table';
import {
AgGridFetchFn,
useVirtualTable,
} from '/@/renderer/components/virtual-table/hooks/use-virtual-table';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { useSongListContext } from '/@/renderer/features/songs/context/song-list-context';
import {
useCurrentServer,
useListStoreActions,
usePlayButtonBehavior,
useSongListFilter,
useSongListStore,
} from '/@/renderer/store';
interface SongListTableViewProps {
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const SongListTableView = ({ tableRef, itemCount }: SongListTableViewProps) => {
const server = useCurrentServer();
const { id, pageKey, handlePlay } = useSongListContext();
const filter = useSongListFilter({ id, key: pageKey });
const listProperties = useSongListStore({ id, key: pageKey });
const { setTable, setTablePagination } = useListStoreActions();
const fetchFn: AgGridFetchFn<SongListResponse, Omit<SongListQuery, 'startIndex'>> = useCallback(
async ({ filter, limit, startIndex }, signal) => {
const res = api.controller.getSongList({
apiClientProps: {
server,
signal,
},
query: {
...filter,
limit,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
startIndex,
},
});
return res;
},
[server],
);
const tableProps = useVirtualTable<SongListResponse, Omit<SongListQuery, 'startIndex'>>({
contextMenu: SONG_CONTEXT_MENU_ITEMS,
fetch: {
filter,
fn: fetchFn,
itemCount,
queryKey: queryKeys.albums.list,
server,
},
itemCount,
itemType: LibraryItem.SONG,
pageKey,
properties: listProperties,
setTable,
setTablePagination,
tableRef,
});
const playButtonBehavior = usePlayButtonBehavior();
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
handlePlay?.({ initialSongId: e.data.id, playType: playButtonBehavior });
};
return (
<VirtualGridAutoSizerContainer>
<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}`}
ref={tableRef}
{...tableProps}
onRowDoubleClicked={handleRowDoubleClick}
/>
</VirtualGridAutoSizerContainer>
);
};