add additional client-side filters to playlist songs

This commit is contained in:
jeffvli
2026-02-12 21:48:29 -08:00
parent 78875572e9
commit f1b5dc8ef3
9 changed files with 1028 additions and 47 deletions
+2
View File
@@ -236,6 +236,8 @@
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"matchAnd": "and",
"matchOr": "or",
"albumCount": "$t(entity.album, {\"count\": 2}) count",
"artist": "$t(entity.artist, {\"count\": 1})",
"biography": "biography",
@@ -0,0 +1,635 @@
import type { RowComponentProps } from 'react-window-v2';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import {
ArtistMultiSelectRow,
GenreMultiSelectRow,
} from '/@/renderer/features/shared/components/multi-select-rows';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useCurrentServer } from '/@/renderer/store';
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import {
VirtualMultiSelect,
type VirtualMultiSelectOption,
} from '/@/shared/components/multi-select/virtual-multi-select';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
interface BooleanSegmentFilterProps {
label: string;
onChange: (value: boolean | null) => void;
segmentData: Array<{ label: string; value: string }>;
value: boolean | null | undefined;
}
function booleanToSegmentValue(value: boolean | null | undefined): string {
if (value === true) return 'true';
if (value === false) return 'false';
return 'none';
}
function segmentValueToBoolean(value: string): boolean | null {
if (value === 'true') return true;
if (value === 'false') return false;
return null;
}
const BooleanSegmentFilter = ({
label,
onChange,
segmentData,
value,
}: BooleanSegmentFilterProps) => (
<Stack gap="xs">
<Text size="sm" weight={500}>
{label}
</Text>
<SegmentedControl
data={segmentData}
onChange={(v) => onChange(segmentValueToBoolean(v))}
size="sm"
value={booleanToSegmentValue(value)}
w="100%"
/>
</Stack>
);
interface MultiSelectFilterOption {
albumCount: null | number;
imageUrl: string | undefined;
label: string;
songCount: number;
value: string;
}
interface MultiSelectFilterProps {
displayCountType?: 'song';
height: number;
label: React.ReactNode;
onChange: (value: null | string[]) => void;
options: MultiSelectFilterOption[];
RowComponent: (props: RowComponentProps<MultiSelectRowContext>) => React.ReactElement;
singleSelect: boolean;
value: string[];
}
type MultiSelectRowContext = {
disabled?: boolean;
displayCountType?: 'album' | 'song';
focusedIndex: null | number;
onToggle: (value: string) => void;
options: VirtualMultiSelectOption<MultiSelectFilterOption>[];
value: string[];
};
const MultiSelectFilter = ({
displayCountType = 'song',
height,
label,
onChange,
options,
RowComponent,
singleSelect,
value,
}: MultiSelectFilterProps) => (
<VirtualMultiSelect
displayCountType={displayCountType}
height={height}
label={label}
onChange={onChange}
options={options}
RowComponent={RowComponent}
singleSelect={singleSelect}
value={value}
/>
);
interface YearRangeFilterProps {
fromYearLabel: string;
maxYear: number | undefined;
minYear: number | undefined;
onMaxYear: (e: number | string) => void;
onMinYear: (e: number | string) => void;
toYearLabel: string;
}
const YearRangeFilter = ({
fromYearLabel,
maxYear,
minYear,
onMaxYear,
onMinYear,
toYearLabel,
}: YearRangeFilterProps) => (
<Group gap="sm" wrap="nowrap">
<NumberInput
hideControls={false}
label={fromYearLabel}
max={5000}
min={0}
onChange={(e) => onMinYear(e)}
style={{ flex: 1 }}
value={minYear != null ? minYear : ''}
/>
<NumberInput
hideControls={false}
label={toYearLabel}
max={5000}
min={0}
onChange={(e) => onMaxYear(e)}
style={{ flex: 1 }}
value={maxYear != null ? maxYear : ''}
/>
</Group>
);
interface MultiSelectFilterLabelProps {
andOrValue: 'and' | 'or';
entityLabel: string;
filterMultipleLabel: string;
filterSingleLabel: string;
matchAndLabel: string;
matchOrLabel: string;
onAndOrChange: (value: 'and' | 'or') => void;
onSingleMultiChange: (value: string) => void;
showAndOr: boolean;
singleMultiValue: 'multi' | 'single';
}
const MultiSelectFilterLabel = ({
andOrValue,
entityLabel,
filterMultipleLabel,
filterSingleLabel,
matchAndLabel,
matchOrLabel,
onAndOrChange,
onSingleMultiChange,
showAndOr,
singleMultiValue,
}: MultiSelectFilterLabelProps) => (
<Group gap="xs" justify="space-between" w="100%">
<Text fw={500} size="sm">
{entityLabel}
</Text>
<Group gap="xs">
{showAndOr && (
<SegmentedControl
data={[
{ label: matchAndLabel, value: 'and' },
{ label: matchOrLabel, value: 'or' },
]}
onChange={(value) => onAndOrChange(value === 'or' ? 'or' : 'and')}
size="xs"
value={andOrValue}
/>
)}
<SegmentedControl
data={[
{ label: filterSingleLabel, value: 'single' },
{ label: filterMultipleLabel, value: 'multi' },
]}
onChange={onSingleMultiChange}
size="xs"
value={singleMultiValue}
/>
</Group>
</Group>
);
export const ClientSideSongFilters = () => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const {
query,
setAlbumArtistIds,
setAlbumArtistIdsMode,
setArtistIds,
setArtistIdsMode,
setFavorite,
setGenreId,
setGenreIdsMode,
setHasRating,
setMaxYear,
setMinYear,
} = usePlaylistSongListFilters();
const playlistSongsQuery = useSuspenseQuery(
playlistsQueries.songList({
query: { id: playlistId },
serverId: server?.id,
}),
);
const albumArtistSelectMode = useAppStore((state) => state.albumArtistSelectMode);
const artistSelectMode = useAppStore((state) => state.artistSelectMode);
const genreSelectMode = useAppStore((state) => state.genreSelectMode);
const { setAlbumArtistSelectMode, setArtistSelectMode, setGenreSelectMode } =
useAppStoreActions();
const songs = useMemo(() => {
return (playlistSongsQuery.data?.items ?? []) as Song[];
}, [playlistSongsQuery.data]);
const filteredSongs = useMemo(
() => applyClientSideSongFilters(songs, query as Record<string, unknown>),
[songs, query],
);
const songsForAlbumArtistOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = albumArtistSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutAlbumArtist = {
...query,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutAlbumArtist);
}
return filteredSongs;
}, [albumArtistSelectMode, filteredSongs, query, songs]);
const songsForArtistOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = artistSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutArtist = {
...query,
[FILTER_KEYS.SONG.ARTIST_IDS]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutArtist);
}
return filteredSongs;
}, [artistSelectMode, filteredSongs, query, songs]);
const songsForGenreOptions = useMemo(() => {
const idsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const useFilteredResult = genreSelectMode === 'multi' && idsMode === 'and';
if (!useFilteredResult) {
const queryWithoutGenre = {
...query,
[FILTER_KEYS.SONG.GENRE_ID]: undefined,
} as Record<string, unknown>;
return applyClientSideSongFilters(songs, queryWithoutGenre);
}
return filteredSongs;
}, [filteredSongs, genreSelectMode, query, songs]);
const albumArtistOptions = useMemo(() => {
const byId = new Map<
string,
{ id: string; imageUrl: string | undefined; name: string; songCount: number }
>();
for (const song of songsForAlbumArtistOptions) {
for (const artist of song.albumArtists ?? []) {
if (!artist.id) continue;
const existing = byId.get(artist.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(artist.id, {
id: artist.id,
imageUrl:
artist.imageUrl ??
getItemImageUrl({
id: artist.id,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'table',
}),
name: artist.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((a) => ({
albumCount: null as null | number,
imageUrl: a.imageUrl,
label: a.name,
songCount: a.songCount,
value: a.id,
}));
}, [songsForAlbumArtistOptions]);
const artistOptions = useMemo(() => {
const byId = new Map<
string,
{ id: string; imageUrl: string | undefined; name: string; songCount: number }
>();
for (const song of songsForArtistOptions) {
for (const artist of song.artists ?? []) {
if (!artist.id) continue;
const existing = byId.get(artist.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(artist.id, {
id: artist.id,
imageUrl:
artist.imageUrl ??
getItemImageUrl({
id: artist.id,
itemType: LibraryItem.ARTIST,
type: 'table',
}),
name: artist.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((a) => ({
albumCount: null as null | number,
imageUrl: a.imageUrl,
label: a.name,
songCount: a.songCount,
value: a.id,
}));
}, [songsForArtistOptions]);
const genreOptions = useMemo(() => {
const byId = new Map<string, { id: string; name: string; songCount: number }>();
for (const song of songsForGenreOptions) {
for (const genre of song.genres ?? []) {
if (!genre.id) continue;
const existing = byId.get(genre.id);
if (existing) {
existing.songCount += 1;
} else {
byId.set(genre.id, {
id: genre.id,
name: genre.name,
songCount: 1,
});
}
}
}
return Array.from(byId.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((g) => ({
albumCount: null as null | number,
imageUrl: undefined,
label: g.name,
songCount: g.songCount,
value: g.id,
}));
}, [songsForGenreOptions]);
const segmentedControlData = useMemo(
() => [
{ label: t('common.none', { postProcess: 'titleCase' }), value: 'none' },
{ label: t('common.yes', { postProcess: 'titleCase' }), value: 'true' },
{ label: t('common.no', { postProcess: 'titleCase' }), value: 'false' },
],
[t],
);
const handleMinYear = useMemo(
() => (e: number | string) => {
if (e === '' || e === null || e === undefined) {
setMinYear(null);
return;
}
const year = typeof e === 'number' ? e : Number(e);
setMinYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);
},
[setMinYear],
);
const handleMaxYear = useMemo(
() => (e: number | string) => {
if (e === '' || e === null || e === undefined) {
setMaxYear(null);
return;
}
const year = typeof e === 'number' ? e : Number(e);
setMaxYear(!isNaN(year) && isFinite(year) && year > 0 ? year : null);
},
[setMaxYear],
);
const debouncedHandleMinYear = useDebouncedCallback(handleMinYear, 300);
const debouncedHandleMaxYear = useDebouncedCallback(handleMaxYear, 300);
const selectedGenreIds = useMemo(
() => (query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined) ?? [],
[query],
);
const handleGenreSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setGenreSelectMode(newMode);
if (newMode === 'single' && selectedGenreIds.length > 1) {
setGenreId([selectedGenreIds[0]]);
}
},
[selectedGenreIds, setGenreId, setGenreSelectMode],
);
const genreIdsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleGenreChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setGenreId(e);
} else {
setGenreId(null);
}
},
[setGenreId],
);
const selectedArtistIds = useMemo(
() => (query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined) ?? [],
[query],
);
const handleArtistSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setArtistSelectMode(newMode);
if (newMode === 'single' && selectedArtistIds.length > 1) {
setArtistIds([selectedArtistIds[0]]);
}
},
[selectedArtistIds, setArtistIds, setArtistSelectMode],
);
const artistIdsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleArtistChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setArtistIds(e);
} else {
setArtistIds(null);
}
},
[setArtistIds],
);
const selectedAlbumArtistIds = useMemo(
() => (query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined) ?? [],
[query],
);
const handleAlbumArtistSelectModeChange = useCallback(
(value: string) => {
const newMode = value as 'multi' | 'single';
setAlbumArtistSelectMode(newMode);
if (newMode === 'single' && selectedAlbumArtistIds.length > 1) {
setAlbumArtistIds([selectedAlbumArtistIds[0]]);
}
},
[selectedAlbumArtistIds, setAlbumArtistIds, setAlbumArtistSelectMode],
);
const albumArtistIdsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const handleAlbumArtistChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
setAlbumArtistIds(e);
} else {
setAlbumArtistIds(null);
}
},
[setAlbumArtistIds],
);
const queryFavorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;
const queryHasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;
const queryMinYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;
const queryMaxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;
const matchAndLabel = t('filter.matchAnd', { postProcess: 'titleCase' });
const matchOrLabel = t('filter.matchOr', { postProcess: 'titleCase' });
const filterSingleLabel = t('common.filter_single', { postProcess: 'titleCase' });
const filterMultipleLabel = t('common.filter_multiple', { postProcess: 'titleCase' });
return (
<Stack px="md" py="md">
<BooleanSegmentFilter
label={t('filter.isFavorited', { postProcess: 'sentenceCase' })}
onChange={setFavorite}
segmentData={segmentedControlData}
value={queryFavorite}
/>
<Stack gap="xs" mt="md">
<BooleanSegmentFilter
label={t('filter.isRated', { postProcess: 'sentenceCase' })}
onChange={setHasRating}
segmentData={segmentedControlData}
value={queryHasRating}
/>
</Stack>
<Divider my="md" />
<MultiSelectFilter
height={300}
label={
<MultiSelectFilterLabel
andOrValue={artistIdsMode}
entityLabel={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setArtistIdsMode}
onSingleMultiChange={handleArtistSelectModeChange}
showAndOr={artistSelectMode === 'multi'}
singleMultiValue={artistSelectMode}
/>
}
onChange={handleArtistChange}
options={artistOptions}
RowComponent={ArtistMultiSelectRow}
singleSelect={artistSelectMode === 'single'}
value={selectedArtistIds}
/>
<Divider my="md" />
<MultiSelectFilter
height={300}
label={
<MultiSelectFilterLabel
andOrValue={albumArtistIdsMode}
entityLabel={t('entity.albumArtist', {
count: 2,
postProcess: 'sentenceCase',
})}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setAlbumArtistIdsMode}
onSingleMultiChange={handleAlbumArtistSelectModeChange}
showAndOr={albumArtistSelectMode === 'multi'}
singleMultiValue={albumArtistSelectMode}
/>
}
onChange={handleAlbumArtistChange}
options={albumArtistOptions}
RowComponent={ArtistMultiSelectRow}
singleSelect={albumArtistSelectMode === 'single'}
value={selectedAlbumArtistIds}
/>
<Divider my="md" />
<MultiSelectFilter
height={220}
label={
<MultiSelectFilterLabel
andOrValue={genreIdsMode}
entityLabel={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
filterMultipleLabel={filterMultipleLabel}
filterSingleLabel={filterSingleLabel}
matchAndLabel={matchAndLabel}
matchOrLabel={matchOrLabel}
onAndOrChange={setGenreIdsMode}
onSingleMultiChange={handleGenreSelectModeChange}
showAndOr={genreSelectMode === 'multi'}
singleMultiValue={genreSelectMode}
/>
}
onChange={handleGenreChange}
options={genreOptions}
RowComponent={GenreMultiSelectRow}
singleSelect={genreSelectMode === 'single'}
value={selectedGenreIds}
/>
<Divider my="md" />
<YearRangeFilter
fromYearLabel={t('filter.fromYear', { postProcess: 'titleCase' })}
maxYear={queryMaxYear}
minYear={queryMinYear}
onMaxYear={debouncedHandleMaxYear}
onMinYear={debouncedHandleMinYear}
toYearLabel={t('filter.toYear', { postProcess: 'titleCase' })}
/>
</Stack>
);
};
@@ -15,6 +15,7 @@ import { useListContext } from '/@/renderer/context/list-context';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { applyClientSideSongFilters } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import { type PlaylistAlbumRow, playlistSongsToAlbums } from '/@/renderer/features/playlists/utils';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
@@ -40,18 +41,25 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const sortedAlbums = useMemo(() => {
let songs = data?.items ?? [];
if (searchTerm?.trim()) {
songs = searchLibraryItems(songs, searchTerm, LibraryItem.SONG);
}
const sortedSongs = sortSongList(
songs,
const filteredAndSortedSongs = useMemo(() => {
const raw = data?.items ?? [];
const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);
const searched = searchTerm?.trim()
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
: filtered;
return sortSongList(
searched,
(query.sortBy as SongListSort) ?? SongListSort.ID,
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
);
return playlistSongsToAlbums(sortedSongs);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
}, [data?.items, query, searchTerm]);
const sortedAlbums = useMemo(
() => playlistSongsToAlbums(filteredAndSortedSongs),
[filteredAndSortedSongs],
);
const isPaginated = pagination === ListPaginationType.PAGINATED;
const totalAlbumCount = sortedAlbums.length;
@@ -119,8 +127,8 @@ export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListRespon
}, [setItemCount, totalAlbumCount]);
useEffect(() => {
setListData?.(data?.items ?? []);
}, [data?.items, setListData]);
setListData?.(filteredAndSortedSongs);
}, [filteredAndSortedSongs, setListData]);
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: true });
const { handleColumnReordered } = useItemListColumnReorder({
@@ -1,6 +1,6 @@
import { openContextModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
@@ -13,12 +13,17 @@ import {
import { useListContext } from '/@/renderer/context/list-context';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { FilterButton } from '/@/renderer/features/shared/components/filter-button';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';
import { isFilterValueSet } from '/@/renderer/features/shared/components/list-filters';
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 { MoreButton } from '/@/renderer/features/shared/components/more-button';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useContainerQuery } from '/@/renderer/hooks';
import {
PlaylistTarget,
@@ -32,7 +37,9 @@ import { Divider } from '/@/shared/components/divider/divider';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Modal } from '/@/shared/components/modal/modal';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -41,6 +48,69 @@ interface PlaylistDetailSongListHeaderFiltersProps {
isSmartPlaylist?: boolean;
}
const PlaylistSongListFiltersModal = () => {
const { t } = useTranslation();
const { isSidebarOpen, setIsSidebarOpen } = useListContext();
const { clear, query } = usePlaylistSongListFilters();
const [isOpen, handlers] = useDisclosure(false);
const hasActiveFilters = useMemo(() => {
return Boolean(
isFilterValueSet(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]) ||
isFilterValueSet(query[FILTER_KEYS.SONG.ARTIST_IDS]) ||
query[FILTER_KEYS.SONG.FAVORITE] !== undefined ||
isFilterValueSet(query[FILTER_KEYS.SONG.GENRE_ID]) ||
query[FILTER_KEYS.SONG.HAS_RATING] !== undefined ||
query[FILTER_KEYS.SONG.MAX_YEAR] !== undefined ||
query[FILTER_KEYS.SONG.MIN_YEAR] !== undefined,
);
}, [query]);
const handlePin = () => {
setIsSidebarOpen?.(!isSidebarOpen);
};
const canPin = Boolean(setIsSidebarOpen);
return (
<>
<FilterButton isActive={hasActiveFilters} onClick={handlers.toggle} />
<Modal
handlers={handlers}
opened={isOpen}
size="lg"
styles={{
content: {
height: '100%',
maxHeight: '640px',
maxWidth: 'var(--theme-content-max-width)',
width: '100%',
},
}}
title={
<Group justify="space-between" style={{ paddingRight: '3rem', width: '100%' }}>
<Group>
{canPin && (
<ActionIcon
icon={isSidebarOpen ? 'unpin' : 'pin'}
onClick={handlePin}
variant="subtle"
/>
)}
{t('common.filters', { postProcess: 'sentenceCase' })}
</Group>
<Button onClick={clear} size="compact-sm" variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })}
</Button>
</Group>
}
>
<ClientSideSongFilters />
</Modal>
</>
);
};
export const PlaylistDetailSongListHeaderFilters = ({
isSmartPlaylist,
}: PlaylistDetailSongListHeaderFiltersProps) => {
@@ -114,6 +184,8 @@ export const PlaylistDetailSongListHeaderFilters = ({
disabled={isEditMode}
listKey={ItemListKey.PLAYLIST_SONG}
/>
<Divider orientation="vertical" />
<PlaylistSongListFiltersModal />
<ListRefreshButton disabled={isEditMode} listKey={listKey} />
<MoreButton onClick={handleMore} />
</Group>
@@ -5,17 +5,25 @@ import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-searc
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { useAppStore } from '/@/renderer/store/app.store';
import {
parseArrayParam,
parseBooleanParam,
parseCustomFiltersParam,
parseIntParam,
setMultipleSearchParams,
setSearchParam,
} from '/@/renderer/utils/query-params';
import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const usePlaylistSongListFilters = () => {
const albumArtistIdsMode = useAppStore((state) => state.albumArtistIdsMode);
const artistIdsMode = useAppStore((state) => state.artistIdsMode);
const genreIdsMode = useAppStore((state) => state.genreIdsMode);
const setAlbumArtistIdsModeStore = useAppStore((state) => state.actions.setAlbumArtistIdsMode);
const setArtistIdsModeStore = useAppStore((state) => state.actions.setArtistIdsMode);
const setGenreIdsModeStore = useAppStore((state) => state.actions.setGenreIdsMode);
const { sortBy } = useSortByFilter<SongListSort>(SongListSort.ID, ItemListKey.PLAYLIST_SONG);
const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.PLAYLIST_SONG);
@@ -24,8 +32,8 @@ export const usePlaylistSongListFilters = () => {
const [searchParams, setSearchParams] = useSearchParams();
const albumIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS),
const albumArtistIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS),
[searchParams],
);
@@ -54,16 +62,22 @@ export const usePlaylistSongListFilters = () => {
[searchParams],
);
const hasRating = useMemo(
() => parseBooleanParam(searchParams, FILTER_KEYS.SONG.HAS_RATING),
[searchParams],
);
const custom = useMemo(
() => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM),
[searchParams],
);
const setAlbumIds = useCallback(
const setAlbumArtistIds = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), {
replace: true,
});
setSearchParams(
(prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_ARTIST_IDS, value),
{ replace: true },
);
},
[setSearchParams],
);
@@ -113,6 +127,30 @@ export const usePlaylistSongListFilters = () => {
[setSearchParams],
);
const setHasRating = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value), {
replace: true,
});
},
[setSearchParams],
);
const setAlbumArtistIdsMode = useCallback(
(value: 'and' | 'or') => setAlbumArtistIdsModeStore(value),
[setAlbumArtistIdsModeStore],
);
const setArtistIdsMode = useCallback(
(value: 'and' | 'or') => setArtistIdsModeStore(value),
[setArtistIdsModeStore],
);
const setGenreIdsMode = useCallback(
(value: 'and' | 'or') => setGenreIdsModeStore(value),
[setGenreIdsModeStore],
);
const setCustom = useCallback(
(value: null | Record<string, any>) => {
setSearchParams(
@@ -141,26 +179,74 @@ export const usePlaylistSongListFilters = () => {
[setSearchParams],
);
const query = {
[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,
};
const clear = useCallback(() => {
setSearchParams(
(prev) =>
setMultipleSearchParams(
prev,
{
[FILTER_KEYS.SONG._CUSTOM]: null,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS]: null,
[FILTER_KEYS.SONG.ARTIST_IDS]: null,
[FILTER_KEYS.SONG.FAVORITE]: null,
[FILTER_KEYS.SONG.GENRE_ID]: null,
[FILTER_KEYS.SONG.HAS_RATING]: null,
[FILTER_KEYS.SONG.MAX_YEAR]: null,
[FILTER_KEYS.SONG.MIN_YEAR]: null,
},
new Set([FILTER_KEYS.SONG._CUSTOM]),
),
{ replace: true },
);
}, [setSearchParams]);
const query = useMemo(
() => ({
[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_ARTIST_IDS]: albumArtistIds ?? undefined,
[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE]: albumArtistIdsMode,
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
[FILTER_KEYS.SONG.ARTIST_IDS_MODE]: artistIdsMode,
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID_MODE]: genreIdsMode,
[FILTER_KEYS.SONG.HAS_RATING]: hasRating ?? undefined,
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
}),
[
searchTerm,
sortBy,
sortOrder,
custom,
albumArtistIds,
albumArtistIdsMode,
artistIds,
artistIdsMode,
favorite,
genreId,
genreIdsMode,
hasRating,
maxYear,
minYear,
],
);
return {
clear,
query,
setAlbumIds,
setAlbumArtistIds,
setAlbumArtistIdsMode,
setArtistIds,
setArtistIdsMode,
setCustom,
setFavorite,
setGenreId,
setGenreIdsMode,
setHasRating,
setMaxYear,
setMinYear,
setSearchTerm,
@@ -3,9 +3,88 @@ import { useEffect, useMemo } from 'react';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { sortSongList } from '/@/shared/api/utils';
import { LibraryItem, PlaylistSongListResponse, Song } from '/@/shared/types/domain-types';
import {
LibraryItem,
PlaylistSongListResponse,
Song,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
export function applyClientSideSongFilters(songs: Song[], query: Record<string, unknown>): Song[] {
let result = songs;
const favorite = query[FILTER_KEYS.SONG.FAVORITE] as boolean | undefined;
if (favorite === true) {
result = result.filter((s) => s.userFavorite === true);
} else if (favorite === false) {
result = result.filter((s) => s.userFavorite === false);
}
const hasRating = query[FILTER_KEYS.SONG.HAS_RATING] as boolean | undefined;
if (hasRating === true) {
result = result.filter((s) => s.userRating != null && s.userRating > 0);
} else if (hasRating === false) {
result = result.filter((s) => s.userRating == null || s.userRating === 0);
}
const albumArtistIdsMode =
(query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const albumArtistIds = query[FILTER_KEYS.SONG.ALBUM_ARTIST_IDS] as string[] | undefined;
if (albumArtistIds?.length) {
if (albumArtistIdsMode === 'and') {
result = result.filter((s) =>
albumArtistIds!.every((id) => s.albumArtists?.some((a) => a.id === id)),
);
} else {
const set = new Set(albumArtistIds);
result = result.filter((s) => s.albumArtists?.some((a) => a.id && set.has(a.id)));
}
}
const artistIdsMode =
(query[FILTER_KEYS.SONG.ARTIST_IDS_MODE] as 'and' | 'or' | undefined) ?? 'and';
const artistIds = query[FILTER_KEYS.SONG.ARTIST_IDS] as string[] | undefined;
if (artistIds?.length) {
if (artistIdsMode === 'and') {
result = result.filter((s) =>
artistIds!.every((id) => s.artists?.some((a) => a.id === id)),
);
} else {
const set = new Set(artistIds);
result = result.filter((s) => s.artists?.some((a) => a.id && set.has(a.id)));
}
}
const genreIdsMode =
(query[FILTER_KEYS.SONG.GENRE_ID_MODE] as 'and' | 'or' | undefined) ?? 'and';
const genreIds = query[FILTER_KEYS.SONG.GENRE_ID] as string[] | undefined;
if (genreIds?.length) {
if (genreIdsMode === 'and') {
result = result.filter((s) =>
genreIds!.every((id) => s.genres?.some((g) => g.id === id)),
);
} else {
const set = new Set(genreIds);
result = result.filter((s) => s.genres?.some((g) => g.id && set.has(g.id)));
}
}
const minYear = query[FILTER_KEYS.SONG.MIN_YEAR] as number | undefined;
if (minYear != null) {
result = result.filter((s) => s.releaseYear != null && s.releaseYear >= minYear);
}
const maxYear = query[FILTER_KEYS.SONG.MAX_YEAR] as number | undefined;
if (maxYear != null) {
result = result.filter((s) => s.releaseYear != null && s.releaseYear <= maxYear);
}
return result;
}
export function usePlaylistTrackList(data: PlaylistSongListResponse | undefined): {
sortedAndFilteredSongs: Song[];
@@ -17,20 +96,23 @@ export function usePlaylistTrackList(data: PlaylistSongListResponse | undefined)
const sortedAndFilteredSongs = useMemo(() => {
const raw = data?.items ?? [];
if (searchTerm) {
return searchLibraryItems(raw, searchTerm, LibraryItem.SONG);
}
return sortSongList(raw, query.sortBy, query.sortOrder);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const filtered = applyClientSideSongFilters(raw, query as Record<string, unknown>);
const searched = searchTerm
? searchLibraryItems(filtered, searchTerm, LibraryItem.SONG)
: filtered;
return sortSongList(
searched,
(query.sortBy as SongListSort) ?? SongListSort.ID,
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
);
}, [data?.items, query, searchTerm]);
const totalCount = sortedAndFilteredSongs.length;
useEffect(() => {
setListData?.(sortedAndFilteredSongs);
setItemCount?.(totalCount);
}, [sortedAndFilteredSongs, totalCount, setListData, setItemCount]);
}, [query, searchTerm, setListData, setItemCount, sortedAndFilteredSongs, totalCount]);
return { sortedAndFilteredSongs, totalCount };
}
@@ -4,8 +4,9 @@ import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, useLocation, useNavigate, useParams } from 'react-router';
import { ListContext } from '/@/renderer/context/list-context';
import { ListContext, useListContext } from '/@/renderer/context/list-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { ClientSideSongFilters } from '/@/renderer/features/playlists/components/client-side-song-filters';
import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
import { PlaylistDetailSongListHeader } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header';
import {
@@ -13,18 +14,27 @@ import {
PlaylistQueryBuilderRef,
} from '/@/renderer/features/playlists/components/playlist-query-builder';
import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AppRoute } from '/@/renderer/router/routes';
import { PlaylistTarget, useCurrentServer, usePlaylistTarget } from '/@/renderer/store';
import {
PlaylistTarget,
useCurrentServer,
usePageSidebar,
usePlaylistTarget,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { ConfirmModal } from '/@/shared/components/modal/modal';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
@@ -236,6 +246,38 @@ const PlaylistQueryEditor = ({
);
};
const PlaylistSongListFiltersSidebar = () => {
const { t } = useTranslation();
const { setIsSidebarOpen } = useListContext();
const { clear } = usePlaylistSongListFilters();
return (
<Stack h="100%" style={{ minHeight: 0 }}>
<Group justify="space-between" pb={0} pl="md" pr="md" pt="md">
<Text fw={500} size="xl">
{t('common.filters', { postProcess: 'sentenceCase' })}
</Text>
<Group gap="xs">
<Button onClick={clear} size="compact-sm" variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })}
</Button>
{setIsSidebarOpen && (
<ActionIcon
icon="unpin"
onClick={() => setIsSidebarOpen(false)}
size="compact-sm"
variant="subtle"
/>
)}
</Group>
</Group>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<ClientSideSongFilters />
</ScrollArea>
</Stack>
);
};
const PlaylistDetailSongListRoute = () => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -408,23 +450,36 @@ const PlaylistDetailSongListRoute = () => {
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
const [listData, setListData] = useState<unknown[]>([]);
const [mode, setMode] = useState<'edit' | 'view'>('view');
const [isSidebarOpen, setIsSidebarOpen] = usePageSidebar(listKey);
const providerValue = useMemo(() => {
return {
customFilters: undefined,
displayMode,
id: playlistId,
isSidebarOpen,
isSmartPlaylist,
itemCount,
listData,
listKey,
mode,
pageKey: listKey,
setIsSidebarOpen,
setItemCount,
setListData,
setMode,
};
}, [playlistId, isSmartPlaylist, displayMode, listKey, itemCount, listData, mode]);
}, [
playlistId,
isSmartPlaylist,
displayMode,
listKey,
isSidebarOpen,
itemCount,
listData,
mode,
setIsSidebarOpen,
]);
return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
@@ -441,9 +496,14 @@ const PlaylistDetailSongListRoute = () => {
onToggleQueryBuilder={handleToggleShowQueryBuilder}
/>
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
<ListWithSidebarContainer>
<ListWithSidebarContainer.SidebarPortal>
<PlaylistSongListFiltersSidebar />
</ListWithSidebarContainer.SidebarPortal>
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
</ListWithSidebarContainer>
{(isSmartPlaylist || showQueryBuilder) && (
<PlaylistQueryEditor
createPlaylistMutation={createPlaylistMutation}
+5 -1
View File
@@ -61,10 +61,14 @@ enum SharedFilterKeys {
enum SongFilterKeys {
_CUSTOM = '_custom',
ALBUM_IDS = 'albumIds',
ALBUM_ARTIST_IDS = 'albumArtistIds',
ALBUM_ARTIST_IDS_MODE = 'albumArtistIdsMode',
ARTIST_IDS = 'artistIds',
ARTIST_IDS_MODE = 'artistIdsMode',
FAVORITE = 'favorite',
GENRE_ID = 'genreIds',
GENRE_ID_MODE = 'genreIdsMode',
HAS_RATING = 'hasRating',
MAX_YEAR = 'maxYear',
MIN_YEAR = 'minYear',
}
+32
View File
@@ -10,8 +10,12 @@ export interface AppSlice extends AppState {
actions: {
setAlbumArtistDetailGroupingType: (groupingType: 'all' | 'primary') => void;
setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;
setAlbumArtistIdsMode: (mode: 'and' | 'or') => void;
setAlbumArtistSelectMode: (mode: 'multi' | 'single') => void;
setAppStore: (data: Partial<AppSlice>) => void;
setArtistIdsMode: (mode: 'and' | 'or') => void;
setArtistSelectMode: (mode: 'multi' | 'single') => void;
setGenreIdsMode: (mode: 'and' | 'or') => void;
setGenreSelectMode: (mode: 'multi' | 'single') => void;
setPageSidebar: (key: string, value: boolean) => void;
setPrivateMode: (enabled: boolean) => void;
@@ -27,8 +31,12 @@ export interface AppState {
sortBy: AlbumListSort;
sortOrder: SortOrder;
};
albumArtistIdsMode: 'and' | 'or';
albumArtistSelectMode: 'multi' | 'single';
artistIdsMode: 'and' | 'or';
artistSelectMode: 'multi' | 'single';
commandPalette: CommandPaletteProps;
genreIdsMode: 'and' | 'or';
genreSelectMode: 'multi' | 'single';
isReorderingQueue: boolean;
pageSidebar: Record<string, boolean>;
@@ -79,14 +87,34 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
};
});
},
setAlbumArtistIdsMode: (mode) => {
set((state) => {
state.albumArtistIdsMode = mode;
});
},
setAlbumArtistSelectMode: (mode) => {
set((state) => {
state.albumArtistSelectMode = mode;
});
},
setAppStore: (data) => {
set({ ...get(), ...data });
},
setArtistIdsMode: (mode) => {
set((state) => {
state.artistIdsMode = mode;
});
},
setArtistSelectMode: (mode) => {
set((state) => {
state.artistSelectMode = mode;
});
},
setGenreIdsMode: (mode) => {
set((state) => {
state.genreIdsMode = mode;
});
},
setGenreSelectMode: (mode) => {
set((state) => {
state.genreSelectMode = mode;
@@ -123,6 +151,9 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
},
albumArtistIdsMode: 'and',
albumArtistSelectMode: 'multi',
artistIdsMode: 'and',
artistSelectMode: 'multi',
commandPalette: {
close: () => {
@@ -142,6 +173,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
});
},
},
genreIdsMode: 'and',
genreSelectMode: 'multi',
isReorderingQueue: false,
pageSidebar: {