Add song list functionality

This commit is contained in:
jeffvli 2022-12-27 13:52:50 -08:00
parent c7f588539d
commit 8a42a1bc6c
7 changed files with 851 additions and 322 deletions

View file

@ -0,0 +1,133 @@
import { ChangeEvent, useMemo } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
import { SongListFilter, useSetSongFilters, useSongListStore } from '/@/renderer/store';
import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
interface JellyfinSongFiltersProps {
handleFilterChange: (filters: SongListFilter) => void;
}
export const JellyfinSongFilters = ({ handleFilterChange }: JellyfinSongFiltersProps) => {
const { filter } = useSongListStore();
const setFilters = useSetSongFilters();
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
const genreListQuery = useGenreList(null);
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const selectedGenres = useMemo(() => {
return filter.jfParams?.genreIds?.split(',');
}, [filter.jfParams?.genreIds]);
const toggleFilters = [
{
label: 'Is favorited',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilters({
jfParams: {
...filter.jfParams,
includeItemTypes: 'Audio',
isFavorite: e.currentTarget.checked ? true : undefined,
},
});
handleFilterChange(updatedFilters);
},
value: filter.jfParams?.isFavorite,
},
];
const handleMinYearFilter = debounce((e: number | undefined) => {
if (e && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilters({
jfParams: {
...filter.jfParams,
includeItemTypes: 'Audio',
minYear: e,
},
});
handleFilterChange(updatedFilters);
}, 500);
const handleMaxYearFilter = debounce((e: number | undefined) => {
if (e && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilters({
jfParams: {
...filter.jfParams,
includeItemTypes: 'Audio',
maxYear: e,
},
});
handleFilterChange(updatedFilters);
}, 500);
const handleGenresFilter = debounce((e: string[] | undefined) => {
const genreFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilters({
jfParams: {
...filter.jfParams,
genreIds: genreFilterString,
includeItemTypes: 'Audio',
},
});
handleFilterChange(updatedFilters);
}, 250);
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`nd-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group position="apart">
<Text>Year range</Text>
<Group>
<NumberInput
required
max={2300}
min={1700}
value={filter.jfParams?.minYear}
width={60}
onChange={handleMinYearFilter}
/>
<NumberInput
max={2300}
min={1700}
value={filter.jfParams?.maxYear}
width={60}
onChange={handleMaxYearFilter}
/>
</Group>
</Group>
<Divider my="0.5rem" />
<Stack>
<Text>Genres</Text>
<MultiSelect
clearable
searchable
data={genreList}
defaultValue={selectedGenres}
width={250}
onChange={handleGenresFilter}
/>
</Stack>
</Stack>
);
};

View file

@ -0,0 +1,68 @@
import { ChangeEvent } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import { NumberInput, Switch, Text } from '/@/renderer/components';
import { SongListFilter, useSetSongFilters, useSongListStore } from '/@/renderer/store';
import debounce from 'lodash/debounce';
interface NavidromeSongFiltersProps {
handleFilterChange: (filters: SongListFilter) => void;
}
export const NavidromeSongFilters = ({ handleFilterChange }: NavidromeSongFiltersProps) => {
const { filter } = useSongListStore();
const setFilters = useSetSongFilters();
const toggleFilters = [
{
label: 'Is favorited',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilters({
ndParams: { ...filter.ndParams, starred: e.currentTarget.checked ? true : undefined },
});
handleFilterChange(updatedFilters);
},
value: filter.ndParams?.starred,
},
];
const handleYearFilter = debounce((e: number | undefined) => {
const updatedFilters = setFilters({
ndParams: {
...filter.ndParams,
year: e,
},
});
handleFilterChange(updatedFilters);
}, 500);
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`nd-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
size="xs"
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group position="apart">
<Text>Year</Text>
<NumberInput
max={5000}
min={0}
size="xs"
value={filter.ndParams?.year}
width={50}
onChange={handleYearFilter}
/>
</Group>
</Stack>
);
};

View file

