mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 13:00:13 +02:00
implement new lists for songs
This commit is contained in:
@@ -6,31 +6,24 @@ import { useTranslation } from 'react-i18next';
|
||||
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
|
||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
||||
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
||||
import { SongListFilter, useCurrentServer } 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';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
|
||||
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { GenreListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
|
||||
|
||||
interface JellyfinSongFiltersProps {
|
||||
customFilters?: Partial<SongListFilter>;
|
||||
onFilterChange: (filters: SongListFilter) => void;
|
||||
pageKey: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const JellyfinSongFilters = ({
|
||||
customFilters,
|
||||
onFilterChange,
|
||||
pageKey,
|
||||
serverId,
|
||||
}: JellyfinSongFiltersProps) => {
|
||||
export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps) => {
|
||||
const server = useCurrentServer();
|
||||
const { t } = useTranslation();
|
||||
const { setFilter } = useListStoreActions();
|
||||
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
|
||||
const { query, setCustom, setFavorite, setMaxYear, setMinYear } = useSongListFilters();
|
||||
|
||||
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||
|
||||
@@ -39,12 +32,12 @@ export const JellyfinSongFilters = ({
|
||||
const genreListQuery = useQuery(
|
||||
genresQueries.list({
|
||||
query: {
|
||||
musicFolderId: filter?.musicFolderId,
|
||||
musicFolderId: query.musicFolderId,
|
||||
sortBy: GenreListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
serverId: server.id,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -59,122 +52,57 @@ export const JellyfinSongFilters = ({
|
||||
const tagsQuery = useQuery(
|
||||
sharedQueries.tags({
|
||||
query: {
|
||||
folder: filter?.musicFolderId,
|
||||
folder: query.musicFolderId,
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId,
|
||||
serverId: server.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const selectedGenres = useMemo(() => {
|
||||
return filter?._custom?.jellyfin?.GenreIds?.split(',');
|
||||
}, [filter?._custom?.jellyfin?.GenreIds]);
|
||||
return query._custom?.GenreIds?.split(',');
|
||||
}, [query._custom?.GenreIds]);
|
||||
|
||||
const selectedTags = useMemo(() => {
|
||||
return filter?._custom?.jellyfin?.Tags?.split('|');
|
||||
}, [filter?._custom?.jellyfin?.Tags]);
|
||||
return query._custom?.Tags?.split('|');
|
||||
}, [query._custom?.Tags]);
|
||||
|
||||
const yesNoFilters = [
|
||||
{
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
onChange: (favorite?: boolean) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
IncludeItemTypes: 'Audio',
|
||||
},
|
||||
},
|
||||
favorite,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
onChange: (favorite: boolean | undefined) => {
|
||||
setFavorite(favorite ?? null);
|
||||
},
|
||||
value: filter.favorite,
|
||||
value: query.favorite,
|
||||
},
|
||||
];
|
||||
|
||||
const handleMinYearFilter = debounce((e: number | string) => {
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
IncludeItemTypes: 'Audio',
|
||||
},
|
||||
},
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
setMinYear(e === '' ? null : (e as number));
|
||||
}, 500);
|
||||
|
||||
const handleMaxYearFilter = debounce((e: number | string) => {
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
IncludeItemTypes: 'Audio',
|
||||
},
|
||||
},
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
setMaxYear(e === '' ? null : (e as number));
|
||||
}, 500);
|
||||
|
||||
const handleGenresFilter = debounce((e: string[] | undefined) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
IncludeItemTypes: 'Audio',
|
||||
},
|
||||
},
|
||||
genreIds: e,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
setCustom((prev) => ({
|
||||
...prev,
|
||||
GenreIds: e?.join(',') || undefined,
|
||||
IncludeItemTypes: 'Audio',
|
||||
...prev?.jellyfin,
|
||||
}));
|
||||
}, 250);
|
||||
|
||||
const handleTagFilter = debounce((e: string[] | undefined) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
_custom: {
|
||||
...filter?._custom,
|
||||
jellyfin: {
|
||||
...filter?._custom?.jellyfin,
|
||||
IncludeItemTypes: 'Audio',
|
||||
Tags: e?.join('|') || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
onFilterChange(updatedFilters);
|
||||
setCustom((prev) => ({
|
||||
...prev,
|
||||
IncludeItemTypes: 'Audio',
|
||||
Tags: e?.join('|') || undefined,
|
||||
...prev?.jellyfin,
|
||||
}));
|
||||
}, 250);
|
||||
|
||||
return (
|
||||
@@ -188,20 +116,20 @@ export const JellyfinSongFilters = ({
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter?.minYear}
|
||||
defaultValue={query.minYear}
|
||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||
max={2300}
|
||||
min={1700}
|
||||
onChange={handleMinYearFilter}
|
||||
required={!!filter?.minYear}
|
||||
required={!!query.minYear}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={filter?.maxYear}
|
||||
defaultValue={query.maxYear}
|
||||
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
||||
max={2300}
|
||||
min={1700}
|
||||
onChange={handleMaxYearFilter}
|
||||
required={!!filter?.minYear}
|
||||
required={!!query.minYear}
|
||||
/>
|
||||
</Group>
|
||||
{!isGenrePage && (
|
||||
|
||||
@@ -74,7 +74,7 @@ export const NavidromeSongFilters = ({
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const hasBrf = hasFeature(server, ServerFeature.BFR);
|
||||
const hasBFR = hasFeature(server, ServerFeature.BFR);
|
||||
|
||||
const handleGenresFilter = debounce((e: null | string[]) => {
|
||||
const updatedFilters = setFilter({
|
||||
@@ -166,7 +166,7 @@ export const NavidromeSongFilters = ({
|
||||
value={filter._custom?.navidrome?.year}
|
||||
width={50}
|
||||
/>
|
||||
{!isGenrePage && !hasBrf && (
|
||||
{!isGenrePage && !hasBFR && (
|
||||
<SelectWithInvalidData
|
||||
clearable
|
||||
data={genreList}
|
||||
@@ -178,7 +178,7 @@ export const NavidromeSongFilters = ({
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{!isGenrePage && hasBrf && (
|
||||
{!isGenrePage && hasBFR && (
|
||||
<Group grow>
|
||||
<MultiSelectWithInvalidData
|
||||
clearable
|
||||
|
||||
@@ -1,44 +1,120 @@
|
||||
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 { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-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 SongListTableView = lazy(() =>
|
||||
import('/@/renderer/features/songs/components/song-list-table-view').then((module) => ({
|
||||
default: module.SongListTableView,
|
||||
const SongListInfiniteGrid = lazy(() =>
|
||||
import('/@/renderer/features/songs/components/song-list-infinite-grid').then((module) => ({
|
||||
default: module.SongListInfiniteGrid,
|
||||
})),
|
||||
);
|
||||
const SongListPaginatedGrid = lazy(() =>
|
||||
import('/@/renderer/features/songs/components/song-list-paginated-grid').then((module) => ({
|
||||
default: module.SongListPaginatedGrid,
|
||||
})),
|
||||
);
|
||||
const SongListInfiniteTable = lazy(() =>
|
||||
import('/@/renderer/features/songs/components/song-list-infinite-table').then((module) => ({
|
||||
default: module.SongListInfiniteTable,
|
||||
})),
|
||||
);
|
||||
const SongListPaginatedTable = lazy(() =>
|
||||
import('/@/renderer/features/songs/components/song-list-paginated-table').then((module) => ({
|
||||
default: module.SongListPaginatedTable,
|
||||
})),
|
||||
);
|
||||
|
||||
const SongListGridView = lazy(() =>
|
||||
import('/@/renderer/features/songs/components/song-list-grid-view').then((module) => ({
|
||||
default: module.SongListGridView,
|
||||
})),
|
||||
);
|
||||
|
||||
interface SongListContentProps {
|
||||
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const SongListContent = ({ gridRef, itemCount, tableRef }: SongListContentProps) => {
|
||||
const { pageKey } = useListContext();
|
||||
const { display } = useListStoreByKey({ key: pageKey });
|
||||
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
|
||||
export const SongListContent = () => {
|
||||
const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.SONG);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
{isGrid ? (
|
||||
<SongListGridView gridRef={gridRef} itemCount={itemCount} />
|
||||
) : (
|
||||
<SongListTableView itemCount={itemCount} tableRef={tableRef} />
|
||||
)}
|
||||
<SongListView
|
||||
display={display}
|
||||
grid={grid}
|
||||
itemsPerPage={itemsPerPage}
|
||||
pagination={pagination}
|
||||
table={table}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export const SongListView = ({
|
||||
display,
|
||||
grid,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
table,
|
||||
}: ItemListSettings) => {
|
||||
const server = useCurrentServer();
|
||||
|
||||
const { query } = useSongListFilters();
|
||||
|
||||
switch (display) {
|
||||
case ListDisplayType.GRID: {
|
||||
switch (pagination) {
|
||||
case ListPaginationType.INFINITE:
|
||||
return (
|
||||
<SongListInfiniteGrid
|
||||
gap={grid.itemGap}
|
||||
itemsPerPage={itemsPerPage}
|
||||
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
|
||||
query={query}
|
||||
serverId={server.id}
|
||||
/>
|
||||
);
|
||||
case ListPaginationType.PAGINATED:
|
||||
return (
|
||||
<SongListPaginatedGrid
|
||||
gap={grid.itemGap}
|
||||
itemsPerPage={itemsPerPage}
|
||||
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
|
||||
query={query}
|
||||
serverId={server.id}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
case ListDisplayType.TABLE: {
|
||||
switch (pagination) {
|
||||
case ListPaginationType.INFINITE:
|
||||
return (
|
||||
<SongListInfiniteTable
|
||||
columns={table.columns}
|
||||
enableAlternateRowColors={table.enableAlternateRowColors}
|
||||
enableHorizontalBorders={table.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={table.enableRowHoverHighlight}
|
||||
enableVerticalBorders={table.enableVerticalBorders}
|
||||
itemsPerPage={itemsPerPage}
|
||||
query={query}
|
||||
serverId={server.id}
|
||||
size={table.size}
|
||||
/>
|
||||
);
|
||||
case ListPaginationType.PAGINATED:
|
||||
return (
|
||||
<SongListPaginatedTable
|
||||
columns={table.columns}
|
||||
enableAlternateRowColors={table.enableAlternateRowColors}
|
||||
enableHorizontalBorders={table.enableHorizontalBorders}
|
||||
enableRowHoverHighlight={table.enableRowHoverHighlight}
|
||||
enableVerticalBorders={table.enableVerticalBorders}
|
||||
itemsPerPage={itemsPerPage}
|
||||
query={query}
|
||||
serverId={server.id}
|
||||
size={table.size}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { QueryKey, useQueryClient } from '@tanstack/react-query';
|
||||
import { MutableRefObject, useCallback, useEffect, 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 { SONG_CARD_ROWS } from '/@/renderer/components/card/card-rows';
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
|
||||
import {
|
||||
VirtualInfiniteGrid,
|
||||
VirtualInfiniteGridRef,
|
||||
} 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 { useEventStore } from '/@/renderer/store/event.store';
|
||||
import {
|
||||
LibraryItem,
|
||||
Song,
|
||||
SongListQuery,
|
||||
SongListResponse,
|
||||
SongListSort,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { CardRow, ListDisplayType } from '/@/shared/types/types';
|
||||
interface SongListGridViewProps {
|
||||
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>;
|
||||
itemCount?: number;
|
||||
}
|
||||
|
||||
export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { customFilters, id, pageKey } = useListContext();
|
||||
const { display, filter, grid } = useListStoreByKey<SongListQuery>({ 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 });
|
||||
|
||||
useEffect(() => {
|
||||
const unSub = useEventStore.subscribe((state) => {
|
||||
const event = state.event;
|
||||
if (event && event.event === 'favorite') {
|
||||
const idSet = new Set(state.ids);
|
||||
const userFavorite = event.favorite;
|
||||
|
||||
gridRef.current?.updateItemData((data) => {
|
||||
if (idSet.has(data.id)) {
|
||||
return {
|
||||
...data,
|
||||
userFavorite,
|
||||
};
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unSub();
|
||||
};
|
||||
}, [gridRef]);
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<Song>[] = [
|
||||
SONG_CARD_ROWS.name,
|
||||
SONG_CARD_ROWS.album,
|
||||
SONG_CARD_ROWS.albumArtists,
|
||||
];
|
||||
|
||||
switch (filter.sortBy) {
|
||||
case SongListSort.ALBUM:
|
||||
break;
|
||||
case SongListSort.ARTIST:
|
||||
break;
|
||||
case SongListSort.DURATION:
|
||||
rows.push(SONG_CARD_ROWS.duration);
|
||||
break;
|
||||
case SongListSort.EXPLICIT_STATUS:
|
||||
rows.push(SONG_CARD_ROWS.explicitStatus);
|
||||
break;
|
||||
case SongListSort.FAVORITED:
|
||||
break;
|
||||
case SongListSort.NAME:
|
||||
break;
|
||||
case SongListSort.PLAY_COUNT:
|
||||
rows.push(SONG_CARD_ROWS.playCount);
|
||||
break;
|
||||
case SongListSort.RANDOM:
|
||||
break;
|
||||
case SongListSort.RATING:
|
||||
rows.push(SONG_CARD_ROWS.rating);
|
||||
break;
|
||||
case SongListSort.RECENTLY_ADDED:
|
||||
rows.push(SONG_CARD_ROWS.createdAt);
|
||||
break;
|
||||
case SongListSort.RECENTLY_PLAYED:
|
||||
rows.push(SONG_CARD_ROWS.lastPlayedAt);
|
||||
break;
|
||||
case SongListSort.YEAR:
|
||||
rows.push(SONG_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case SongListSort.RELEASE_DATE:
|
||||
rows.push(SONG_CARD_ROWS.releaseDate);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [filter.sortBy]);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
if (id) {
|
||||
setSearchParams(
|
||||
(params) => {
|
||||
params.set('scrollOffset', String(e.scrollOffset));
|
||||
return params;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
} else {
|
||||
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
|
||||
}
|
||||
},
|
||||
[id, pageKey, setGrid, setSearchParams],
|
||||
);
|
||||
|
||||
const fetchInitialData = useCallback(() => {
|
||||
const query: SongListQuery = {
|
||||
...filter,
|
||||
...customFilters,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', query, id);
|
||||
|
||||
const queriesFromCache: [QueryKey, SongListResponse | undefined][] =
|
||||
queryClient.getQueriesData({
|
||||
exact: false,
|
||||
fetchStatus: 'idle',
|
||||
queryKey,
|
||||
stale: false,
|
||||
});
|
||||
|
||||
const itemData: Song[] = [];
|
||||
|
||||
for (const [, data] of queriesFromCache) {
|
||||
const { items, startIndex } = data || {};
|
||||
|
||||
if (items && items.length !== 1 && startIndex !== undefined) {
|
||||
let itemIndex = 0;
|
||||
for (
|
||||
let rowIndex = startIndex;
|
||||
rowIndex < startIndex + items.length;
|
||||
rowIndex += 1
|
||||
) {
|
||||
itemData[rowIndex] = items[itemIndex];
|
||||
itemIndex += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return itemData;
|
||||
}, [customFilters, filter, id, queryClient, server?.id]);
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip, take }: { skip: number; take: number }) => {
|
||||
if (!server) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const query: SongListQuery = {
|
||||
imageSize: 250,
|
||||
limit: take,
|
||||
...filter,
|
||||
...customFilters,
|
||||
startIndex: skip,
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', query, id);
|
||||
|
||||
const songs = await queryClient.fetchQuery({
|
||||
queryFn: async ({ signal }) =>
|
||||
controller.getSongList({
|
||||
apiClientProps: {
|
||||
serverId: server?.id || '',
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
queryKey,
|
||||
});
|
||||
|
||||
return songs;
|
||||
},
|
||||
[customFilters, filter, id, queryClient, server],
|
||||
);
|
||||
|
||||
return (
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<AutoSizer>
|
||||
{({ height, width }: Size) => (
|
||||
<VirtualInfiniteGrid
|
||||
cardRows={cardRows}
|
||||
display={display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
fetchInitialData={fetchInitialData}
|
||||
handleFavorite={handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemCount={itemCount || 0}
|
||||
itemGap={grid?.itemGap ?? 10}
|
||||
itemSize={grid?.itemSize || 200}
|
||||
itemType={LibraryItem.SONG}
|
||||
key={`song-list-${server?.id}-${display}`}
|
||||
loading={itemCount === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
onScroll={handleGridScroll}
|
||||
ref={gridRef}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'albumId', slugProperty: 'albumId' }],
|
||||
}}
|
||||
width={width}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
);
|
||||
};
|
||||
@@ -1,599 +1,36 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { openModal } from '@mantine/modals';
|
||||
import { useQuery } 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 { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
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 { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
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 { JellyfinSongFilters } from '/@/renderer/features/songs/components/jellyfin-song-filters';
|
||||
import { NavidromeSongFilters } from '/@/renderer/features/songs/components/navidrome-song-filters';
|
||||
import { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filter';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import {
|
||||
PersistedTableColumn,
|
||||
SongListFilter,
|
||||
useCurrentServer,
|
||||
useListStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { useListStoreByKey } from '/@/renderer/store/list.store';
|
||||
import { Button } from '/@/shared/components/button/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 { 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 {
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SongListQuery,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ListDisplayType, Play } from '/@/shared/types/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.album', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ALBUM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ALBUM_ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.DURATION,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.PLAY_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RANDOM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RECENTLY_ADDED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.releaseDate', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RELEASE_DATE,
|
||||
},
|
||||
],
|
||||
navidrome: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.album', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ALBUM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ALBUM_ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.ARTIST,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.bpm', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.BPM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('common.channel', { count: 2, postProcess: 'titleCase' }),
|
||||
value: SongListSort.CHANNELS,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.comment', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.COMMENT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.DURATION,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.explicitStatus', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.EXPLICIT_STATUS,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.FAVORITED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.genre', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.GENRE,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.NAME,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.PLAY_COUNT,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RANDOM,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RATING,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RECENTLY_ADDED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.RECENTLY_PLAYED,
|
||||
},
|
||||
{
|
||||
defaultOrder: SortOrder.DESC,
|
||||
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.YEAR,
|
||||
},
|
||||
],
|
||||
subsonic: [
|
||||
{
|
||||
defaultOrder: SortOrder.ASC,
|
||||
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
|
||||
value: SongListSort.NAME,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface SongListHeaderFiltersProps {
|
||||
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const SongListHeaderFilters = ({
|
||||
gridRef,
|
||||
itemCount,
|
||||
tableRef,
|
||||
}: SongListHeaderFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const { customFilters, handlePlay, pageKey } = useListContext();
|
||||
const { display, filter, grid, table } = useListStoreByKey<SongListQuery>({
|
||||
filter: customFilters,
|
||||
key: pageKey,
|
||||
});
|
||||
|
||||
const { setDisplayType, setFilter, setGrid, setTable, setTablePagination } =
|
||||
useListStoreActions();
|
||||
|
||||
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
|
||||
itemCount,
|
||||
itemType: LibraryItem.SONG,
|
||||
server,
|
||||
});
|
||||
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useQuery(
|
||||
sharedQueries.musicFolders({ query: null, serverId: server?.id }),
|
||||
);
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
(
|
||||
FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]
|
||||
).find((f) => f.value === filter.sortBy)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
|
||||
|
||||
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({
|
||||
customFilters,
|
||||
data: {
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
|
||||
if (isGrid) {
|
||||
handleRefreshGrid(gridRef, updatedFilters);
|
||||
} else {
|
||||
handleRefreshTable(tableRef, updatedFilters);
|
||||
}
|
||||
},
|
||||
[
|
||||
customFilters,
|
||||
gridRef,
|
||||
handleRefreshGrid,
|
||||
handleRefreshTable,
|
||||
isGrid,
|
||||
pageKey,
|
||||
server?.type,
|
||||
setFilter,
|
||||
tableRef,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSetMusicFolder = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
|
||||
let updatedFilters: null | SongListFilter = null;
|
||||
if (e.currentTarget.value === String(filter.musicFolderId)) {
|
||||
updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: { musicFolderId: undefined },
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
} else {
|
||||
updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: { musicFolderId: e.currentTarget.value },
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
}
|
||||
|
||||
if (isGrid) {
|
||||
handleRefreshGrid(gridRef, updatedFilters);
|
||||
} else {
|
||||
handleRefreshTable(tableRef, updatedFilters);
|
||||
}
|
||||
},
|
||||
[
|
||||
filter.musicFolderId,
|
||||
isGrid,
|
||||
setFilter,
|
||||
customFilters,
|
||||
pageKey,
|
||||
handleRefreshGrid,
|
||||
gridRef,
|
||||
handleRefreshTable,
|
||||
tableRef,
|
||||
],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: { sortOrder: newSortOrder },
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
|
||||
if (isGrid) {
|
||||
handleRefreshGrid(gridRef, updatedFilters);
|
||||
} else {
|
||||
handleRefreshTable(tableRef, updatedFilters);
|
||||
}
|
||||
}, [
|
||||
customFilters,
|
||||
filter.sortOrder,
|
||||
gridRef,
|
||||
handleRefreshGrid,
|
||||
handleRefreshTable,
|
||||
isGrid,
|
||||
pageKey,
|
||||
setFilter,
|
||||
tableRef,
|
||||
]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(displayType: ListDisplayType) => {
|
||||
setDisplayType({
|
||||
data: displayType,
|
||||
key: pageKey,
|
||||
});
|
||||
|
||||
if (display === ListDisplayType.TABLE) {
|
||||
tableRef.current?.api.paginationSetPageSize(
|
||||
tableRef.current.props.infiniteInitialRowCount,
|
||||
);
|
||||
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
|
||||
} else if (display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
|
||||
}
|
||||
},
|
||||
[display, pageKey, setDisplayType, setTablePagination, tableRef],
|
||||
);
|
||||
|
||||
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],
|
||||
width: 100,
|
||||
} as PersistedTableColumn;
|
||||
|
||||
return setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
|
||||
}
|
||||
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
return setTable({ data: { columns: newColumns }, key: pageKey });
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (autoFitColumns: boolean) => {
|
||||
setTable({ data: { autoFit: autoFitColumns }, key: pageKey });
|
||||
|
||||
if (autoFitColumns) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
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 handleRefresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.songs.list(server?.id || '') });
|
||||
if (isGrid) {
|
||||
handleRefreshGrid(gridRef, filter);
|
||||
} else {
|
||||
handleRefreshTable(tableRef, filter);
|
||||
}
|
||||
};
|
||||
|
||||
const onFilterChange = (filter: SongListFilter) => {
|
||||
if (isGrid) {
|
||||
handleRefreshGrid(gridRef, {
|
||||
...filter,
|
||||
});
|
||||
} else {
|
||||
handleRefreshTable(tableRef, {
|
||||
...filter,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFiltersModal = () => {
|
||||
let FilterComponent;
|
||||
|
||||
switch (server?.type) {
|
||||
case ServerType.JELLYFIN:
|
||||
FilterComponent = JellyfinSongFilters;
|
||||
break;
|
||||
case ServerType.NAVIDROME:
|
||||
FilterComponent = NavidromeSongFilters;
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
FilterComponent = SubsonicSongFilters;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!FilterComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
openModal({
|
||||
children: (
|
||||
<FilterComponent
|
||||
customFilters={customFilters}
|
||||
onFilterChange={onFilterChange}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
/>
|
||||
),
|
||||
title: 'Song Filters',
|
||||
});
|
||||
};
|
||||
|
||||
const isFilterApplied = useMemo(() => {
|
||||
const isNavidromeFilterApplied =
|
||||
server?.type === ServerType.NAVIDROME &&
|
||||
filter._custom?.navidrome &&
|
||||
Object.values(filter?._custom?.navidrome).some((value) => value !== undefined);
|
||||
|
||||
const isJellyfinFilterApplied =
|
||||
server?.type === ServerType.JELLYFIN &&
|
||||
filter?._custom?.jellyfin &&
|
||||
Object.values(filter?._custom?.jellyfin)
|
||||
.filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio
|
||||
.some((value) => value !== undefined);
|
||||
|
||||
const isGenericFilterApplied = filter?.favorite !== undefined || filter?.genreIds?.length;
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied || isGenericFilterApplied;
|
||||
}, [
|
||||
filter._custom?.jellyfin,
|
||||
filter._custom?.navidrome,
|
||||
filter?.favorite,
|
||||
filter?.genreIds?.length,
|
||||
server?.type,
|
||||
]);
|
||||
|
||||
const isFolderFilterApplied = useMemo(() => {
|
||||
return filter.musicFolderId !== undefined;
|
||||
}, [filter.musicFolderId]);
|
||||
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
export const SongListHeaderFilters = () => {
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group gap="sm" ref={cq.ref} w="100%">
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button variant="subtle">{sortByLabel}</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
|
||||
<DropdownMenu.Item
|
||||
isSelected={f.value === filter.sortBy}
|
||||
key={`filter-${f.name}`}
|
||||
onClick={handleSetSortBy}
|
||||
value={f.value}
|
||||
>
|
||||
{f.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Group gap="sm" w="100%">
|
||||
<ListSortByDropdown
|
||||
defaultSortByValue={SongListSort.NAME}
|
||||
itemType={LibraryItem.SONG}
|
||||
listKey={ItemListKey.SONG}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
{server?.type !== ServerType.SUBSONIC && (
|
||||
<OrderToggleButton
|
||||
onToggle={handleToggleSortOrder}
|
||||
sortOrder={filter.sortOrder}
|
||||
/>
|
||||
)}
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<FolderButton isActive={!!isFolderFilterApplied} />
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.items.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
isSelected={filter.musicFolderId === folder.id}
|
||||
key={`musicFolder-${folder.id}`}
|
||||
onClick={handleSetMusicFolder}
|
||||
value={folder.id}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<FilterButton isActive={!!isFilterApplied} onClick={handleOpenFiltersModal} />
|
||||
<RefreshButton onClick={handleRefresh} />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<MoreButton />
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
leftSection={<Icon icon="mediaPlay" />}
|
||||
onClick={() => handlePlay?.({ playType: Play.NOW })}
|
||||
>
|
||||
{t('player.play', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
leftSection={<Icon icon="mediaShuffle" />}
|
||||
onClick={() => handlePlay?.({ playType: Play.SHUFFLE })}
|
||||
>
|
||||
{t('player.shuffle', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
leftSection={<Icon icon="mediaPlayLast" />}
|
||||
onClick={() => handlePlay?.({ playType: Play.LAST })}
|
||||
>
|
||||
{t('player.addLast', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
leftSection={<Icon icon="mediaPlayNext" />}
|
||||
onClick={() => handlePlay?.({ playType: Play.NEXT })}
|
||||
>
|
||||
{t('player.addNext', { postProcess: 'sentenceCase' })}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item
|
||||
leftSection={<Icon icon="refresh" />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{t('common.refresh', { postProcess: 'titleCase' })}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<ListSortOrderToggleButton
|
||||
defaultSortOrder={SortOrder.ASC}
|
||||
listKey={ItemListKey.SONG}
|
||||
/>
|
||||
<ListMusicFolderDropdown listKey={ItemListKey.SONG} />
|
||||
<ListFilters itemType={LibraryItem.SONG} />
|
||||
<ListRefreshButton listKey={ItemListKey.SONG} />
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ListConfigMenu
|
||||
autoFitColumns={table.autoFit}
|
||||
displayType={display}
|
||||
itemGap={grid?.itemGap || 0}
|
||||
itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight}
|
||||
onChangeAutoFitColumns={handleAutoFitColumns}
|
||||
onChangeDisplayType={handleSetViewType}
|
||||
onChangeItemGap={handleItemGap}
|
||||
onChangeItemSize={debouncedHandleItemSize}
|
||||
onChangeTableColumns={handleTableColumns}
|
||||
tableColumns={table?.columns.map((column) => column.column)}
|
||||
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
<ListConfigMenu listKey={ItemListKey.SONG} tableColumnsData={SONG_TABLE_COLUMNS} />
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1,86 +1,33 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, 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 { 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 { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
|
||||
import { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
|
||||
import { SongListFilter, useCurrentServer } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { LibraryItem, SongListQuery } from '/@/shared/types/domain-types';
|
||||
|
||||
interface SongListHeaderProps {
|
||||
genreId?: string;
|
||||
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const SongListHeader = ({
|
||||
genreId,
|
||||
gridRef,
|
||||
itemCount,
|
||||
tableRef,
|
||||
title,
|
||||
}: SongListHeaderProps) => {
|
||||
export const SongListHeader = ({ title }: SongListHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const cq = useContainerQuery();
|
||||
const genreRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const { customFilters, filter, handlePlay, refresh, search } = useDisplayRefresh<SongListQuery>(
|
||||
{
|
||||
gridRef,
|
||||
itemCount,
|
||||
itemType: LibraryItem.SONG,
|
||||
server,
|
||||
tableRef,
|
||||
},
|
||||
);
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = search(e) as SongListFilter;
|
||||
|
||||
const filterWithCustom = {
|
||||
...updatedFilters,
|
||||
...customFilters,
|
||||
};
|
||||
|
||||
refresh(filterWithCustom);
|
||||
}, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (genreRef.current && genreRef.current !== genreId) {
|
||||
refresh(customFilters);
|
||||
}
|
||||
|
||||
genreRef.current = genreId;
|
||||
}, [customFilters, genreId, refresh, tableRef]);
|
||||
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const { itemCount } = useListContext();
|
||||
const pageTitle = title || t('page.trackList.title', { postProcess: 'titleCase' });
|
||||
|
||||
return (
|
||||
<Stack gap={0} ref={cq.ref}>
|
||||
<Stack gap={0}>
|
||||
<PageHeader>
|
||||
<Flex justify="space-between" w="100%">
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton
|
||||
onClick={() => handlePlay?.({ playType: playButtonBehavior })}
|
||||
/>
|
||||
<LibraryHeaderBar.Title>
|
||||
{title || t('page.trackList.title', { postProcess: 'titleCase' })}
|
||||
</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => {}} />
|
||||
<LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Badge
|
||||
isLoading={itemCount === null || itemCount === undefined}
|
||||
>
|
||||
@@ -88,16 +35,12 @@ export const SongListHeader = ({
|
||||
</LibraryHeaderBar.Badge>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} />
|
||||
<ListSearchInput />
|
||||
</Group>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<FilterBar>
|
||||
<SongListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
<SongListHeaderFilters />
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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 { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
|
||||
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
|
||||
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
interface SongListInfiniteGridProps extends ItemListGridComponentProps<SongListQuery> {}
|
||||
|
||||
export const SongListInfiniteGrid = forwardRef<any, SongListInfiniteGridProps>(
|
||||
(
|
||||
{
|
||||
gap = 'md',
|
||||
itemsPerPage = 100,
|
||||
itemsPerRow,
|
||||
query = {
|
||||
sortBy: SongListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
saveScrollOffset = true,
|
||||
serverId,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const listCountQuery = songsQueries.listCount({
|
||||
query: { ...query },
|
||||
serverId: serverId,
|
||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||
|
||||
const listQueryFn = api.controller.getSongList;
|
||||
|
||||
const { data, onRangeChanged } = useItemListInfiniteLoader({
|
||||
eventKey: ItemListKey.SONG,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.SONG,
|
||||
listCountQuery,
|
||||
listQueryFn,
|
||||
query,
|
||||
serverId,
|
||||
});
|
||||
|
||||
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
|
||||
enabled: saveScrollOffset,
|
||||
});
|
||||
|
||||
return (
|
||||
<ItemGridList
|
||||
data={data}
|
||||
gap={gap}
|
||||
initialTop={{
|
||||
to: scrollOffset ?? 0,
|
||||
type: 'offset',
|
||||
}}
|
||||
itemsPerRow={itemsPerRow}
|
||||
itemType={LibraryItem.SONG}
|
||||
onRangeChanged={onRangeChanged}
|
||||
onScrollEnd={handleOnScrollEnd}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
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 { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
interface SongListInfiniteTableProps extends ItemListTableComponentProps<SongListQuery> {}
|
||||
|
||||
export const SongListInfiniteTable = forwardRef<any, SongListInfiniteTableProps>(
|
||||
(
|
||||
{
|
||||
columns,
|
||||
enableAlternateRowColors = false,
|
||||
enableHorizontalBorders = false,
|
||||
enableRowHoverHighlight = true,
|
||||
enableVerticalBorders = false,
|
||||
itemsPerPage = 100,
|
||||
query = {
|
||||
sortBy: SongListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
saveScrollOffset = true,
|
||||
serverId,
|
||||
size = 'default',
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const listCountQuery = songsQueries.listCount({
|
||||
query: { ...query },
|
||||
serverId: serverId,
|
||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||
|
||||
const listQueryFn = api.controller.getSongList;
|
||||
|
||||
const { data, onRangeChanged } = useItemListInfiniteLoader({
|
||||
eventKey: ItemListKey.SONG,
|
||||
itemsPerPage,
|
||||
itemType: LibraryItem.SONG,
|
||||
listCountQuery,
|
||||
listQueryFn,
|
||||
query,
|
||||
serverId,
|
||||
});
|
||||
|
||||
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
|
||||
enabled: saveScrollOffset,
|
||||
});
|
||||
|
||||
return (
|
||||
<ItemTableList
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={data}
|
||||
enableAlternateRowColors={enableAlternateRowColors}
|
||||
enableHorizontalBorders={enableHorizontalBorders}
|
||||
enableRowHoverHighlight={enableRowHoverHighlight}
|
||||
enableVerticalBorders={enableVerticalBorders}
|
||||
initialTop={{
|
||||
to: scrollOffset ?? 0,
|
||||
type: 'offset',
|
||||
}}
|
||||
itemType={LibraryItem.SONG}
|
||||
onRangeChanged={onRangeChanged}
|
||||
onScrollEnd={handleOnScrollEnd}
|
||||
ref={ref}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
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 { 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 { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
|
||||
interface SongListPaginatedGridProps extends ItemListGridComponentProps<SongListQuery> {}
|
||||
|
||||
export const SongListPaginatedGrid = forwardRef<any, SongListPaginatedGridProps>(
|
||||
(
|
||||
{
|
||||
gap = 'md',
|
||||
itemsPerPage = 100,
|
||||
query = {
|
||||
sortBy: SongListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
serverId,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const listCountQuery = songsQueries.listCount({
|
||||
query: { ...query },
|
||||
serverId: serverId,
|
||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||
|
||||
const listQueryFn = api.controller.getSongList;
|
||||
|
||||
const { currentPage, onChange } = useItemListPagination();
|
||||
|
||||
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
listCountQuery,
|
||||
listQueryFn,
|
||||
query,
|
||||
serverId,
|
||||
});
|
||||
|
||||
return (
|
||||
<ItemListWithPagination
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onChange={onChange}
|
||||
pageCount={pageCount}
|
||||
totalItemCount={totalItemCount}
|
||||
>
|
||||
<ItemGridList data={data || []} gap={gap} itemType={LibraryItem.SONG} ref={ref} />
|
||||
</ItemListWithPagination>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
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 { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
|
||||
interface SongListPaginatedTableProps extends ItemListTableComponentProps<SongListQuery> {}
|
||||
|
||||
export const SongListPaginatedTable = forwardRef<any, SongListPaginatedTableProps>(
|
||||
(
|
||||
{
|
||||
columns,
|
||||
enableAlternateRowColors = false,
|
||||
enableHorizontalBorders = false,
|
||||
enableRowHoverHighlight = true,
|
||||
enableVerticalBorders = false,
|
||||
itemsPerPage = 100,
|
||||
query = {
|
||||
sortBy: SongListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
saveScrollOffset = true,
|
||||
serverId,
|
||||
size = 'default',
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const listCountQuery = songsQueries.listCount({
|
||||
query: { ...query },
|
||||
serverId: serverId,
|
||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||
|
||||
const listQueryFn = api.controller.getSongList;
|
||||
|
||||
const { currentPage, onChange } = useItemListPagination();
|
||||
|
||||
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
listCountQuery,
|
||||
listQueryFn,
|
||||
query,
|
||||
serverId,
|
||||
});
|
||||
|
||||
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
|
||||
enabled: saveScrollOffset,
|
||||
});
|
||||
|
||||
return (
|
||||
<ItemListWithPagination
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onChange={onChange}
|
||||
pageCount={pageCount}
|
||||
totalItemCount={totalItemCount}
|
||||
>
|
||||
<ItemTableList
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
data={data || []}
|
||||
enableAlternateRowColors={enableAlternateRowColors}
|
||||
enableHorizontalBorders={enableHorizontalBorders}
|
||||
enableRowHoverHighlight={enableRowHoverHighlight}
|
||||
enableVerticalBorders={enableVerticalBorders}
|
||||
initialTop={{
|
||||
to: scrollOffset ?? 0,
|
||||
type: 'offset',
|
||||
}}
|
||||
itemType={LibraryItem.SONG}
|
||||
onScrollEnd={handleOnScrollEnd}
|
||||
ref={ref}
|
||||
size={size}
|
||||
/>
|
||||
</ItemListWithPagination>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import { MutableRefObject } from 'react';
|
||||
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
|
||||
import { VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
|
||||
import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table';
|
||||
import { useListContext } from '/@/renderer/context/list-context';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { useAppFocus } from '/@/renderer/hooks';
|
||||
import {
|
||||
useCurrentServer,
|
||||
usePlayerSong,
|
||||
usePlayerStatus,
|
||||
usePlayButtonBehavior,
|
||||
} from '/@/renderer/store';
|
||||
import { LibraryItem, QueueSong, SongListQuery } from '/@/shared/types/domain-types';
|
||||
|
||||
interface SongListTableViewProps {
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const SongListTableView = ({ itemCount, tableRef }: SongListTableViewProps) => {
|
||||
const server = useCurrentServer();
|
||||
const { customFilters, handlePlay, id, pageKey } = useListContext();
|
||||
const isFocused = useAppFocus();
|
||||
const currentSong = usePlayerSong();
|
||||
const status = usePlayerStatus();
|
||||
|
||||
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
|
||||
|
||||
const tableProps = useVirtualTable<SongListQuery>({
|
||||
columnType: 'generic',
|
||||
contextMenu: SONG_CONTEXT_MENU_ITEMS,
|
||||
customFilters,
|
||||
isSearchParams: Boolean(id),
|
||||
itemCount,
|
||||
itemType: LibraryItem.SONG,
|
||||
pageKey,
|
||||
server,
|
||||
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-${tableProps.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
{...tableProps}
|
||||
context={{
|
||||
...tableProps.context,
|
||||
currentSong,
|
||||
isFocused,
|
||||
status,
|
||||
}}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
rowClassRules={rowClassRules}
|
||||
shouldUpdateSong
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
);
|
||||
};
|
||||
+17
-49
@@ -1,34 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
|
||||
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
|
||||
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
||||
import { SongListFilter, useCurrentServer } from '/@/renderer/store';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
|
||||
import { GenreListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
|
||||
interface SubsonicSongFiltersProps {
|
||||
customFilters?: Partial<SongListFilter>;
|
||||
onFilterChange: (filters: SongListFilter) => void;
|
||||
pageKey: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const SubsonicSongFilters = ({
|
||||
customFilters,
|
||||
onFilterChange,
|
||||
pageKey,
|
||||
serverId,
|
||||
}: SubsonicSongFiltersProps) => {
|
||||
export const SubsonicSongFilters = ({ customFilters }: SubsonicSongFiltersProps) => {
|
||||
const server = useCurrentServer();
|
||||
const { t } = useTranslation();
|
||||
const { setFilter } = useListStoreActions();
|
||||
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
|
||||
const { query, setFavorite, setGenreId } = useSongListFilters();
|
||||
|
||||
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||
|
||||
@@ -39,7 +32,7 @@ export const SubsonicSongFilters = ({
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
serverId: server.id,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -52,35 +45,16 @@ export const SubsonicSongFilters = ({
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const handleGenresFilter = debounce((e: null | string) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
genreIds: e ? [e] : undefined,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
setGenreId(e ? [e] : null);
|
||||
}, 250);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
disabled: filter.genreIds !== undefined || isGenrePage || !!filter.searchTerm,
|
||||
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
customFilters,
|
||||
data: {
|
||||
favorite: e.target.checked ? true : undefined,
|
||||
},
|
||||
itemType: LibraryItem.SONG,
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
|
||||
onFilterChange(updatedFilters);
|
||||
onChange: (favorite: boolean | undefined) => {
|
||||
setFavorite(favorite ?? null);
|
||||
},
|
||||
value: filter.favorite,
|
||||
value: query.favorite,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -89,22 +63,16 @@ export const SubsonicSongFilters = ({
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group justify="space-between" key={`ss-filter-${filter.label}`}>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
checked={filter?.value || false}
|
||||
disabled={filter.disabled}
|
||||
onChange={filter.onChange}
|
||||
size="xs"
|
||||
/>
|
||||
<YesNoSelect onChange={filter.onChange} size="xs" value={filter.value} />
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
{!isGenrePage && (
|
||||
<Select
|
||||
<SelectWithInvalidData
|
||||
clearable
|
||||
data={genreList}
|
||||
defaultValue={filter.genreIds ? filter.genreIds[0] : undefined}
|
||||
disabled={!!filter.searchTerm}
|
||||
defaultValue={query.genreId ? query.genreId[0] : undefined}
|
||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
||||
onChange={handleGenresFilter}
|
||||
searchable
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
parseAsArrayOf,
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
parseAsJson,
|
||||
parseAsString,
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
|
||||
import { useMusicFolderIdFilter } from '/@/renderer/features/shared/hooks/use-music-folder-id-filter';
|
||||
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
|
||||
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
|
||||
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
|
||||
import { customFiltersSchema, FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||
import { SongListSort } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
export const useSongListFilters = () => {
|
||||
const { sortBy } = useSortByFilter<SongListSort>(null, ItemListKey.SONG);
|
||||
|
||||
const { sortOrder } = useSortOrderFilter(null, ItemListKey.SONG);
|
||||
|
||||
const { musicFolderId } = useMusicFolderIdFilter(null, ItemListKey.SONG);
|
||||
|
||||
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
||||
|
||||
const [albumIds, setAlbumIds] = useQueryState(
|
||||
FILTER_KEYS.SONG.ALBUM_IDS,
|
||||
parseAsArrayOf(parseAsString),
|
||||
);
|
||||
|
||||
const [genreId, setGenreId] = useQueryState(
|
||||
FILTER_KEYS.SONG.GENRE_ID,
|
||||
parseAsArrayOf(parseAsString),
|
||||
);
|
||||
|
||||
const [artistIds, setArtistIds] = useQueryState(
|
||||
FILTER_KEYS.SONG.ARTIST_IDS,
|
||||
parseAsArrayOf(parseAsString),
|
||||
);
|
||||
|
||||
const [minYear, setMinYear] = useQueryState(FILTER_KEYS.SONG.MIN_YEAR, parseAsInteger);
|
||||
|
||||
const [maxYear, setMaxYear] = useQueryState(FILTER_KEYS.SONG.MAX_YEAR, parseAsInteger);
|
||||
|
||||
const [favorite, setFavorite] = useQueryState(FILTER_KEYS.SONG.FAVORITE, parseAsBoolean);
|
||||
|
||||
const [custom, setCustom] = useQueryState(
|
||||
FILTER_KEYS.SONG._CUSTOM,
|
||||
parseAsJson(customFiltersSchema),
|
||||
);
|
||||
|
||||
const query = {
|
||||
[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,
|
||||
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
|
||||
[FILTER_KEYS.SONG.ALBUM_IDS]: albumIds ?? undefined,
|
||||
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
|
||||
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
|
||||
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
|
||||
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
|
||||
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
query,
|
||||
setAlbumIds,
|
||||
setArtistIds,
|
||||
setCustom,
|
||||
setFavorite,
|
||||
setGenreId,
|
||||
setMaxYear,
|
||||
setMinYear,
|
||||
setSearchTerm,
|
||||
};
|
||||
};
|
||||
@@ -1,51 +1,36 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
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 { songsQueries } from '/@/renderer/features/songs/api/songs-api';
|
||||
import { SongListContent } from '/@/renderer/features/songs/components/song-list-content';
|
||||
import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header';
|
||||
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
|
||||
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { GenreListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
|
||||
const TrackListRoute = () => {
|
||||
const gridRef = useRef<null | VirtualInfiniteGridRef>(null);
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const server = useCurrentServer();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { albumArtistId, genreId } = useParams();
|
||||
|
||||
const pageKey = albumArtistId ? `albumArtistSong` : 'song';
|
||||
|
||||
const customFilters = useMemo(() => {
|
||||
const value = {
|
||||
...(albumArtistId && { artistIds: [albumArtistId] }),
|
||||
...(genreId && {
|
||||
genreIds: [genreId],
|
||||
}),
|
||||
};
|
||||
// const customFilters = useMemo(() => {
|
||||
// const value = {
|
||||
// ...(albumArtistId && { artistIds: [albumArtistId] }),
|
||||
// ...(genreId && {
|
||||
// genreIds: [genreId],
|
||||
// }),
|
||||
// };
|
||||
|
||||
if (isEmpty(value)) {
|
||||
return undefined;
|
||||
}
|
||||
// if (isEmpty(value)) {
|
||||
// return undefined;
|
||||
// }
|
||||
|
||||
return value;
|
||||
}, [albumArtistId, genreId]);
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const songListFilter = useListFilterByKey<SongListQuery>({
|
||||
filter: customFilters,
|
||||
key: pageKey,
|
||||
});
|
||||
// return value;
|
||||
// }, [albumArtistId, genreId]);
|
||||
|
||||
const genreList = useQuery(
|
||||
genresQueries.list({
|
||||
@@ -71,57 +56,16 @@ const TrackListRoute = () => {
|
||||
return genre?.name;
|
||||
}, [genreId, genreList.data]);
|
||||
|
||||
const itemCountCheck = useQuery(
|
||||
songsQueries.listCount({
|
||||
options: {
|
||||
gcTime: 1000 * 60,
|
||||
},
|
||||
query: songListFilter,
|
||||
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 { initialSongId, playType } = args;
|
||||
const query: SongListQuery = { ...songListFilter, limit: itemCount, startIndex: 0 };
|
||||
|
||||
if (albumArtistId) {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [albumArtistId],
|
||||
type: LibraryItem.ALBUM_ARTIST,
|
||||
},
|
||||
initialSongId,
|
||||
playType,
|
||||
query,
|
||||
});
|
||||
} else {
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
initialSongId,
|
||||
playType,
|
||||
query,
|
||||
});
|
||||
}
|
||||
},
|
||||
[albumArtistId, handlePlayQueueAdd, itemCount, songListFilter],
|
||||
);
|
||||
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
customFilters,
|
||||
handlePlay,
|
||||
id: albumArtistId ?? genreId,
|
||||
itemCount,
|
||||
pageKey,
|
||||
setItemCount,
|
||||
};
|
||||
}, [albumArtistId, customFilters, genreId, handlePlay, pageKey]);
|
||||
}, [albumArtistId, genreId, itemCount, pageKey, setItemCount]);
|
||||
|
||||
const artist = searchParams.get('artistName');
|
||||
const title = artist ? artist : genreId ? genreTitle : undefined;
|
||||
@@ -129,14 +73,8 @@ const TrackListRoute = () => {
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<SongListHeader
|
||||
genreId={genreId}
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
title={title}
|
||||
/>
|
||||
<SongListContent gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} />
|
||||
<SongListHeader title={title} />
|
||||
<SongListContent />
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user