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 && (
-
- )}
- : undefined}
- searchable
- searchValue={albumArtistSearchTerm}
- />
);
};
diff --git a/src/renderer/features/shared/components/multi-select-rows.tsx b/src/renderer/features/shared/components/multi-select-rows.tsx
index 35b6a4dbd..48d9ff322 100644
--- a/src/renderer/features/shared/components/multi-select-rows.tsx
+++ b/src/renderer/features/shared/components/multi-select-rows.tsx
@@ -11,17 +11,20 @@ import { Text } from '/@/shared/components/text/text';
import { LibraryItem } from '/@/shared/types/domain-types';
export function ArtistMultiSelectRow({
+ displayCountType = 'album',
focusedIndex,
index,
onToggle,
options,
style,
}: RowComponentProps<{
+ displayCountType?: 'album' | 'song';
focusedIndex: null | number;
onToggle: (value: string) => void;
options: VirtualMultiSelectOption<{
albumCount: null | number;
imageUrl: string | undefined;
+ songCount: null | number;
}>[];
value: string[];
}>) {
@@ -32,6 +35,9 @@ export function ArtistMultiSelectRow({
}, [onToggle, options, index]);
const isFocused = focusedIndex === index;
+ const count =
+ displayCountType === 'song' ? options[index].songCount : options[index].albumCount;
+ const countEntity = displayCountType === 'song' ? 'song' : 'album';
return (
- {options[index].albumCount ? (
+ {count ? (
<>
- {options[index].albumCount}{' '}
- {t('entity.album', { count: options[index].albumCount })}
+ {count} {t(`entity.${countEntity}`, { count })}
>
- ) : (
- <> >
- )}
+ ) : null}
@@ -67,15 +70,20 @@ export function ArtistMultiSelectRow({
}
export function GenreMultiSelectRow({
+ displayCountType = 'album',
focusedIndex,
index,
onToggle,
options,
style,
}: RowComponentProps<{
+ displayCountType?: 'album' | 'song';
focusedIndex: null | number;
onToggle: (value: string) => void;
- options: VirtualMultiSelectOption<{ albumCount: null | number }>[];
+ options: VirtualMultiSelectOption<{
+ albumCount: null | number;
+ songCount: null | number;
+ }>[];
value: string[];
}>) {
const { t } = useTranslation();
@@ -85,6 +93,9 @@ export function GenreMultiSelectRow({
}, [onToggle, options, index]);
const isFocused = focusedIndex === index;
+ const count =
+ displayCountType === 'song' ? options[index].songCount : options[index].albumCount;
+ const countEntity = displayCountType === 'song' ? 'song' : 'album';
return (
- {options[index].albumCount ? (
+ {count ? (
<>
- {options[index].albumCount}{' '}
- {t('entity.album', { count: options[index].albumCount })}
+ {count} {t(`entity.${countEntity}`, { count })}
>
- ) : (
- <> >
- )}
+ ) : null}
diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx
index 7e4400b11..7b163e50e 100644
--- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx
+++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx
@@ -1,25 +1,41 @@
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useSuspenseQuery } 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 { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { useGenreList } 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 { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
-import { useCurrentServerId } from '/@/renderer/store';
+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 } 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 { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
-import { LibraryItem } from '/@/shared/types/domain-types';
+import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
-export const JellyfinSongFilters = () => {
- const serverId = useCurrentServerId();
+interface JellyfinSongFiltersProps {
+ disableArtistFilter?: boolean;
+}
+
+export const JellyfinSongFilters = ({ disableArtistFilter }: JellyfinSongFiltersProps) => {
+ const server = useCurrentServer();
+ const serverId = server.id;
const { t } = useTranslation();
- const { query, setCustom, setFavorite, setMaxYear, setMinYear } = useSongListFilters();
+ const { query, setArtistIds, setCustom, setFavorite, setMaxYear, setMinYear } =
+ useSongListFilters();
const { customFilters } = useListContext();
@@ -32,11 +48,44 @@ export const JellyfinSongFilters = () => {
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 albumArtistListQuery = useSuspenseQuery(
+ artistsQueries.albumArtistList({
+ options: {
+ gcTime: 1000 * 60 * 2,
+ staleTime: 1000 * 60 * 1,
+ },
+ query: {
+ sortBy: AlbumArtistListSort.NAME,
+ sortOrder: SortOrder.ASC,
+ startIndex: 0,
+ },
+ serverId,
+ }),
+ );
+
+ const selectableAlbumArtists = useMemo(() => {
+ 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]);
+
const tagsQuery = useQuery(
sharedQueries.tagList({
query: {
@@ -46,8 +95,10 @@ export const JellyfinSongFilters = () => {
}),
);
+ const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);
+
const selectedGenres = useMemo(() => {
- return query._custom?.GenreIds?.split(',');
+ return query._custom?.GenreIds?.split(',') || [];
}, [query._custom?.GenreIds]);
const selectedTags = useMemo(() => {
@@ -136,6 +187,95 @@ export const JellyfinSongFilters = () => {
[setCustom],
);
+ const artistSelectMode = useAppStore((state) => state.artistSelectMode);
+ const genreSelectMode = useAppStore((state) => state.genreSelectMode);
+ const { setArtistSelectMode, setGenreSelectMode } = useAppStoreActions();
+
+ 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 artistFilterLabel = useMemo(() => {
+ return (
+
+
+ {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
+
+
+
+ );
+ }, [artistSelectMode, handleArtistSelectModeChange, t]);
+
+ const handleArtistChange = useCallback(
+ (e: null | string[]) => {
+ if (e && e.length > 0) {
+ setArtistIds(e);
+ } else {
+ setArtistIds(null);
+ }
+ },
+ [setArtistIds],
+ );
+
+ const handleGenreSelectModeChange = useCallback(
+ (value: string) => {
+ const newMode = value as 'multi' | 'single';
+ setGenreSelectMode(newMode);
+
+ if (newMode === 'single' && selectedGenres.length > 1) {
+ handleGenresFilter([selectedGenres[0]]);
+ }
+ },
+ [selectedGenres, handleGenresFilter, setGenreSelectMode],
+ );
+
+ const genreFilterLabel = useMemo(() => {
+ return (
+
+
+ {t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
+
+
+
+ );
+ }, [genreSelectMode, handleGenreSelectModeChange, t]);
+
return (
{yesNoFilters.map((filter) => (
@@ -146,6 +286,37 @@ export const JellyfinSongFilters = () => {
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
/>
))}
+ {!disableArtistFilter && (
+ <>
+
+
+ >
+ )}
+ {!isGenrePage && (
+ <>
+
+
+ >
+ )}
{
required={!!query.minYear}
/>
- {!isGenrePage && (
-
- )}
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
{
const { t } = useTranslation();
- const { query, setFavorite, setGenreId, setMaxYear, setMinYear } = useSongListFilters();
+ const server = useCurrentServer();
+ const serverId = server.id;
+ const { query, setArtistIds, setFavorite, setGenreId, setMaxYear, setMinYear } =
+ useSongListFilters();
const { customFilters } = useListContext();
@@ -30,22 +43,74 @@ export const NavidromeSongFilters = () => {
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(
+ const albumArtistListQuery = useSuspenseQuery(
+ artistsQueries.albumArtistList({
+ options: {
+ gcTime: 1000 * 60 * 2,
+ staleTime: 1000 * 60 * 1,
+ },
+ query: {
+ sortBy: AlbumArtistListSort.NAME,
+ sortOrder: SortOrder.ASC,
+ startIndex: 0,
+ },
+ serverId,
+ }),
+ );
+
+ const selectableAlbumArtists = useMemo(() => {
+ 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]);
+
+ // 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('common.yes', { postProcess: 'titleCase' }),
+ value: 'true',
+ },
+ {
+ label: t('common.no', { postProcess: 'titleCase' }),
+ value: 'false',
},
],
- [t, query.favorite, setFavorite],
+ [t],
);
const handleYearFilter = useMemo(
@@ -73,6 +138,48 @@ export const NavidromeSongFilters = () => {
const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300);
+ const genreSelectMode = useAppStore((state) => state.genreSelectMode);
+ const { setGenreSelectMode } = useAppStoreActions();
+
+ const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);
+
+ 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 genreFilterLabel = useMemo(() => {
+ return (
+
+
+ {t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
+
+
+
+ );
+ }, [genreSelectMode, handleGenreSelectModeChange, t]);
+
const handleGenreChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
@@ -84,18 +191,98 @@ export const NavidromeSongFilters = () => {
[setGenreId],
);
+ 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) {
+ setArtistIds([selectedArtistIds[0]]);
+ }
+ },
+ [selectedArtistIds, setArtistIds, setArtistSelectMode],
+ );
+
+ const artistFilterLabel = useMemo(() => {
+ return (
+
+
+ {t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
+
+
+
+ );
+ }, [artistSelectMode, handleArtistSelectModeChange, t]);
+
+ const handleArtistChange = useCallback(
+ (e: null | string[]) => {
+ if (e && e.length > 0) {
+ setArtistIds(e);
+ } else {
+ setArtistIds(null);
+ }
+ },
+ [setArtistIds],
+ );
+
return (
- {yesNoUndefinedFilters.map((filter) => (
- filter.onChange(e ? e === 'true' : undefined)}
+
+
+ {t('filter.isFavorited', { postProcess: 'sentenceCase' })}
+
+ {
+ setFavorite(segmentValueToBoolean(value));
+ }}
+ size="sm"
+ w="100%"
/>
- ))}
+
+
+ {!isGenrePage && (
+
+ )}
{
min={0}
onChange={(e) => debouncedHandleYearFilter(e)}
/>
- {!isGenrePage && (
-
- )}
diff --git a/src/renderer/features/songs/components/subsonic-song-filters.tsx b/src/renderer/features/songs/components/subsonic-song-filters.tsx
index 9a8afe7dc..cb0cfd787 100644
--- a/src/renderer/features/songs/components/subsonic-song-filters.tsx
+++ b/src/renderer/features/songs/components/subsonic-song-filters.tsx
@@ -1,12 +1,13 @@
import { ChangeEvent, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
-import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import { useListContext } from '/@/renderer/context/list-context';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
+import { GenreMultiSelectRow } from '/@/renderer/features/shared/components/multi-select-rows';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
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 { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text';
@@ -24,18 +25,34 @@ export const SubsonicSongFilters = () => {
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(
() => [
{
@@ -61,13 +78,15 @@ export const SubsonicSongFilters = () => {
{!isGenrePage && (
<>
-
>
)}
diff --git a/src/renderer/store/app.store.ts b/src/renderer/store/app.store.ts
index f5ef70b06..80c688ef5 100644
--- a/src/renderer/store/app.store.ts
+++ b/src/renderer/store/app.store.ts
@@ -11,6 +11,8 @@ export interface AppSlice extends AppState {
setAlbumArtistDetailGroupingType: (groupingType: 'all' | 'primary') => void;
setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;
setAppStore: (data: Partial) => void;
+ setArtistSelectMode: (mode: 'multi' | 'single') => void;
+ setGenreSelectMode: (mode: 'multi' | 'single') => void;
setPageSidebar: (key: string, value: boolean) => void;
setPrivateMode: (enabled: boolean) => void;
setShowTimeRemaining: (enabled: boolean) => void;
@@ -25,7 +27,9 @@ export interface AppState {
sortBy: AlbumListSort;
sortOrder: SortOrder;
};
+ artistSelectMode: 'multi' | 'single';
commandPalette: CommandPaletteProps;
+ genreSelectMode: 'multi' | 'single';
isReorderingQueue: boolean;
pageSidebar: Record;
platform: Platform;
@@ -78,6 +82,16 @@ export const useAppStore = createWithEqualityFn()(
setAppStore: (data) => {
set({ ...get(), ...data });
},
+ setArtistSelectMode: (mode) => {
+ set((state) => {
+ state.artistSelectMode = mode;
+ });
+ },
+ setGenreSelectMode: (mode) => {
+ set((state) => {
+ state.genreSelectMode = mode;
+ });
+ },
setPageSidebar: (key, value) => {
set((state) => {
state.pageSidebar[key] = value;
@@ -109,6 +123,7 @@ export const useAppStore = createWithEqualityFn()(
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
},
+ artistSelectMode: 'multi',
commandPalette: {
close: () => {
set((state) => {
@@ -127,6 +142,7 @@ export const useAppStore = createWithEqualityFn()(
});
},
},
+ genreSelectMode: 'multi',
isReorderingQueue: false,
pageSidebar: {
album: true,
diff --git a/src/shared/components/multi-select/virtual-multi-select.tsx b/src/shared/components/multi-select/virtual-multi-select.tsx
index 1aaab3e3b..3313311ac 100644
--- a/src/shared/components/multi-select/virtual-multi-select.tsx
+++ b/src/shared/components/multi-select/virtual-multi-select.tsx
@@ -9,6 +9,7 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Center } from '/@/shared/components/center/center';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
+import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text';
@@ -16,12 +17,15 @@ import { Text } from '/@/shared/components/text/text';
export type VirtualMultiSelectOption = T & { label: string; value: string };
interface VirtualMultiSelectProps {
+ displayCountType?: 'album' | 'song';
height: number;
+ isLoading?: boolean;
label?: React.ReactNode | string;
onChange: (value: null | string[]) => void;
options: VirtualMultiSelectOption[];
RowComponent: (
props: RowComponentProps<{
+ displayCountType?: 'album' | 'song';
focusedIndex: null | number;
onToggle: (value: string) => void;
options: VirtualMultiSelectOption[];
@@ -33,7 +37,9 @@ interface VirtualMultiSelectProps {
}
export function VirtualMultiSelect({
+ displayCountType = 'album',
height,
+ isLoading = false,
label,
onChange,
options,
@@ -235,7 +241,11 @@ export function VirtualMultiSelect({
style={{ height: `${height}px` }}
tabIndex={0}
>
- {stableOptions.length === 0 ? (
+ {isLoading ? (
+
+
+
+ ) : stableOptions.length === 0 ? (
{t('common.noResultsFromQuery', { postProcess: 'sentenceCase' })}
@@ -248,6 +258,7 @@ export function VirtualMultiSelect({
rowCount={stableOptions.length}
rowHeight={rowHeight}
rowProps={{
+ displayCountType,
focusedIndex,
onToggle: handleToggle,
options: stableOptions,