@ -0,0 +1,207 @@
import { MutableRefObject, useCallback, useMemo } from 'react';
import type {
BodyScrollEvent,
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
} 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 {
getColumnDefs,
TablePagination,
VirtualGridAutoSizerContainer,
VirtualTable,
} from '/@/renderer/components';
import { useSongList } from '/@/renderer/features/songs/queries/song-list-query';
import {
useCurrentServer,
useSetSongTable,
useSetSongTablePagination,
useSongListStore,
useSongTablePagination,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { AnimatePresence } from 'framer-motion';
import debounce from 'lodash/debounce';
interface SongListContentProps {
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const SongListContent = ({ tableRef }: SongListContentProps) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const page = useSongListStore();
const pagination = useSongTablePagination();
const setPagination = useSetSongTablePagination();
const setTable = useSetSongTable();
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const checkSongList = useSongList({
limit: 1,
startIndex: 0,
...page.filter,
});
const columnDefs = useMemo(() => getColumnDefs(page.table.columns), [page.table.columns]);
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
};
}, []);
const onGridReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.songs.list(server?.id || '', {
limit,
startIndex,
...page.filter,
});
const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
api.controller.getSongList({
query: {
limit,
startIndex,
...page.filter,
},
server,
signal,
}),
);
const songs = api.normalize.songList(songsRes, server);
params.successCallback(songs?.items || [], songsRes?.totalRecordCount);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
params.api.ensureIndexVisible(page.table.scrollOffset || 0, 'top');
},
[page.filter, page.table.scrollOffset, queryClient, server],
);
const onPaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
// Scroll to top of page on pagination change
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
setPagination({
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
});
},
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
);
const handleGridSizeChange = () => {
if (page.table.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const handleColumnMove = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
const columnsInSettings = page.table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!page.table.autoFit && {
width: column.getColDef().width,
}),
});
}
}
setTable({ columns: updatedColumns });
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
const handleScroll = debounce((e: BodyScrollEvent) => {
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
setTable({ scrollOffset });
}, 200);
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-${page.display}-${page.table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
animateRows
maintainColumnOrder
suppressCopyRowsToClipboard
suppressMoveWhenRowDragging
suppressPaginationPanel
suppressRowDrag
suppressScrollOnNewData
blockLoadDebounceMillis={200}
cacheBlockSize={500}
cacheOverflowSize={1}
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
infiniteInitialRowCount={checkSongList.data?.totalRecordCount || 10}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
rowBuffer={20}
rowHeight={page.table.rowHeight || 40}
rowModelType="infinite"
rowSelection="multiple"
// onBodyScroll={handleScroll}
onBodyScrollEnd={handleScroll}
onCellContextMenu={(e) => console.log('context', e)}
onColumnMoved={handleColumnMove}
onGridReady={onGridReady}
onGridSizeChanged={handleGridSizeChange}
onPaginationChanged={onPaginationChanged}
/>
</VirtualGridAutoSizerContainer>
<AnimatePresence
presenceAffectsLayout
mode="wait"
>
{page.display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pagination={pagination}
setPagination={setPagination}
tableRef={tableRef}
/>
)}
</AnimatePresence>
</Stack>
);
};

View file

