From f1b5dc8ef363154b669b859ffb0a7ed6e7230db1 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 12 Feb 2026 21:48:29 -0800 Subject: [PATCH] add additional client-side filters to playlist songs --- src/i18n/locales/en.json | 2 + .../components/client-side-song-filters.tsx | 635 ++++++++++++++++++ .../components/playlist-detail-album-view.tsx | 30 +- ...aylist-detail-song-list-header-filters.tsx | 74 +- .../hooks/use-playlist-song-list-filters.ts | 124 +++- .../hooks/use-playlist-track-list.ts | 100 ++- .../playlist-detail-song-list-route.tsx | 72 +- src/renderer/features/shared/utils.ts | 6 +- src/renderer/store/app.store.ts | 32 + 9 files changed, 1028 insertions(+), 47 deletions(-) create mode 100644 src/renderer/features/playlists/components/client-side-song-filters.tsx 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: {