diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 262d3b98b..2938f9a79 100755
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -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",
diff --git a/src/renderer/features/playlists/components/client-side-song-filters.tsx b/src/renderer/features/playlists/components/client-side-song-filters.tsx
new file mode 100644
index 000000000..e37587018
--- /dev/null
+++ b/src/renderer/features/playlists/components/client-side-song-filters.tsx
@@ -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) => (
+
+
+ {label}
+
+ onChange(segmentValueToBoolean(v))}
+ size="sm"
+ value={booleanToSegmentValue(value)}
+ w="100%"
+ />
+
+);
+
+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) => React.ReactElement;
+ singleSelect: boolean;
+ value: string[];
+}
+
+type MultiSelectRowContext = {
+ disabled?: boolean;
+ displayCountType?: 'album' | 'song';
+ focusedIndex: null | number;
+ onToggle: (value: string) => void;
+ options: VirtualMultiSelectOption[];
+ value: string[];
+};
+
+const MultiSelectFilter = ({
+ displayCountType = 'song',
+ height,
+ label,
+ onChange,
+ options,
+ RowComponent,
+ singleSelect,
+ value,
+}: MultiSelectFilterProps) => (
+
+);
+
+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) => (
+
+ onMinYear(e)}
+ style={{ flex: 1 }}
+ value={minYear != null ? minYear : ''}
+ />
+ onMaxYear(e)}
+ style={{ flex: 1 }}
+ value={maxYear != null ? maxYear : ''}
+ />
+
+);
+
+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) => (
+
+
+ {entityLabel}
+
+
+ {showAndOr && (
+ onAndOrChange(value === 'or' ? 'or' : 'and')}
+ size="xs"
+ value={andOrValue}
+ />
+ )}
+
+
+
+);
+
+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),
+ [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;
+ 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;
+ 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;
+ 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();
+ 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 (
+
+
+
+
+
+
+
+ }
+ onChange={handleArtistChange}
+ options={artistOptions}
+ RowComponent={ArtistMultiSelectRow}
+ singleSelect={artistSelectMode === 'single'}
+ value={selectedArtistIds}
+ />
+
+
+ }
+ onChange={handleAlbumArtistChange}
+ options={albumArtistOptions}
+ RowComponent={ArtistMultiSelectRow}
+ singleSelect={albumArtistSelectMode === 'single'}
+ value={selectedAlbumArtistIds}
+ />
+
+
+ }
+ onChange={handleGenreChange}
+ options={genreOptions}
+ RowComponent={GenreMultiSelectRow}
+ singleSelect={genreSelectMode === 'single'}
+ value={selectedGenreIds}
+ />
+
+
+
+ );
+};
diff --git a/src/renderer/features/playlists/components/playlist-detail-album-view.tsx b/src/renderer/features/playlists/components/playlist-detail-album-view.tsx
index 664a8f4d9..16ac0e26b 100644
--- a/src/renderer/features/playlists/components/playlist-detail-album-view.tsx
+++ b/src/renderer/features/playlists/components/playlist-detail-album-view.tsx
@@ -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);
+
+ 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({
diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx
index ea00bba78..b02f5ea81 100644
--- a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx
+++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx
@@ -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 (
+ <>
+
+
+
+ {canPin && (
+
+ )}
+ {t('common.filters', { postProcess: 'sentenceCase' })}
+
+
+
+ }
+ >
+
+
+ >
+ );
+};
+
export const PlaylistDetailSongListHeaderFilters = ({
isSmartPlaylist,
}: PlaylistDetailSongListHeaderFiltersProps) => {
@@ -114,6 +184,8 @@ export const PlaylistDetailSongListHeaderFilters = ({
disabled={isEditMode}
listKey={ItemListKey.PLAYLIST_SONG}
/>
+
+
diff --git a/src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts b/src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts
index 767d245d1..c5b5bfa1e 100644
--- a/src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts
+++ b/src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts
@@ -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.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) => {
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,
diff --git a/src/renderer/features/playlists/hooks/use-playlist-track-list.ts b/src/renderer/features/playlists/hooks/use-playlist-track-list.ts
index c5f961cd4..92c8926b3 100644
--- a/src/renderer/features/playlists/hooks/use-playlist-track-list.ts
+++ b/src/renderer/features/playlists/hooks/use-playlist-track-list.ts
@@ -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): 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);
+ 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 };
}
diff --git a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx
index fbf839785..8aa70aa26 100644
--- a/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx
+++ b/src/renderer/features/playlists/routes/playlist-detail-song-list-route.tsx
@@ -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 (
+
+
+
+ {t('common.filters', { postProcess: 'sentenceCase' })}
+
+
+
+ {setIsSidebarOpen && (
+ setIsSidebarOpen(false)}
+ size="compact-sm"
+ variant="subtle"
+ />
+ )}
+
+
+
+
+
+
+ );
+};
+
const PlaylistDetailSongListRoute = () => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -408,23 +450,36 @@ const PlaylistDetailSongListRoute = () => {
const [itemCount, setItemCount] = useState(undefined);
const [listData, setListData] = useState([]);
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 (
@@ -441,9 +496,14 @@ const PlaylistDetailSongListRoute = () => {
onToggleQueryBuilder={handleToggleShowQueryBuilder}
/>
- }>
-
-
+
+
+
+
+ }>
+
+
+
{(isSmartPlaylist || showQueryBuilder) && (
void;
setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;
+ setAlbumArtistIdsMode: (mode: 'and' | 'or') => void;
+ setAlbumArtistSelectMode: (mode: 'multi' | 'single') => void;
setAppStore: (data: Partial) => 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;
@@ -79,14 +87,34 @@ export const useAppStore = createWithEqualityFn()(
};
});
},
+ 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()(
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()(
});
},
},
+ genreIdsMode: 'and',
genreSelectMode: 'multi',
isReorderingQueue: false,
pageSidebar: {