@ -1,40 +1,76 @@
import type { MouseEvent } from 'react';
import { useCallback } from 'react';
import { Group } from '@mantine/core';
import { Button, Slider, PageHeader, DropdownMenu } from '/@/renderer/components';
import throttle from 'lodash/throttle';
import { RiArrowDownSLine } from 'react-icons/ri';
import { SongListSort, SortOrder } from '/@/renderer/api/types';
import { useCurrentServer, useAppStoreActions, useSongRouteStore } from '/@/renderer/store';
import { CardDisplayType } from '/@/renderer/types';
import type { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import {
RiArrowDownSLine,
RiFilter3Line,
RiFolder2Line,
RiMoreFill,
RiSortAsc,
RiSortDesc,
} from 'react-icons/ri';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { ServerType, SongListSort, SortOrder } from '/@/renderer/api/types';
import {
Button,
DropdownMenu,
PageHeader,
SearchInput,
Slider,
TextTitle,
Switch,
MultiSelect,
Text,
SONG_TABLE_COLUMNS,
} from '/@/renderer/components';
import { 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';
import { queryClient } from '/@/renderer/lib/react-query';
import {
SongListFilter,
useCurrentServer,
useSetSongFilters,
useSetSongStore,
useSetSongTable,
useSetSongTablePagination,
useSongListStore,
} from '/@/renderer/store';
import { ListDisplayType, TableColumn } from '/@/renderer/types';
const FILTERS = {
jellyfin: [
{ name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
{ name: 'Artist', value: SongListSort.ARTIST },
{ name: 'Duration', value: SongListSort.DURATION },
{ name: 'Name', value: SongListSort.NAME },
{ name: 'Name', value: SongListSort.PLAY_COUNT },
{ name: 'Random', value: SongListSort.RANDOM },
{ name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
{ name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
{ name: 'Release Date', value: SongListSort.RELEASE_DATE },
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Duration', value: SongListSort.DURATION },
{ defaultOrder: SortOrder.ASC, name: 'Most Played', value: SongListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: SongListSort.RANDOM },
{ defaultOrder: SortOrder.ASC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
{ defaultOrder: SortOrder.ASC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
{ defaultOrder: SortOrder.ASC, name: 'Release Date', value: SongListSort.RELEASE_DATE },
],
navidrome: [
{ name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
{ name: 'Artist', value: SongListSort.ARTIST },
{ name: 'BPM', value: SongListSort.BPM },
{ name: 'Channels', value: SongListSort.CHANNELS },
{ name: 'Comment', value: SongListSort.COMMENT },
{ name: 'Duration', value: SongListSort.DURATION },
{ name: 'Favorited', value: SongListSort.FAVORITED },
{ name: 'Genre', value: SongListSort.GENRE },
{ name: 'Name', value: SongListSort.NAME },
{ name: 'Play Count', value: SongListSort.PLAY_COUNT },
{ name: 'Rating', value: SongListSort.RATING },
{ name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
{ name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
{ name: 'Year', value: SongListSort.YEAR },
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
{ defaultOrder: SortOrder.DESC, name: 'BPM', value: SongListSort.BPM },
{ defaultOrder: SortOrder.ASC, name: 'Channels', value: SongListSort.CHANNELS },
{ defaultOrder: SortOrder.ASC, name: 'Comment', value: SongListSort.COMMENT },
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: SongListSort.DURATION },
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: SongListSort.FAVORITED },
{ defaultOrder: SortOrder.ASC, name: 'Genre', value: SongListSort.GENRE },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
{ defaultOrder: SortOrder.DESC, name: 'Play Count', value: SongListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: SongListSort.RATING },
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
{ defaultOrder: SortOrder.DESC, name: 'Year', value: SongListSort.YEAR },
],
};
@ -43,219 +79,363 @@ const ORDER = [
{ name: 'Descending', value: SortOrder.DESC },
];
export const SongListHeader = () => {
interface SongListHeaderProps {
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const SongListHeader = ({ tableRef }: SongListHeaderProps) => {
const server = useCurrentServer();
const { setPage } = useAppStoreActions();
const page = useSongRouteStore();
const filters = page.list.filter;
const page = useSongListStore();
const setPage = useSetSongStore();
const setFilter = useSetSongFilters();
const setTable = useSetSongTable();
const cq = useContainerQuery();
const musicFoldersQuery = useMusicFolders();
const sortByLabel =
(server?.type &&
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
(f) => f.value === filters.sortBy,
(f) => f.value === page.filter.sortBy,
)?.name) ||
'Unknown';
const sortOrderLabel = ORDER.find((s) => s.value === filters.sortOrder)?.name;
const sortOrderLabel = ORDER.find((s) => s.value === page.filter.sortOrder)?.name;
const setSize = throttle(
(e: number) =>
setPage('songs', {
...page,
list: { ...page.list, size: e },
}),
200,
const handleFilterChange = useCallback(
async (filters?: SongListFilter) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const pageFilters = filters || page.filter;
const queryKey = queryKeys.songs.list(server?.id || '', {
limit,
startIndex,
...pageFilters,
});
const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
api.controller.getSongList({
query: {
limit,
startIndex,
...pageFilters,
},
server,
signal,
}),
);
const songs = api.normalize.songList(songsRes, server);
params.successCallback(songs?.items || [], songsRes?.totalRecordCount);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
},
[page.filter, server, tableRef],
);
const handleSetFilter = useCallback(
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
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({
sortBy: e.currentTarget.value as SongListSort,
sortOrder: sortOrder || SortOrder.ASC,
});
handleFilterChange(updatedFilters);
},
[handleFilterChange, server?.type, setFilter],
);
const handleSetMusicFolder = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setPage('songs', {
list: {
...page.list,
filter: {
...page.list.filter,
sortBy: e.currentTarget.value as SongListSort,
},
},
});
let updatedFilters = null;
if (e.currentTarget.value === String(page.filter.musicFolderId)) {
updatedFilters = setFilter({ musicFolderId: undefined });
} else {
updatedFilters = setFilter({ musicFolderId: e.currentTarget.value });
}
handleFilterChange(updatedFilters);
},
[page.list, setPage],
[handleFilterChange, page.filter.musicFolderId, setFilter],
);
const handleSetOrder = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setPage('songs', {
list: {
...page.list,
filter: {
...page.list.filter,
sortOrder: e.currentTarget.value as SortOrder,
},
},
});
},
[page.list, setPage],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = page.filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({ sortOrder: newSortOrder });
handleFilterChange(updatedFilters);
}, [page.filter.sortOrder, handleFilterChange, setFilter]);
const setPagination = useSetSongTablePagination();
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
const type = e.currentTarget.value;
if (type === CardDisplayType.CARD) {
setPage('songs', {
...page,
list: {
...page.list,
display: CardDisplayType.CARD,
type: 'grid',
},
});
} else if (type === CardDisplayType.POSTER) {
setPage('songs', {
...page,
list: {
...page.list,
display: CardDisplayType.POSTER,
type: 'grid',
},
});
} else {
setPage('songs', {
...page,
list: {
...page.list,
type: 'list',
},
});
const display = e.currentTarget.value as ListDisplayType;
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
if (display === ListDisplayType.TABLE) {
tableRef.current?.api.paginationSetPageSize(tableRef.current.props.infiniteInitialRowCount);
setPagination({ currentPage: 0 });
} else if (display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ currentPage: 0 });
}
},
[page, setPage],
[page, setPage, setPagination, tableRef],
);
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
searchTerm: e.target.value === '' ? undefined : e.target.value,
});
handleFilterChange(updatedFilters);
}, 500);
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = page.table.columns;
if (values.length === 0) {
return setTable({
columns: [],
});
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
return setTable({ columns: [...existingColumns, newColumn] });
}
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
return setTable({ columns: newColumns });
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ autoFit: e.currentTarget.checked });
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
const handleRowHeight = (e: number) => {
setTable({ rowHeight: e });
};
return (
<PageHeader>
<Group>
<DropdownMenu
position="bottom-end"
width={100}
<Flex
ref={cq.ref}
direction="row"
justify="space-between"
>
<Flex
align="center"
gap="md"
justify="center"
>
<DropdownMenu.Target>
<Button
compact
rightIcon={<RiArrowDownSLine size={15} />}
size="xl"
sx={{ paddingLeft: 0, paddingRight: 0 }}
variant="subtle"
>
Tracks
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item>
<Slider
defaultValue={page.list?.size || 0}
label={null}
onChange={setSize}
/>
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
$isActive={page.list.type === 'grid' && page.list.display === CardDisplayType.CARD}
value={CardDisplayType.CARD}
onClick={handleSetViewType}
>
Card
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={page.list.type === 'grid' && page.list.display === CardDisplayType.POSTER}
value={CardDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
</DropdownMenu.Item>
<DropdownMenu.Item
disabled
$isActive={page.list.type === 'list'}
value="list"
onClick={handleSetViewType}
>
List
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="normal"
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
key={`filter-${filter.name}`}
$isActive={filter.value === filters.sortBy}
value={filter.value}
onClick={handleSetFilter}
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
rightIcon={<RiArrowDownSLine size={15} />}
size="xl"
sx={{ paddingLeft: 0, paddingRight: 0 }}
variant="subtle"
>
{filter.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="normal"
variant="subtle"
>
{sortOrderLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{ORDER.map((sort) => (
<TextTitle
fw="bold"
order={3}
>
Tracks
</TextTitle>
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Item
key={`sort-${sort.value}`}
$isActive={sort.value === filters.sortOrder}
value={sort.value}
onClick={handleSetOrder}
$isActive={page.display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
{sort.name}
Table
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="normal"
variant="subtle"
>
Folder
</Button>
</DropdownMenu.Target>
{/* <DropdownMenu.Dropdown>
{serverFolders?.map((folder) => (
<DropdownMenu.Item
key={folder.id}
$isActive={filters.serverFolderId.includes(folder.id)}
closeMenuOnClick={false}
value={folder.id}
onClick={handleSetServerFolder}
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown> */}
</DropdownMenu>
</Group>
<DropdownMenu.Item
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
value={ListDisplayType.TABLE_PAGINATED}
onClick={handleSetViewType}
>
Table (paginated)
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>Item Size</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={page.table.rowHeight || 0}
label={null}
max={100}
min={25}
onChangeEnd={handleRowHeight}
/>
</DropdownMenu.Item>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<MultiSelect
clearable
data={SONG_TABLE_COLUMNS}
defaultValue={page.table?.columns.map((column) => column.column)}
width={300}
onChange={handleTableColumns}
/>
</DropdownMenu.Item>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={page.table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
key={`filter-${filter.name}`}
$isActive={filter.value === page.filter.sortBy}
value={filter.value}
onClick={handleSetSortBy}
>
{filter.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
fw="600"
variant="subtle"
onClick={handleToggleSortOrder}
>
{cq.isMd ? (
sortOrderLabel
) : (
<>
{page.filter.sortOrder === SortOrder.ASC ? (
<RiSortAsc size={15} />
) : (
<RiSortDesc size={15} />
)}
</>
)}
</Button>
{server?.type === ServerType.JELLYFIN && (
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
variant="subtle"
>
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.map((folder) => (
<DropdownMenu.Item
key={`musicFolder-${folder.id}`}
$isActive={page.filter.musicFolderId === folder.id}
value={folder.id}
onClick={handleSetMusicFolder}
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
)}
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
variant="subtle"
>
{cq.isMd ? 'Filters' : <RiFilter3Line size={15} />}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{server?.type === ServerType.NAVIDROME ? (
<NavidromeSongFilters handleFilterChange={handleFilterChange} />
) : (
<JellyfinSongFilters handleFilterChange={handleFilterChange} />
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
variant="subtle"
>
<RiMoreFill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item disabled>Play</DropdownMenu.Item>
<DropdownMenu.Item disabled>Play last</DropdownMenu.Item>
<DropdownMenu.Item disabled>Play next</DropdownMenu.Item>
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Flex>
<Flex gap="md">
<SearchInput
defaultValue={page.filter.searchTerm}
openedWidth={cq.isLg ? 300 : cq.isMd ? 250 : cq.isSm ? 150 : 75}
onChange={handleSearch}
/>
</Flex>
</Flex>
</PageHeader>
);
};

View file

@ -1,113 +1,18 @@
import { useCallback, useMemo } from 'react';
import {
VirtualGridContainer,
VirtualGridAutoSizerContainer,
VirtualTable,
getColumnDefs,
} from '/@/renderer/components';
import type { ColDef, GridReadyEvent, IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useRef } from 'react';
import { VirtualGridContainer } from '/@/renderer/components';
import { AnimatedPage } from '/@/renderer/features/shared';
import { useTableSettings } from '/@/renderer/store/settings.store';
import { SongListContent } from '/@/renderer/features/songs/components/song-list-content';
import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header';
import { useSongList } from '/@/renderer/features/songs/queries/song-list-query';
import { SongListSort, SortOrder } from '/@/renderer/api/types';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useCurrentServer, useSongRouteStore } from '/@/renderer/store';
import { controller } from '/@/renderer/api/controller';
import { api } from '/@/renderer/api';
import { useQueryClient } from '@tanstack/react-query';
const TrackListRoute = () => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const page = useSongRouteStore();
const filters = page.list.filter;
const tableConfig = useTableSettings('songs');
const checkSongList = useSongList({
limit: 1,
sortBy: SongListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
});
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
};
}, []);
const onGridReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.songs.list(server?.id || '', {
limit,
startIndex,
...filters,
});
const songsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
controller.getSongList({
query: {
limit,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
startIndex,
},
server,
signal,
}),
);
const songs = api.normalize.songList(songsRes, server);
params.successCallback(songs?.items || [], -1);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
},
[filters, queryClient, server],
);
const tableRef = useRef<AgGridReactType | null>(null);
return (
<AnimatedPage>
<VirtualGridContainer>
<SongListHeader />
<VirtualGridAutoSizerContainer>
{!checkSongList.isLoading && (
<VirtualTable
alwaysShowHorizontalScroll
animateRows
maintainColumnOrder
suppressCopyRowsToClipboard
suppressMoveWhenRowDragging
suppressRowDrag
suppressScrollOnNewData
blockLoadDebounceMillis={200}
cacheBlockSize={500}
cacheOverflowSize={1}
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
infiniteInitialRowCount={checkSongList.data?.totalRecordCount}
rowBuffer={20}
rowHeight={tableConfig.rowHeight || 40}
rowModelType="infinite"
rowSelection="multiple"
onCellContextMenu={(e) => console.log('context', e)}
onGridReady={onGridReady}
/>
)}
</VirtualGridAutoSizerContainer>
<SongListHeader tableRef={tableRef} />
<SongListContent tableRef={tableRef} />
</VirtualGridContainer>
</AnimatedPage>
);

View file

@ -3,8 +3,9 @@ import { nanoid } from 'nanoid/non-secure';
import create from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { AlbumListSort, SortOrder } from '/@/renderer/api/types';
import { AlbumListSort, SongListSort, SortOrder } from '/@/renderer/api/types';
import { useAlbumStore } from '/@/renderer/store/album.store';
import { useSongStore } from '/@/renderer/store/song.store';
import { ServerListItem } from '/@/renderer/types';
export interface AuthState {
@ -48,7 +49,12 @@ export const useAuthStore = create<AuthSlice>()(
useAlbumStore.getState().actions.setFilters({
musicFolderId: undefined,
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.ASC,
sortOrder: SortOrder.DESC,
});
useSongStore.getState().actions.setFilters({
musicFolderId: undefined,
sortBy: SongListSort.RECENTLY_ADDED,
sortOrder: SortOrder.DESC,
});
}
});

View file

@ -4,9 +4,10 @@ import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { SongListArgs, SongListSort, SortOrder } from '/@/renderer/api/types';
import { DataTableProps } from '/@/renderer/store/settings.store';
import { ListDisplayType, TableColumn } from '/@/renderer/types';
import { ListDisplayType, TableColumn, TablePagination } from '/@/renderer/types';
type TableProps = {
pagination: TablePagination;
scrollOffset: number;
} & DataTableProps;
@ -16,16 +17,18 @@ type ListProps<T> = {
table: TableProps;
};
type AlbumListFilter = Omit<SongListArgs['query'], 'startIndex' | 'limit'>;
export type SongListFilter = Omit<SongListArgs['query'], 'startIndex' | 'limit'>;
interface SongState {
list: ListProps<AlbumListFilter>;
list: ListProps<SongListFilter>;
}
export interface SongSlice extends SongState {
actions: {
setFilters: (data: Partial<AlbumListFilter>) => void;
setFilters: (data: Partial<SongListFilter>) => SongListFilter;
setStore: (data: Partial<SongSlice>) => void;
setTable: (data: Partial<TableProps>) => void;
setTablePagination: (data: Partial<TableProps['pagination']>) => void;
};
}
@ -38,10 +41,22 @@ export const useSongStore = create<SongSlice>()(
set((state) => {
state.list.filter = { ...state.list.filter, ...data };
});
return get().list.filter;
},
setStore: (data) => {
set({ ...get(), ...data });
},
setTable: (data) => {
set((state) => {
state.list.table = { ...state.list.table, ...data };
});
},
setTablePagination: (data) => {
set((state) => {
state.list.table.pagination = { ...state.list.table.pagination, ...data };
});
},
},
list: {
display: ListDisplayType.TABLE,
@ -78,6 +93,12 @@ export const useSongStore = create<SongSlice>()(
width: 100,
},
],
pagination: {
currentPage: 1,
itemsPerPage: 100,
totalItems: 1,
totalPages: 1,
},
rowHeight: 60,
scrollOffset: 0,
},
@ -99,8 +120,17 @@ export const useSongStoreActions = () => useSongStore((state) => state.actions);
export const useSetSongStore = () => useSongStore((state) => state.actions.setStore);
export const useSetSongFilters = () => useSongStore((state) => state.actions.setFilters);
export const useSongFilters = () => {
return useSongStore((state) => [state.list.filter, state.actions.setFilters]);
};
export const useSongListStore = () => useSongStore((state) => state.list);
export const useSongTablePagination = () => useSongStore((state) => state.list.table.pagination);
export const useSetSongTablePagination = () =>
useSongStore((state) => state.actions.setTablePagination);
export const useSetSongTable = () => useSongStore((state) => state.actions.setTable);