mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
enhance album/song list filters
This commit is contained in:
@@ -90,6 +90,8 @@
|
|||||||
"filter_one": "filter",
|
"filter_one": "filter",
|
||||||
"filter_other": "filters",
|
"filter_other": "filters",
|
||||||
"filters": "filters",
|
"filters": "filters",
|
||||||
|
"filter_single": "single",
|
||||||
|
"filter_multiple": "multi",
|
||||||
"forceRestartRequired": "restart to apply changes… close the notification to restart",
|
"forceRestartRequired": "restart to apply changes… close the notification to restart",
|
||||||
"forward": "forward",
|
"forward": "forward",
|
||||||
"gap": "gap",
|
"gap": "gap",
|
||||||
|
|||||||
@@ -2,18 +2,26 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||||
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||||
import { useListContext } from '/@/renderer/context/list-context';
|
import { useListContext } from '/@/renderer/context/list-context';
|
||||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||||
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
|
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
|
||||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-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 { useCurrentServerId } from '/@/renderer/store';
|
||||||
|
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
|
||||||
import { Divider } from '/@/shared/components/divider/divider';
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
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 { 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 { 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 { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
|
||||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||||
import {
|
import {
|
||||||
@@ -61,7 +69,9 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
|
|||||||
const genreList = useMemo(() => {
|
const genreList = useMemo(() => {
|
||||||
if (!genreListQuery?.data) return [];
|
if (!genreListQuery?.data) return [];
|
||||||
return genreListQuery.data.items.map((genre) => ({
|
return genreListQuery.data.items.map((genre) => ({
|
||||||
|
albumCount: genre.albumCount,
|
||||||
label: genre.name,
|
label: genre.name,
|
||||||
|
songCount: genre.songCount,
|
||||||
value: genre.id,
|
value: genre.id,
|
||||||
}));
|
}));
|
||||||
}, [genreListQuery.data]);
|
}, [genreListQuery.data]);
|
||||||
@@ -173,7 +183,14 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
|
|||||||
if (!albumArtistListQuery?.data?.items) return [];
|
if (!albumArtistListQuery?.data?.items) return [];
|
||||||
|
|
||||||
return albumArtistListQuery?.data?.items?.map((artist) => ({
|
return albumArtistListQuery?.data?.items?.map((artist) => ({
|
||||||
|
albumCount: artist.albumCount,
|
||||||
|
imageUrl: getItemImageUrl({
|
||||||
|
id: artist.id,
|
||||||
|
itemType: LibraryItem.ARTIST,
|
||||||
|
type: 'table',
|
||||||
|
}),
|
||||||
label: artist.name,
|
label: artist.name,
|
||||||
|
songCount: artist.songCount,
|
||||||
value: artist.id,
|
value: artist.id,
|
||||||
}));
|
}));
|
||||||
}, [albumArtistListQuery.data?.items]);
|
}, [albumArtistListQuery.data?.items]);
|
||||||
@@ -195,6 +212,87 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
|
|||||||
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
|
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
|
||||||
const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 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 (
|
||||||
|
<Group gap="xs" justify="space-between" w="100%">
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('common.filter_single', { postProcess: 'titleCase' }),
|
||||||
|
value: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('common.filter_multiple', { postProcess: 'titleCase' }),
|
||||||
|
value: 'multi',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={handleArtistSelectModeChange}
|
||||||
|
size="xs"
|
||||||
|
value={artistSelectMode}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}, [artistSelectMode, handleArtistSelectModeChange, t]);
|
||||||
|
|
||||||
|
const genreFilterLabel = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<Group gap="xs" justify="space-between" w="100%">
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('common.filter_single', { postProcess: 'titleCase' }),
|
||||||
|
value: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('common.filter_multiple', { postProcess: 'titleCase' }),
|
||||||
|
value: 'multi',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={handleGenreSelectModeChange}
|
||||||
|
size="xs"
|
||||||
|
value={genreSelectMode}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}, [genreSelectMode, handleGenreSelectModeChange, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack px="md" py="md">
|
<Stack px="md" py="md">
|
||||||
{yesNoFilter.map((filter) => (
|
{yesNoFilter.map((filter) => (
|
||||||
@@ -205,6 +303,38 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
|
|||||||
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
|
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{!disableArtistFilter && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<VirtualMultiSelect
|
||||||
|
displayCountType="album"
|
||||||
|
height={300}
|
||||||
|
isLoading={albumArtistListQuery.isFetching}
|
||||||
|
label={artistFilterLabel}
|
||||||
|
onChange={handleAlbumArtistFilter}
|
||||||
|
options={selectableAlbumArtists}
|
||||||
|
RowComponent={ArtistMultiSelectRow}
|
||||||
|
singleSelect={artistSelectMode === 'single'}
|
||||||
|
value={selectedArtistIds}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isGenrePage && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<VirtualMultiSelect
|
||||||
|
displayCountType="album"
|
||||||
|
height={220}
|
||||||
|
isLoading={genreListQuery.isFetching}
|
||||||
|
label={genreFilterLabel}
|
||||||
|
onChange={handleGenresFilter}
|
||||||
|
options={genreList}
|
||||||
|
RowComponent={GenreMultiSelectRow}
|
||||||
|
singleSelect={genreSelectMode === 'single'}
|
||||||
|
value={selectedGenreIds}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -226,27 +356,6 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
|
|||||||
required={!!query.minYear}
|
required={!!query.minYear}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
{!isGenrePage && (
|
|
||||||
<MultiSelectWithInvalidData
|
|
||||||
clearable
|
|
||||||
data={genreList}
|
|
||||||
defaultValue={query.genreIds || []}
|
|
||||||
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
|
||||||
onChange={handleGenresFilter}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MultiSelectWithInvalidData
|
|
||||||
clearable
|
|
||||||
data={selectableAlbumArtists}
|
|
||||||
defaultValue={query.artistIds || []}
|
|
||||||
disabled={disableArtistFilter}
|
|
||||||
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
|
||||||
limit={300}
|
|
||||||
onChange={handleAlbumArtistFilter}
|
|
||||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
|
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
|
||||||
<MultiSelectWithInvalidData
|
<MultiSelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
|
|||||||
@@ -2,23 +2,29 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
|||||||
import { ChangeEvent, useCallback, useMemo } from 'react';
|
import { ChangeEvent, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||||
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||||
import { useListContext } from '/@/renderer/context/list-context';
|
import { useListContext } from '/@/renderer/context/list-context';
|
||||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||||
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
||||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||||
|
import {
|
||||||
|
ArtistMultiSelectRow,
|
||||||
|
GenreMultiSelectRow,
|
||||||
|
} from '/@/renderer/features/shared/components/multi-select-rows';
|
||||||
import { useCurrentServer, useCurrentServerId } from '/@/renderer/store';
|
import { useCurrentServer, useCurrentServerId } from '/@/renderer/store';
|
||||||
|
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
|
||||||
import { titleCase } from '/@/renderer/utils';
|
import { titleCase } from '/@/renderer/utils';
|
||||||
import { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types';
|
import { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types';
|
||||||
import { Divider } from '/@/shared/components/divider/divider';
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
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 { 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 { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Switch } from '/@/shared/components/switch/switch';
|
import { Switch } from '/@/shared/components/switch/switch';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
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 { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||||
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
|
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
@@ -32,6 +38,9 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
const serverId = server.id;
|
const serverId = server.id;
|
||||||
|
|
||||||
const { customFilters } = useListContext();
|
const { customFilters } = useListContext();
|
||||||
|
const artistSelectMode = useAppStore((state) => state.artistSelectMode);
|
||||||
|
const genreSelectMode = useAppStore((state) => state.genreSelectMode);
|
||||||
|
const { setArtistSelectMode, setGenreSelectMode } = useAppStoreActions();
|
||||||
|
|
||||||
const isGenrePage = customFilters?.genreIds !== undefined;
|
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||||
|
|
||||||
@@ -52,29 +61,43 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
const genreList = useMemo(() => {
|
const genreList = useMemo(() => {
|
||||||
if (!genreListQuery?.data) return [];
|
if (!genreListQuery?.data) return [];
|
||||||
return genreListQuery.data.items.map((genre) => ({
|
return genreListQuery.data.items.map((genre) => ({
|
||||||
|
albumCount: genre.albumCount,
|
||||||
label: genre.name,
|
label: genre.name,
|
||||||
|
songCount: genre.songCount,
|
||||||
value: genre.id,
|
value: genre.id,
|
||||||
}));
|
}));
|
||||||
}, [genreListQuery.data]);
|
}, [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' }),
|
label: t('common.none', { postProcess: 'titleCase' }),
|
||||||
onChange: (favorite?: boolean) => {
|
value: 'none',
|
||||||
setFavorite(favorite ?? null);
|
|
||||||
},
|
|
||||||
value: query.favorite,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
|
label: t('common.yes', { postProcess: 'titleCase' }),
|
||||||
onChange: (compilation?: boolean) => {
|
value: 'true',
|
||||||
setCompilation(compilation ?? null);
|
},
|
||||||
},
|
{
|
||||||
value: query.compilation,
|
label: t('common.no', { postProcess: 'titleCase' }),
|
||||||
|
value: 'false',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, query.favorite, query.compilation, setFavorite, setCompilation],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleFilters = useMemo(
|
const toggleFilters = useMemo(
|
||||||
@@ -141,7 +164,14 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
if (!albumArtistListQuery?.data?.items) return [];
|
if (!albumArtistListQuery?.data?.items) return [];
|
||||||
|
|
||||||
return albumArtistListQuery?.data?.items?.map((artist) => ({
|
return albumArtistListQuery?.data?.items?.map((artist) => ({
|
||||||
|
albumCount: artist.albumCount,
|
||||||
|
imageUrl: getItemImageUrl({
|
||||||
|
id: artist.id,
|
||||||
|
itemType: LibraryItem.ARTIST,
|
||||||
|
type: 'table',
|
||||||
|
}),
|
||||||
label: artist.name,
|
label: artist.name,
|
||||||
|
songCount: artist.songCount,
|
||||||
value: artist.id,
|
value: artist.id,
|
||||||
}));
|
}));
|
||||||
}, [albumArtistListQuery.data?.items]);
|
}, [albumArtistListQuery.data?.items]);
|
||||||
@@ -159,6 +189,9 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
[setGenreId],
|
[setGenreId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);
|
||||||
|
const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);
|
||||||
|
|
||||||
const handleAlbumArtistChange = useCallback(
|
const handleAlbumArtistChange = useCallback(
|
||||||
(e: null | string[]) => {
|
(e: null | string[]) => {
|
||||||
if (e && e.length > 0) {
|
if (e && e.length > 0) {
|
||||||
@@ -170,23 +203,147 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
[setAlbumArtist],
|
[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 (
|
||||||
|
<Group gap="xs" justify="space-between" w="100%">
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('common.filter_single', { postProcess: 'titleCase' }),
|
||||||
|
value: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('common.filter_multiple', { postProcess: 'titleCase' }),
|
||||||
|
value: 'multi',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={handleArtistSelectModeChange}
|
||||||
|
size="xs"
|
||||||
|
value={artistSelectMode}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}, [artistSelectMode, handleArtistSelectModeChange, t]);
|
||||||
|
|
||||||
|
const genreFilterLabel = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<Group gap="xs" justify="space-between" w="100%">
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('common.filter_single', { postProcess: 'titleCase' }),
|
||||||
|
value: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('common.filter_multiple', { postProcess: 'titleCase' }),
|
||||||
|
value: 'multi',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={handleGenreSelectModeChange}
|
||||||
|
size="xs"
|
||||||
|
value={genreSelectMode}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}, [genreSelectMode, handleGenreSelectModeChange, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack px="md" py="md">
|
<Stack px="md" py="md">
|
||||||
{yesNoUndefinedFilters.map((filter) => (
|
<Stack gap="xs">
|
||||||
<YesNoSelect
|
<Text size="sm" weight={500}>
|
||||||
clearable
|
{t('filter.isFavorited', { postProcess: 'sentenceCase' })}
|
||||||
defaultValue={filter.value ? filter.value.toString() : undefined}
|
</Text>
|
||||||
key={`nd-filter-${filter.label}`}
|
<SegmentedControl
|
||||||
label={filter.label}
|
data={segmentedControlData}
|
||||||
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
|
defaultValue={booleanToSegmentValue(query.favorite)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFavorite(segmentValueToBoolean(value));
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
w="100%"
|
||||||
/>
|
/>
|
||||||
))}
|
</Stack>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm" weight={500}>
|
||||||
|
{t('filter.isCompilation', { postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
data={segmentedControlData}
|
||||||
|
defaultValue={booleanToSegmentValue(query.compilation)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setCompilation(segmentValueToBoolean(value));
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
w="100%"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
{toggleFilters.map((filter) => (
|
{toggleFilters.map((filter) => (
|
||||||
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
|
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
|
||||||
<Text>{filter.label}</Text>
|
<Text>{filter.label}</Text>
|
||||||
<Switch defaultChecked={filter?.value ?? false} onChange={filter.onChange} />
|
<Switch defaultChecked={filter?.value ?? false} onChange={filter.onChange} />
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
{!disableArtistFilter && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<VirtualMultiSelect
|
||||||
|
displayCountType="album"
|
||||||
|
height={300}
|
||||||
|
isLoading={albumArtistListQuery.isFetching}
|
||||||
|
label={artistFilterLabel}
|
||||||
|
onChange={handleAlbumArtistChange}
|
||||||
|
options={selectableAlbumArtists}
|
||||||
|
RowComponent={ArtistMultiSelectRow}
|
||||||
|
singleSelect={artistSelectMode === 'single'}
|
||||||
|
value={selectedArtistIds}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isGenrePage && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<VirtualMultiSelect
|
||||||
|
displayCountType="album"
|
||||||
|
height={220}
|
||||||
|
label={genreFilterLabel}
|
||||||
|
onChange={handleGenreChange}
|
||||||
|
options={genreList}
|
||||||
|
RowComponent={GenreMultiSelectRow}
|
||||||
|
singleSelect={genreSelectMode === 'single'}
|
||||||
|
value={selectedGenreIds}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<NumberInput
|
<NumberInput
|
||||||
defaultValue={query.minYear ?? undefined}
|
defaultValue={query.minYear ?? undefined}
|
||||||
@@ -196,27 +353,6 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
min={0}
|
min={0}
|
||||||
onChange={(e) => debouncedHandleYearFilter(e)}
|
onChange={(e) => debouncedHandleYearFilter(e)}
|
||||||
/>
|
/>
|
||||||
{!isGenrePage && (
|
|
||||||
<MultiSelectWithInvalidData
|
|
||||||
clearable
|
|
||||||
data={genreList}
|
|
||||||
defaultValue={query.genreIds || []}
|
|
||||||
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
|
||||||
onChange={handleGenreChange}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MultiSelectWithInvalidData
|
|
||||||
clearable
|
|
||||||
data={selectableAlbumArtists}
|
|
||||||
defaultValue={query.artistIds || []}
|
|
||||||
disabled={disableArtistFilter}
|
|
||||||
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
|
||||||
limit={300}
|
|
||||||
onChange={handleAlbumArtistChange}
|
|
||||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<TagFilters />
|
<TagFilters />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
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 { 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 { useListContext } from '/@/renderer/context/list-context';
|
||||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||||
import { useGenreList } from '/@/renderer/features/genres/api/genres-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 { useCurrentServerId } from '/@/renderer/store';
|
||||||
|
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
|
||||||
import { Divider } from '/@/shared/components/divider/divider';
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
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 { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||||
import { Select } from '/@/shared/components/select/select';
|
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||||
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Switch } from '/@/shared/components/switch/switch';
|
import { Switch } from '/@/shared/components/switch/switch';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
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 {
|
interface SubsonicAlbumFiltersProps {
|
||||||
disableArtistFilter?: boolean;
|
disableArtistFilter?: boolean;
|
||||||
@@ -35,8 +40,6 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
const { query, setAlbumArtist, setFavorite, setGenreId, setMaxYear, setMinYear } =
|
const { query, setAlbumArtist, setFavorite, setGenreId, setMaxYear, setMinYear } =
|
||||||
useAlbumListFilters();
|
useAlbumListFilters();
|
||||||
|
|
||||||
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
|
|
||||||
|
|
||||||
const albumArtistListQuery = useSuspenseQuery(
|
const albumArtistListQuery = useSuspenseQuery(
|
||||||
artistsQueries.albumArtistList({
|
artistsQueries.albumArtistList({
|
||||||
options: {
|
options: {
|
||||||
@@ -58,7 +61,14 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
if (!items) return [];
|
if (!items) return [];
|
||||||
|
|
||||||
return items.map((artist) => ({
|
return items.map((artist) => ({
|
||||||
|
albumCount: artist.albumCount,
|
||||||
|
imageUrl: getItemImageUrl({
|
||||||
|
id: artist.id,
|
||||||
|
itemType: LibraryItem.ARTIST,
|
||||||
|
type: 'table',
|
||||||
|
}),
|
||||||
label: artist.name,
|
label: artist.name,
|
||||||
|
songCount: artist.songCount,
|
||||||
value: artist.id,
|
value: artist.id,
|
||||||
}));
|
}));
|
||||||
}, [items]);
|
}, [items]);
|
||||||
@@ -75,18 +85,34 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
const genreList = useMemo(() => {
|
const genreList = useMemo(() => {
|
||||||
if (!genreListQuery?.data) return [];
|
if (!genreListQuery?.data) return [];
|
||||||
return genreListQuery.data.items.map((genre) => ({
|
return genreListQuery.data.items.map((genre) => ({
|
||||||
|
albumCount: genre.albumCount,
|
||||||
label: genre.name,
|
label: genre.name,
|
||||||
|
songCount: genre.songCount,
|
||||||
value: genre.id,
|
value: genre.id,
|
||||||
}));
|
}));
|
||||||
}, [genreListQuery.data]);
|
}, [genreListQuery.data]);
|
||||||
|
|
||||||
|
const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);
|
||||||
|
|
||||||
const handleGenresFilter = useCallback(
|
const handleGenresFilter = useCallback(
|
||||||
(e: null | string) => {
|
(e: null | string[]) => {
|
||||||
setGenreId(e ? [e] : null);
|
if (e && e.length > 0) {
|
||||||
|
setGenreId([e[0]]);
|
||||||
|
} else {
|
||||||
|
setGenreId(null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[setGenreId],
|
[setGenreId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const genreFilterLabel = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const toggleFilters = useMemo(
|
const toggleFilters = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -142,6 +168,48 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
|
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
|
||||||
const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 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 (
|
||||||
|
<Group gap="xs" justify="space-between" w="100%">
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('common.filter_single', { postProcess: 'titleCase' }),
|
||||||
|
value: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('common.filter_multiple', { postProcess: 'titleCase' }),
|
||||||
|
value: 'multi',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={handleArtistSelectModeChange}
|
||||||
|
size="xs"
|
||||||
|
value={artistSelectMode}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}, [artistSelectMode, handleArtistSelectModeChange, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack px="md" py="md">
|
<Stack px="md" py="md">
|
||||||
{toggleFilters.map((filter) => (
|
{toggleFilters.map((filter) => (
|
||||||
@@ -150,6 +218,36 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
<Switch defaultChecked={filter.value ?? false} onChange={filter.onChange} />
|
<Switch defaultChecked={filter.value ?? false} onChange={filter.onChange} />
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
{!disableArtistFilter && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<VirtualMultiSelect
|
||||||
|
displayCountType="album"
|
||||||
|
height={300}
|
||||||
|
label={artistFilterLabel}
|
||||||
|
onChange={handleAlbumArtistFilter}
|
||||||
|
options={selectableAlbumArtists}
|
||||||
|
RowComponent={ArtistMultiSelectRow}
|
||||||
|
singleSelect={artistSelectMode === 'single'}
|
||||||
|
value={selectedArtistIds}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isGenrePage && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<VirtualMultiSelect
|
||||||
|
displayCountType="album"
|
||||||
|
height={220}
|
||||||
|
label={genreFilterLabel}
|
||||||
|
onChange={handleGenresFilter}
|
||||||
|
options={genreList}
|
||||||
|
RowComponent={GenreMultiSelectRow}
|
||||||
|
singleSelect={true}
|
||||||
|
value={selectedGenreIds}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -171,30 +269,6 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
onChange={(e) => debouncedHandleMaxYearFilter(e)}
|
onChange={(e) => debouncedHandleMaxYearFilter(e)}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
{!isGenrePage && (
|
|
||||||
<Select
|
|
||||||
clearable
|
|
||||||
data={genreList}
|
|
||||||
defaultValue={query.genreIds?.[0] ?? undefined}
|
|
||||||
disabled={Boolean(query.minYear || query.maxYear)}
|
|
||||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
|
||||||
onChange={handleGenresFilter}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MultiSelectWithInvalidData
|
|
||||||
clearable
|
|
||||||
data={selectableAlbumArtists}
|
|
||||||
defaultValue={query.artistIds ?? []}
|
|
||||||
disabled={disableArtistFilter}
|
|
||||||
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
|
||||||
limit={300}
|
|
||||||
onChange={handleAlbumArtistFilter}
|
|
||||||
onSearchChange={setAlbumArtistSearchTerm}
|
|
||||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
|
||||||
searchable
|
|
||||||
searchValue={albumArtistSearchTerm}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,17 +11,20 @@ import { Text } from '/@/shared/components/text/text';
|
|||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export function ArtistMultiSelectRow({
|
export function ArtistMultiSelectRow({
|
||||||
|
displayCountType = 'album',
|
||||||
focusedIndex,
|
focusedIndex,
|
||||||
index,
|
index,
|
||||||
onToggle,
|
onToggle,
|
||||||
options,
|
options,
|
||||||
style,
|
style,
|
||||||
}: RowComponentProps<{
|
}: RowComponentProps<{
|
||||||
|
displayCountType?: 'album' | 'song';
|
||||||
focusedIndex: null | number;
|
focusedIndex: null | number;
|
||||||
onToggle: (value: string) => void;
|
onToggle: (value: string) => void;
|
||||||
options: VirtualMultiSelectOption<{
|
options: VirtualMultiSelectOption<{
|
||||||
albumCount: null | number;
|
albumCount: null | number;
|
||||||
imageUrl: string | undefined;
|
imageUrl: string | undefined;
|
||||||
|
songCount: null | number;
|
||||||
}>[];
|
}>[];
|
||||||
value: string[];
|
value: string[];
|
||||||
}>) {
|
}>) {
|
||||||
@@ -32,6 +35,9 @@ export function ArtistMultiSelectRow({
|
|||||||
}, [onToggle, options, index]);
|
}, [onToggle, options, index]);
|
||||||
|
|
||||||
const isFocused = focusedIndex === index;
|
const isFocused = focusedIndex === index;
|
||||||
|
const count =
|
||||||
|
displayCountType === 'song' ? options[index].songCount : options[index].albumCount;
|
||||||
|
const countEntity = displayCountType === 'song' ? 'song' : 'album';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
@@ -52,14 +58,11 @@ export function ArtistMultiSelectRow({
|
|||||||
{options[index].label}
|
{options[index].label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text isMuted overflow="hidden" size="xs">
|
<Text isMuted overflow="hidden" size="xs">
|
||||||
{options[index].albumCount ? (
|
{count ? (
|
||||||
<>
|
<>
|
||||||
{options[index].albumCount}{' '}
|
{count} {t(`entity.${countEntity}`, { count })}
|
||||||
{t('entity.album', { count: options[index].albumCount })}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null}
|
||||||
<> </>
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -67,15 +70,20 @@ export function ArtistMultiSelectRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GenreMultiSelectRow({
|
export function GenreMultiSelectRow({
|
||||||
|
displayCountType = 'album',
|
||||||
focusedIndex,
|
focusedIndex,
|
||||||
index,
|
index,
|
||||||
onToggle,
|
onToggle,
|
||||||
options,
|
options,
|
||||||
style,
|
style,
|
||||||
}: RowComponentProps<{
|
}: RowComponentProps<{
|
||||||
|
displayCountType?: 'album' | 'song';
|
||||||
focusedIndex: null | number;
|
focusedIndex: null | number;
|
||||||
onToggle: (value: string) => void;
|
onToggle: (value: string) => void;
|
||||||
options: VirtualMultiSelectOption<{ albumCount: null | number }>[];
|
options: VirtualMultiSelectOption<{
|
||||||
|
albumCount: null | number;
|
||||||
|
songCount: null | number;
|
||||||
|
}>[];
|
||||||
value: string[];
|
value: string[];
|
||||||
}>) {
|
}>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -85,6 +93,9 @@ export function GenreMultiSelectRow({
|
|||||||
}, [onToggle, options, index]);
|
}, [onToggle, options, index]);
|
||||||
|
|
||||||
const isFocused = focusedIndex === index;
|
const isFocused = focusedIndex === index;
|
||||||
|
const count =
|
||||||
|
displayCountType === 'song' ? options[index].songCount : options[index].albumCount;
|
||||||
|
const countEntity = displayCountType === 'song' ? 'song' : 'album';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
@@ -99,14 +110,11 @@ export function GenreMultiSelectRow({
|
|||||||
{options[index].label}
|
{options[index].label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text isMuted overflow="hidden" size="xs">
|
<Text isMuted overflow="hidden" size="xs">
|
||||||
{options[index].albumCount ? (
|
{count ? (
|
||||||
<>
|
<>
|
||||||
{options[index].albumCount}{' '}
|
{count} {t(`entity.${countEntity}`, { count })}
|
||||||
{t('entity.album', { count: options[index].albumCount })}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null}
|
||||||
<> </>
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,25 +1,41 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||||
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||||
import { useListContext } from '/@/renderer/context/list-context';
|
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 { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
||||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-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 { 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 { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
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 { 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 { 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 { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
|
||||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
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 = () => {
|
interface JellyfinSongFiltersProps {
|
||||||
const serverId = useCurrentServerId();
|
disableArtistFilter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JellyfinSongFilters = ({ disableArtistFilter }: JellyfinSongFiltersProps) => {
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const serverId = server.id;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { query, setCustom, setFavorite, setMaxYear, setMinYear } = useSongListFilters();
|
const { query, setArtistIds, setCustom, setFavorite, setMaxYear, setMinYear } =
|
||||||
|
useSongListFilters();
|
||||||
|
|
||||||
const { customFilters } = useListContext();
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
@@ -32,11 +48,44 @@ export const JellyfinSongFilters = () => {
|
|||||||
const genreList = useMemo(() => {
|
const genreList = useMemo(() => {
|
||||||
if (!genreListQuery.data) return [];
|
if (!genreListQuery.data) return [];
|
||||||
return genreListQuery.data.items.map((genre) => ({
|
return genreListQuery.data.items.map((genre) => ({
|
||||||
|
albumCount: genre.albumCount,
|
||||||
label: genre.name,
|
label: genre.name,
|
||||||
|
songCount: genre.songCount,
|
||||||
value: genre.id,
|
value: genre.id,
|
||||||
}));
|
}));
|
||||||
}, [genreListQuery.data]);
|
}, [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(
|
const tagsQuery = useQuery(
|
||||||
sharedQueries.tagList({
|
sharedQueries.tagList({
|
||||||
query: {
|
query: {
|
||||||
@@ -46,8 +95,10 @@ export const JellyfinSongFilters = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);
|
||||||
|
|
||||||
const selectedGenres = useMemo(() => {
|
const selectedGenres = useMemo(() => {
|
||||||
return query._custom?.GenreIds?.split(',');
|
return query._custom?.GenreIds?.split(',') || [];
|
||||||
}, [query._custom?.GenreIds]);
|
}, [query._custom?.GenreIds]);
|
||||||
|
|
||||||
const selectedTags = useMemo(() => {
|
const selectedTags = useMemo(() => {
|
||||||
@@ -136,6 +187,95 @@ export const JellyfinSongFilters = () => {
|
|||||||
[setCustom],
|
[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 (
|
||||||
|
<Group gap="xs" justify="space-between" w="100%">
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('common.filter_single', { postProcess: 'titleCase' }),
|
||||||
|
value: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('common.filter_multiple', { postProcess: 'titleCase' }),
|
||||||
|
value: 'multi',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={handleArtistSelectModeChange}
|
||||||
|
size="xs"
|
||||||
|
value={artistSelectMode}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}, [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 (
|
||||||
|
<Group gap="xs" justify="space-between" w="100%">
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('common.filter_single', { postProcess: 'titleCase' }),
|
||||||
|
value: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('common.filter_multiple', { postProcess: 'titleCase' }),
|
||||||
|
value: 'multi',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={handleGenreSelectModeChange}
|
||||||
|
size="xs"
|
||||||
|
value={genreSelectMode}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}, [genreSelectMode, handleGenreSelectModeChange, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack px="md" py="md">
|
<Stack px="md" py="md">
|
||||||
{yesNoFilters.map((filter) => (
|
{yesNoFilters.map((filter) => (
|
||||||
@@ -146,6 +286,37 @@ export const JellyfinSongFilters = () => {
|
|||||||
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
|
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{!disableArtistFilter && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<VirtualMultiSelect
|
||||||
|
displayCountType="song"
|
||||||
|
height={300}
|
||||||
|
label={artistFilterLabel}
|
||||||
|
onChange={handleArtistChange}
|
||||||
|
options={selectableAlbumArtists}
|
||||||
|
RowComponent={ArtistMultiSelectRow}
|
||||||
|
singleSelect={artistSelectMode === 'single'}
|
||||||
|
value={selectedArtistIds}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isGenrePage && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<VirtualMultiSelect
|
||||||
|
displayCountType="song"
|
||||||
|
height={220}
|
||||||
|
isLoading={genreListQuery.isFetching}
|
||||||
|
label={genreFilterLabel}
|
||||||
|
onChange={handleGenresFilter}
|
||||||
|
options={genreList}
|
||||||
|
RowComponent={GenreMultiSelectRow}
|
||||||
|
singleSelect={genreSelectMode === 'single'}
|
||||||
|
value={selectedGenres}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -167,16 +338,6 @@ export const JellyfinSongFilters = () => {
|
|||||||
required={!!query.minYear}
|
required={!!query.minYear}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
{!isGenrePage && (
|
|
||||||
<MultiSelectWithInvalidData
|
|
||||||
clearable
|
|
||||||
data={genreList}
|
|
||||||
defaultValue={selectedGenres}
|
|
||||||
label={t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}
|
|
||||||
onChange={handleGenresFilter}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
|
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
|
||||||
<MultiSelectWithInvalidData
|
<MultiSelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
|
|||||||
@@ -2,24 +2,37 @@ import { useSuspenseQuery } from '@tanstack/react-query';
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||||
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||||
import { useListContext } from '/@/renderer/context/list-context';
|
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 { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
||||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-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 { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
||||||
import { useCurrentServerId } from '/@/renderer/store';
|
import { useCurrentServer, useCurrentServerId } from '/@/renderer/store';
|
||||||
|
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
|
||||||
import { titleCase } from '/@/renderer/utils';
|
import { titleCase } from '/@/renderer/utils';
|
||||||
import { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types';
|
import { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types';
|
||||||
import { Divider } from '/@/shared/components/divider/divider';
|
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 { 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 { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
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 NavidromeSongFilters = () => {
|
export const NavidromeSongFilters = () => {
|
||||||
const { t } = useTranslation();
|
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();
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
@@ -30,22 +43,74 @@ export const NavidromeSongFilters = () => {
|
|||||||
const genreList = useMemo(() => {
|
const genreList = useMemo(() => {
|
||||||
if (!genreListQuery?.data) return [];
|
if (!genreListQuery?.data) return [];
|
||||||
return genreListQuery.data.items.map((genre) => ({
|
return genreListQuery.data.items.map((genre) => ({
|
||||||
|
albumCount: genre.albumCount,
|
||||||
label: genre.name,
|
label: genre.name,
|
||||||
|
songCount: genre.songCount,
|
||||||
value: genre.id,
|
value: genre.id,
|
||||||
}));
|
}));
|
||||||
}, [genreListQuery.data]);
|
}, [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' }),
|
label: t('common.none', { postProcess: 'titleCase' }),
|
||||||
onChange: (favorite?: boolean) => {
|
value: 'none',
|
||||||
setFavorite(favorite ?? null);
|
},
|
||||||
},
|
{
|
||||||
value: query.favorite,
|
label: t('common.yes', { postProcess: 'titleCase' }),
|
||||||
|
value: 'true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('common.no', { postProcess: 'titleCase' }),
|
||||||
|
value: 'false',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, query.favorite, setFavorite],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleYearFilter = useMemo(
|
const handleYearFilter = useMemo(
|
||||||
@@ -73,6 +138,48 @@ export const NavidromeSongFilters = () => {
|
|||||||
|
|
||||||
const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300);
|
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 (
|
||||||
|
<Group gap="xs" justify="space-between" w="100%">
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('common.filter_single', { postProcess: 'titleCase' }),
|
||||||
|
value: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('common.filter_multiple', { postProcess: 'titleCase' }),
|
||||||
|
value: 'multi',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={handleGenreSelectModeChange}
|
||||||
|
size="xs"
|
||||||
|
value={genreSelectMode}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}, [genreSelectMode, handleGenreSelectModeChange, t]);
|
||||||
|
|
||||||
const handleGenreChange = useCallback(
|
const handleGenreChange = useCallback(
|
||||||
(e: null | string[]) => {
|
(e: null | string[]) => {
|
||||||
if (e && e.length > 0) {
|
if (e && e.length > 0) {
|
||||||
@@ -84,18 +191,98 @@ export const NavidromeSongFilters = () => {
|
|||||||
[setGenreId],
|
[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 (
|
||||||
|
<Group gap="xs" justify="space-between" w="100%">
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('common.filter_single', { postProcess: 'titleCase' }),
|
||||||
|
value: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('common.filter_multiple', { postProcess: 'titleCase' }),
|
||||||
|
value: 'multi',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={handleArtistSelectModeChange}
|
||||||
|
size="xs"
|
||||||
|
value={artistSelectMode}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}, [artistSelectMode, handleArtistSelectModeChange, t]);
|
||||||
|
|
||||||
|
const handleArtistChange = useCallback(
|
||||||
|
(e: null | string[]) => {
|
||||||
|
if (e && e.length > 0) {
|
||||||
|
setArtistIds(e);
|
||||||
|
} else {
|
||||||
|
setArtistIds(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setArtistIds],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack px="md" py="md">
|
<Stack px="md" py="md">
|
||||||
{yesNoUndefinedFilters.map((filter) => (
|
<Stack gap="xs">
|
||||||
<YesNoSelect
|
<Text size="sm" weight={500}>
|
||||||
clearable
|
{t('filter.isFavorited', { postProcess: 'sentenceCase' })}
|
||||||
defaultValue={filter.value ? filter.value.toString() : undefined}
|
</Text>
|
||||||
key={`nd-filter-${filter.label}`}
|
<SegmentedControl
|
||||||
label={filter.label}
|
data={segmentedControlData}
|
||||||
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
|
defaultValue={booleanToSegmentValue(query.favorite)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFavorite(segmentValueToBoolean(value));
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
w="100%"
|
||||||
/>
|
/>
|
||||||
))}
|
</Stack>
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
|
<VirtualMultiSelect
|
||||||
|
displayCountType="song"
|
||||||
|
height={300}
|
||||||
|
label={artistFilterLabel}
|
||||||
|
onChange={handleArtistChange}
|
||||||
|
options={selectableAlbumArtists}
|
||||||
|
RowComponent={ArtistMultiSelectRow}
|
||||||
|
singleSelect={artistSelectMode === 'single'}
|
||||||
|
value={selectedArtistIds}
|
||||||
|
/>
|
||||||
|
{!isGenrePage && (
|
||||||
|
<VirtualMultiSelect
|
||||||
|
displayCountType="song"
|
||||||
|
height={220}
|
||||||
|
label={genreFilterLabel}
|
||||||
|
onChange={handleGenreChange}
|
||||||
|
options={genreList}
|
||||||
|
RowComponent={GenreMultiSelectRow}
|
||||||
|
singleSelect={genreSelectMode === 'single'}
|
||||||
|
value={selectedGenreIds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
defaultValue={query.minYear ?? undefined}
|
defaultValue={query.minYear ?? undefined}
|
||||||
hideControls={false}
|
hideControls={false}
|
||||||
@@ -104,16 +291,6 @@ export const NavidromeSongFilters = () => {
|
|||||||
min={0}
|
min={0}
|
||||||
onChange={(e) => debouncedHandleYearFilter(e)}
|
onChange={(e) => debouncedHandleYearFilter(e)}
|
||||||
/>
|
/>
|
||||||
{!isGenrePage && (
|
|
||||||
<MultiSelectWithInvalidData
|
|
||||||
clearable
|
|
||||||
data={genreList}
|
|
||||||
defaultValue={query.genreIds || []}
|
|
||||||
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
|
||||||
onChange={handleGenreChange}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<TagFilters />
|
<TagFilters />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { ChangeEvent, useCallback, useMemo } from 'react';
|
import { ChangeEvent, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
|
||||||
import { useListContext } from '/@/renderer/context/list-context';
|
import { useListContext } from '/@/renderer/context/list-context';
|
||||||
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
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 { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
||||||
import { Divider } from '/@/shared/components/divider/divider';
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
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 { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Switch } from '/@/shared/components/switch/switch';
|
import { Switch } from '/@/shared/components/switch/switch';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
@@ -24,18 +25,34 @@ export const SubsonicSongFilters = () => {
|
|||||||
const genreList = useMemo(() => {
|
const genreList = useMemo(() => {
|
||||||
if (!genreListQuery.data) return [];
|
if (!genreListQuery.data) return [];
|
||||||
return genreListQuery.data.items.map((genre) => ({
|
return genreListQuery.data.items.map((genre) => ({
|
||||||
|
albumCount: genre.albumCount,
|
||||||
label: genre.name,
|
label: genre.name,
|
||||||
|
songCount: genre.songCount,
|
||||||
value: genre.id,
|
value: genre.id,
|
||||||
}));
|
}));
|
||||||
}, [genreListQuery.data]);
|
}, [genreListQuery.data]);
|
||||||
|
|
||||||
|
const selectedGenreIds = useMemo(() => query.genreIds || [], [query.genreIds]);
|
||||||
|
|
||||||
const handleGenresFilter = useCallback(
|
const handleGenresFilter = useCallback(
|
||||||
(e: null | string) => {
|
(e: null | string[]) => {
|
||||||
setGenreId(e ? [e] : null);
|
if (e && e.length > 0) {
|
||||||
|
setGenreId([e[0]]);
|
||||||
|
} else {
|
||||||
|
setGenreId(null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[setGenreId],
|
[setGenreId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const genreFilterLabel = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const toggleFilters = useMemo(
|
const toggleFilters = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -61,13 +78,15 @@ export const SubsonicSongFilters = () => {
|
|||||||
{!isGenrePage && (
|
{!isGenrePage && (
|
||||||
<>
|
<>
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<SelectWithInvalidData
|
<VirtualMultiSelect
|
||||||
clearable
|
displayCountType="song"
|
||||||
data={genreList}
|
height={220}
|
||||||
defaultValue={query.genreIds ? query.genreIds[0] : undefined}
|
label={genreFilterLabel}
|
||||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
|
||||||
onChange={handleGenresFilter}
|
onChange={handleGenresFilter}
|
||||||
searchable
|
options={genreList}
|
||||||
|
RowComponent={GenreMultiSelectRow}
|
||||||
|
singleSelect={true}
|
||||||
|
value={selectedGenreIds}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface AppSlice extends AppState {
|
|||||||
setAlbumArtistDetailGroupingType: (groupingType: 'all' | 'primary') => void;
|
setAlbumArtistDetailGroupingType: (groupingType: 'all' | 'primary') => void;
|
||||||
setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;
|
setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void;
|
||||||
setAppStore: (data: Partial<AppSlice>) => void;
|
setAppStore: (data: Partial<AppSlice>) => void;
|
||||||
|
setArtistSelectMode: (mode: 'multi' | 'single') => void;
|
||||||
|
setGenreSelectMode: (mode: 'multi' | 'single') => void;
|
||||||
setPageSidebar: (key: string, value: boolean) => void;
|
setPageSidebar: (key: string, value: boolean) => void;
|
||||||
setPrivateMode: (enabled: boolean) => void;
|
setPrivateMode: (enabled: boolean) => void;
|
||||||
setShowTimeRemaining: (enabled: boolean) => void;
|
setShowTimeRemaining: (enabled: boolean) => void;
|
||||||
@@ -25,7 +27,9 @@ export interface AppState {
|
|||||||
sortBy: AlbumListSort;
|
sortBy: AlbumListSort;
|
||||||
sortOrder: SortOrder;
|
sortOrder: SortOrder;
|
||||||
};
|
};
|
||||||
|
artistSelectMode: 'multi' | 'single';
|
||||||
commandPalette: CommandPaletteProps;
|
commandPalette: CommandPaletteProps;
|
||||||
|
genreSelectMode: 'multi' | 'single';
|
||||||
isReorderingQueue: boolean;
|
isReorderingQueue: boolean;
|
||||||
pageSidebar: Record<string, boolean>;
|
pageSidebar: Record<string, boolean>;
|
||||||
platform: Platform;
|
platform: Platform;
|
||||||
@@ -78,6 +82,16 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
setAppStore: (data) => {
|
setAppStore: (data) => {
|
||||||
set({ ...get(), ...data });
|
set({ ...get(), ...data });
|
||||||
},
|
},
|
||||||
|
setArtistSelectMode: (mode) => {
|
||||||
|
set((state) => {
|
||||||
|
state.artistSelectMode = mode;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setGenreSelectMode: (mode) => {
|
||||||
|
set((state) => {
|
||||||
|
state.genreSelectMode = mode;
|
||||||
|
});
|
||||||
|
},
|
||||||
setPageSidebar: (key, value) => {
|
setPageSidebar: (key, value) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.pageSidebar[key] = value;
|
state.pageSidebar[key] = value;
|
||||||
@@ -109,6 +123,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
sortBy: AlbumListSort.RELEASE_DATE,
|
sortBy: AlbumListSort.RELEASE_DATE,
|
||||||
sortOrder: SortOrder.DESC,
|
sortOrder: SortOrder.DESC,
|
||||||
},
|
},
|
||||||
|
artistSelectMode: 'multi',
|
||||||
commandPalette: {
|
commandPalette: {
|
||||||
close: () => {
|
close: () => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -127,6 +142,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
genreSelectMode: 'multi',
|
||||||
isReorderingQueue: false,
|
isReorderingQueue: false,
|
||||||
pageSidebar: {
|
pageSidebar: {
|
||||||
album: true,
|
album: true,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
|||||||
import { Center } from '/@/shared/components/center/center';
|
import { Center } from '/@/shared/components/center/center';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
@@ -16,12 +17,15 @@ import { Text } from '/@/shared/components/text/text';
|
|||||||
export type VirtualMultiSelectOption<T> = T & { label: string; value: string };
|
export type VirtualMultiSelectOption<T> = T & { label: string; value: string };
|
||||||
|
|
||||||
interface VirtualMultiSelectProps<T> {
|
interface VirtualMultiSelectProps<T> {
|
||||||
|
displayCountType?: 'album' | 'song';
|
||||||
height: number;
|
height: number;
|
||||||
|
isLoading?: boolean;
|
||||||
label?: React.ReactNode | string;
|
label?: React.ReactNode | string;
|
||||||
onChange: (value: null | string[]) => void;
|
onChange: (value: null | string[]) => void;
|
||||||
options: VirtualMultiSelectOption<T>[];
|
options: VirtualMultiSelectOption<T>[];
|
||||||
RowComponent: (
|
RowComponent: (
|
||||||
props: RowComponentProps<{
|
props: RowComponentProps<{
|
||||||
|
displayCountType?: 'album' | 'song';
|
||||||
focusedIndex: null | number;
|
focusedIndex: null | number;
|
||||||
onToggle: (value: string) => void;
|
onToggle: (value: string) => void;
|
||||||
options: VirtualMultiSelectOption<T>[];
|
options: VirtualMultiSelectOption<T>[];
|
||||||
@@ -33,7 +37,9 @@ interface VirtualMultiSelectProps<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VirtualMultiSelect<T>({
|
export function VirtualMultiSelect<T>({
|
||||||
|
displayCountType = 'album',
|
||||||
height,
|
height,
|
||||||
|
isLoading = false,
|
||||||
label,
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
@@ -235,7 +241,11 @@ export function VirtualMultiSelect<T>({
|
|||||||
style={{ height: `${height}px` }}
|
style={{ height: `${height}px` }}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{stableOptions.length === 0 ? (
|
{isLoading ? (
|
||||||
|
<Center h="100%">
|
||||||
|
<Spinner />
|
||||||
|
</Center>
|
||||||
|
) : stableOptions.length === 0 ? (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
<Text isMuted isNoSelect size="sm">
|
<Text isMuted isNoSelect size="sm">
|
||||||
{t('common.noResultsFromQuery', { postProcess: 'sentenceCase' })}
|
{t('common.noResultsFromQuery', { postProcess: 'sentenceCase' })}
|
||||||
@@ -248,6 +258,7 @@ export function VirtualMultiSelect<T>({
|
|||||||
rowCount={stableOptions.length}
|
rowCount={stableOptions.length}
|
||||||
rowHeight={rowHeight}
|
rowHeight={rowHeight}
|
||||||
rowProps={{
|
rowProps={{
|
||||||
|
displayCountType,
|
||||||
focusedIndex,
|
focusedIndex,
|
||||||
onToggle: handleToggle,
|
onToggle: handleToggle,
|
||||||
options: stableOptions,
|
options: stableOptions,
|
||||||
|
|||||||
Reference in New Issue
Block a user