From 5b519320c296010d71b37d58e9d0a1bf66fdbb38 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 17 Jan 2026 16:56:35 -0800 Subject: [PATCH] enhance album/song list filters --- src/i18n/locales/en.json | 2 + .../components/jellyfin-album-filters.tsx | 153 ++++++++++-- .../components/navidrome-album-filters.tsx | 222 +++++++++++++---- .../components/subsonic-album-filters.tsx | 140 ++++++++--- .../shared/components/multi-select-rows.tsx | 34 ++- .../components/jellyfin-song-filters.tsx | 195 +++++++++++++-- .../components/navidrome-song-filters.tsx | 235 +++++++++++++++--- .../components/subsonic-song-filters.tsx | 37 ++- src/renderer/store/app.store.ts | 16 ++ .../multi-select/virtual-multi-select.tsx | 13 +- 10 files changed, 880 insertions(+), 167 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 15c2eaf1c..4e4f3254f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -90,6 +90,8 @@ "filter_one": "filter", "filter_other": "filters", "filters": "filters", + "filter_single": "single", + "filter_multiple": "multi", "forceRestartRequired": "restart to apply changes… close the notification to restart", "forward": "forward", "gap": "gap", diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index 45589eae7..89c68df7f 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -2,18 +2,26 @@ import { useQuery } from '@tanstack/react-query'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { useListContext } from '/@/renderer/context/list-context'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; +import { + ArtistMultiSelectRow, + GenreMultiSelectRow, +} from '/@/renderer/features/shared/components/multi-select-rows'; import { useCurrentServerId } 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 } from '/@/shared/components/multi-select/virtual-multi-select'; import { NumberInput } from '/@/shared/components/number-input/number-input'; -import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; +import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select'; import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; import { @@ -61,7 +69,9 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte const genreList = useMemo(() => { if (!genreListQuery?.data) return []; return genreListQuery.data.items.map((genre) => ({ + albumCount: genre.albumCount, label: genre.name, + songCount: genre.songCount, value: genre.id, })); }, [genreListQuery.data]); @@ -173,7 +183,14 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte if (!albumArtistListQuery?.data?.items) return []; return albumArtistListQuery?.data?.items?.map((artist) => ({ + albumCount: artist.albumCount, + imageUrl: getItemImageUrl({ + id: artist.id, + itemType: LibraryItem.ARTIST, + type: 'table', + }), label: artist.name, + songCount: artist.songCount, value: artist.id, })); }, [albumArtistListQuery.data?.items]); @@ -195,6 +212,87 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300); const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300); + const artistSelectMode = useAppStore((state) => state.artistSelectMode); + const genreSelectMode = useAppStore((state) => state.genreSelectMode); + const { setArtistSelectMode, setGenreSelectMode } = useAppStoreActions(); + + const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]); + const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]); + + const handleArtistSelectModeChange = useCallback( + (value: string) => { + const newMode = value as 'multi' | 'single'; + setArtistSelectMode(newMode); + + if (newMode === 'single' && selectedArtistIds.length > 1) { + setAlbumArtist([selectedArtistIds[0]]); + } + }, + [selectedArtistIds, setAlbumArtist, setArtistSelectMode], + ); + + 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 artistFilterLabel = useMemo(() => { + return ( + + + {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })} + + + + ); + }, [artistSelectMode, handleArtistSelectModeChange, t]); + + const genreFilterLabel = useMemo(() => { + return ( + + + {t('entity.genre', { count: 2, postProcess: 'sentenceCase' })} + + + + ); + }, [genreSelectMode, handleGenreSelectModeChange, t]); + return ( {yesNoFilter.map((filter) => ( @@ -205,6 +303,38 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte onChange={(e) => filter.onChange(e ? e === 'true' : undefined)} /> ))} + {!disableArtistFilter && ( + <> + + + + )} + {!isGenrePage && ( + <> + + + + )} - {!isGenrePage && ( - - )} - : undefined} - searchable - /> {tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && ( state.artistSelectMode); + const genreSelectMode = useAppStore((state) => state.genreSelectMode); + const { setArtistSelectMode, setGenreSelectMode } = useAppStoreActions(); const isGenrePage = customFilters?.genreIds !== undefined; @@ -52,29 +61,43 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil const genreList = useMemo(() => { if (!genreListQuery?.data) return []; return genreListQuery.data.items.map((genre) => ({ + albumCount: genre.albumCount, label: genre.name, + songCount: genre.songCount, value: genre.id, })); }, [genreListQuery.data]); - const yesNoUndefinedFilters = useMemo( + // Helper function to convert boolean/null to segment value + const booleanToSegmentValue = (value: boolean | null | undefined): string => { + if (value === true) return 'true'; + if (value === false) return 'false'; + return 'none'; + }; + + // Helper function to convert segment value to boolean/null + const segmentValueToBoolean = (value: string): boolean | null => { + if (value === 'true') return true; + if (value === 'false') return false; + return null; + }; + + const segmentedControlData = useMemo( () => [ { - label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), - onChange: (favorite?: boolean) => { - setFavorite(favorite ?? null); - }, - value: query.favorite, + label: t('common.none', { postProcess: 'titleCase' }), + value: 'none', }, { - label: t('filter.isCompilation', { postProcess: 'sentenceCase' }), - onChange: (compilation?: boolean) => { - setCompilation(compilation ?? null); - }, - value: query.compilation, + label: t('common.yes', { postProcess: 'titleCase' }), + value: 'true', + }, + { + label: t('common.no', { postProcess: 'titleCase' }), + value: 'false', }, ], - [t, query.favorite, query.compilation, setFavorite, setCompilation], + [t], ); const toggleFilters = useMemo( @@ -141,7 +164,14 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil if (!albumArtistListQuery?.data?.items) return []; return albumArtistListQuery?.data?.items?.map((artist) => ({ + albumCount: artist.albumCount, + imageUrl: getItemImageUrl({ + id: artist.id, + itemType: LibraryItem.ARTIST, + type: 'table', + }), label: artist.name, + songCount: artist.songCount, value: artist.id, })); }, [albumArtistListQuery.data?.items]); @@ -159,6 +189,9 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil [setGenreId], ); + const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]); + const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]); + const handleAlbumArtistChange = useCallback( (e: null | string[]) => { if (e && e.length > 0) { @@ -170,23 +203,147 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil [setAlbumArtist], ); + const handleArtistSelectModeChange = useCallback( + (value: string) => { + const newMode = value as 'multi' | 'single'; + setArtistSelectMode(newMode); + + if (newMode === 'single' && selectedArtistIds.length > 1) { + setAlbumArtist([selectedArtistIds[0]]); + } + }, + [selectedArtistIds, setAlbumArtist, setArtistSelectMode], + ); + + 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 artistFilterLabel = useMemo(() => { + return ( + + + {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })} + + + + ); + }, [artistSelectMode, handleArtistSelectModeChange, t]); + + const genreFilterLabel = useMemo(() => { + return ( + + + {t('entity.genre', { count: 2, postProcess: 'sentenceCase' })} + + + + ); + }, [genreSelectMode, handleGenreSelectModeChange, t]); + return ( - {yesNoUndefinedFilters.map((filter) => ( - filter.onChange(e ? e === 'true' : undefined)} + + + {t('filter.isFavorited', { postProcess: 'sentenceCase' })} + + { + setFavorite(segmentValueToBoolean(value)); + }} + size="sm" + w="100%" /> - ))} + + + + {t('filter.isCompilation', { postProcess: 'sentenceCase' })} + + { + setCompilation(segmentValueToBoolean(value)); + }} + size="sm" + w="100%" + /> + {toggleFilters.map((filter) => ( {filter.label} ))} + {!disableArtistFilter && ( + <> + + + + )} + {!isGenrePage && ( + <> + + + + )} debouncedHandleYearFilter(e)} /> - {!isGenrePage && ( - - )} - : undefined} - searchable - /> diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx index 37e9060ae..9ac52bf22 100644 --- a/src/renderer/features/albums/components/subsonic-album-filters.tsx +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -1,23 +1,28 @@ import { useSuspenseQuery } from '@tanstack/react-query'; -import { ChangeEvent, useCallback, useMemo, useState } from 'react'; +import { ChangeEvent, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; +import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { useListContext } from '/@/renderer/context/list-context'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; +import { + ArtistMultiSelectRow, + GenreMultiSelectRow, +} from '/@/renderer/features/shared/components/multi-select-rows'; import { useCurrentServerId } 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 } from '/@/shared/components/multi-select/virtual-multi-select'; import { NumberInput } from '/@/shared/components/number-input/number-input'; -import { Select } from '/@/shared/components/select/select'; -import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; +import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; import { Text } from '/@/shared/components/text/text'; import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; -import { AlbumArtistListSort, SortOrder } from '/@/shared/types/domain-types'; +import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types'; interface SubsonicAlbumFiltersProps { disableArtistFilter?: boolean; @@ -35,8 +40,6 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte const { query, setAlbumArtist, setFavorite, setGenreId, setMaxYear, setMinYear } = useAlbumListFilters(); - const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState(''); - const albumArtistListQuery = useSuspenseQuery( artistsQueries.albumArtistList({ options: { @@ -58,7 +61,14 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte if (!items) return []; return items.map((artist) => ({ + albumCount: artist.albumCount, + imageUrl: getItemImageUrl({ + id: artist.id, + itemType: LibraryItem.ARTIST, + type: 'table', + }), label: artist.name, + songCount: artist.songCount, value: artist.id, })); }, [items]); @@ -75,18 +85,34 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte const genreList = useMemo(() => { if (!genreListQuery?.data) return []; return genreListQuery.data.items.map((genre) => ({ + albumCount: genre.albumCount, label: genre.name, + songCount: genre.songCount, value: genre.id, })); }, [genreListQuery.data]); + const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]); + const handleGenresFilter = useCallback( - (e: null | string) => { - setGenreId(e ? [e] : null); + (e: null | string[]) => { + if (e && e.length > 0) { + setGenreId([e[0]]); + } else { + setGenreId(null); + } }, [setGenreId], ); + const genreFilterLabel = useMemo(() => { + return ( + + {t('entity.genre', { count: 1, postProcess: 'sentenceCase' })} + + ); + }, [t]); + const toggleFilters = useMemo( () => [ { @@ -142,6 +168,48 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300); const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300); + const artistSelectMode = useAppStore((state) => state.artistSelectMode); + const { setArtistSelectMode } = useAppStoreActions(); + + const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]); + + const handleArtistSelectModeChange = useCallback( + (value: string) => { + const newMode = value as 'multi' | 'single'; + setArtistSelectMode(newMode); + + if (newMode === 'single' && selectedArtistIds.length > 1) { + setAlbumArtist([selectedArtistIds[0]]); + } + }, + [selectedArtistIds, setAlbumArtist, setArtistSelectMode], + ); + + const artistFilterLabel = useMemo(() => { + return ( + + + {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })} + + + + ); + }, [artistSelectMode, handleArtistSelectModeChange, t]); + return ( {toggleFilters.map((filter) => ( @@ -150,6 +218,36 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte ))} + {!disableArtistFilter && ( + <> + + + + )} + {!isGenrePage && ( + <> + + + + )} debouncedHandleMaxYearFilter(e)} /> - {!isGenrePage && ( -