fix song list filters

This commit is contained in:
jeffvli
2025-11-30 15:58:49 -08:00
parent 6d87da2474
commit c5c2b24a9d
11 changed files with 268 additions and 229 deletions
@@ -586,6 +586,7 @@ export const NavidromeController: InternalControllerEndpoint = {
library_id: getLibraryId(query.musicFolderId),
starred: query.favorite,
title: query.searchTerm,
year: query.maxYear || query.minYear,
...query._custom,
...excludeMissing(apiClientProps.server),
},
@@ -87,10 +87,6 @@ export const AlbumListView = ({
};
}, [query, overrideQuery]);
console.log('query', query);
console.log('overrideQuery', overrideQuery);
console.log('mergedQuery', mergedQuery);
switch (display) {
case ListDisplayType.GRID: {
switch (pagination) {
@@ -100,7 +100,6 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
// Handle empty string, null, undefined, or invalid numbers as clearing
if (e === '' || e === null || e === undefined) {
console.log('clearing year filters');
setMinYear(null);
setMaxYear(null);
return;
@@ -109,11 +108,9 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
const year = typeof e === 'number' ? e : Number(e);
// If it's a valid number, set it; otherwise clear
if (!isNaN(year) && isFinite(year) && year > 0) {
console.log('setting year filters', year);
setMinYear(year);
setMaxYear(year);
} else {
console.log('clearing year filters', year);
setMinYear(null);
setMaxYear(null);
}
@@ -17,10 +17,6 @@ interface ComponentErrorFallbackProps {
const ComponentErrorFallback = ({ resetErrorBoundary }: ComponentErrorFallbackProps) => {
const { t } = useTranslation();
const handleRefresh = () => {
window.location.reload();
};
return (
<Box h="100%" pos="relative" w="100%">
<Center h="100%" p="md" w="100%">
@@ -35,9 +31,6 @@ const ComponentErrorFallback = ({ resetErrorBoundary }: ComponentErrorFallbackPr
<Button onClick={resetErrorBoundary} size="xs" variant="default">
{t('common.reload', { postProcess: 'sentenceCase' })}
</Button>
<Button onClick={handleRefresh} size="xs" variant="filled">
{t('common.refresh', { postProcess: 'sentenceCase' })}
</Button>
</Group>
</Stack>
</Center>
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters';
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
import { FilterButton } from '/@/renderer/features/shared/components/filter-button';
import { JellyfinSongFilters } from '/@/renderer/features/songs/components/jellyfin-song-filters';
import { NavidromeSongFilters } from '/@/renderer/features/songs/components/navidrome-song-filters';
@@ -46,7 +47,11 @@ export const ListFilters = ({ itemType }: ListFiltersProps) => {
const serverType = server.type;
const FilterComponent = FILTERS[serverType][itemType];
return <FilterComponent />;
return (
<ComponentErrorBoundary>
<FilterComponent />
</ComponentErrorBoundary>
);
};
const FILTERS = {
@@ -3,6 +3,7 @@
display: flex;
flex-direction: row;
width: 100%;
min-width: 0;
height: 100%;
container-type: inline-size;
overflow: hidden;
@@ -10,6 +11,7 @@
.sidebar-container {
position: relative;
display: none;
flex-shrink: 0;
width: 300px;
min-width: 300px;
@@ -19,6 +21,12 @@
border-right: 1px solid var(--theme-colors-border);
}
@container (min-width: $mantine-breakpoint-lg) {
.sidebar-container {
display: block;
}
}
.content-container {
position: relative;
display: flex;
@@ -3,12 +3,10 @@ import { createContext, ReactNode, useContext, useMemo, useRef } from 'react';
import styles from './list-with-sidebar-container.module.css';
import { useContainerQuery } from '/@/renderer/hooks';
import { animationProps } from '/@/shared/components/animations/animation-props';
import { Portal } from '/@/shared/components/portal/portal';
interface ListWithSidebarContainerContextValue {
showSidebar: boolean;
sidebarRef: React.RefObject<HTMLDivElement | null>;
}
@@ -33,10 +31,10 @@ function Sidebar({ children }: SidebarProps) {
const context = useContext(ListWithSidebarContainerContext);
if (!context) {
throw new Error('Sidebar must be used within ResponsiveAnimatedPage');
throw new Error('Sidebar must be used within ListWithSidebarContainer');
}
if (!context.showSidebar || !context.sidebarRef?.current) {
if (!context.sidebarRef?.current) {
return null;
}
@@ -53,10 +51,10 @@ function SidebarPortal({ children }: SidebarPortalProps) {
const context = useContext(ListWithSidebarContainerContext);
if (!context) {
throw new Error('SidebarPortal must be used within ResponsiveAnimatedPage');
throw new Error('SidebarPortal must be used within ListWithSidebarContainer');
}
if (!context.showSidebar || !context.sidebarRef?.current) {
if (!context.sidebarRef?.current) {
return null;
}
@@ -65,31 +63,21 @@ function SidebarPortal({ children }: SidebarPortalProps) {
export const ListWithSidebarContainer = ({
children,
sidebarBreakpoint,
sidebarBreakpoint = 1200,
}: ListWithSidebarContainerProps) => {
const sidebarRef = useRef<HTMLDivElement>(null);
const { isLg, ref: containerQueryRef } = useContainerQuery({
lg: sidebarBreakpoint,
});
const showSidebar = isLg;
const contextValue = useMemo(
() => ({
showSidebar,
sidebarRef,
}),
[showSidebar],
[],
);
return (
<ListWithSidebarContainerContext.Provider value={contextValue}>
<div className={styles.container} ref={containerQueryRef}>
<div
className={styles.sidebarContainer}
ref={sidebarRef}
style={{ display: showSidebar ? 'block' : 'none' }}
/>
<div className={styles.container} data-sidebar-breakpoint={sidebarBreakpoint}>
<div className={styles.sidebarContainer} ref={sidebarRef} />
<div className={styles.contentContainer}>{children}</div>
</div>
</ListWithSidebarContainerContext.Provider>
@@ -1,6 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { useMemo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -9,54 +8,23 @@ import {
} from '/@/renderer/components/select-with-invalid-data';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import {
SongListFilter,
useCurrentServer,
useListFilterByKey,
useListStoreActions,
} from '/@/renderer/store';
import { NDSongQueryFields } from '/@/shared/api/navidrome/navidrome-types';
import { hasFeature } from '/@/shared/api/utils';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
import { useCurrentServerId } from '/@/renderer/store';
import { titleCase } from '/@/renderer/utils';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Spinner } from '/@/shared/components/spinner/spinner';
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 { LibraryItem, SongListQuery } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { LibraryItem } from '/@/shared/types/domain-types';
interface NavidromeSongFiltersProps {
customFilters?: Partial<SongListFilter>;
onFilterChange: (filters: SongListFilter) => void;
pageKey: string;
serverId: string;
}
export const NavidromeSongFilters = ({
customFilters,
onFilterChange,
pageKey,
serverId,
}: NavidromeSongFiltersProps) => {
export const NavidromeSongFilters = () => {
const { t } = useTranslation();
const { setFilter } = useListStoreActions();
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
const server = useCurrentServer();
const isGenrePage = customFilters?.genreIds !== undefined;
const { query, setFavorite, setGenreId, setMaxYear, setMinYear } = useSongListFilters();
const genreListQuery = useGenreList();
const tagsQuery = useQuery(
sharedQueries.tags({
query: {
type: LibraryItem.SONG,
},
serverId,
}),
);
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
@@ -65,142 +33,189 @@ export const NavidromeSongFilters = ({
}));
}, [genreListQuery.data]);
const hasBFR = hasFeature(server, ServerFeature.BFR);
const handleGenresFilter = debounce((e: null | string[]) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
genreIds: e ? e : undefined,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
}, 250);
const handleTagFilter = debounce((tag: string, e: null | string) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
[tag]: e || undefined,
},
const yesNoUndefinedFilters = useMemo(
() => [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (favorite?: boolean) => {
setFavorite(favorite ?? null);
},
value: query.favorite,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
],
[t, query.favorite, setFavorite],
);
onFilterChange(updatedFilters);
}, 250);
const handleYearFilter = useMemo(
() => (e: number | string) => {
// Handle empty string, null, undefined, or invalid numbers as clearing
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (favorite: boolean | undefined) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
favorite,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
if (e === '' || e === null || e === undefined) {
setMinYear(null);
setMaxYear(null);
return;
}
onFilterChange(updatedFilters);
},
value: filter.favorite,
const year = typeof e === 'number' ? e : Number(e);
// If it's a valid number, set it; otherwise clear
if (!isNaN(year) && isFinite(year) && year > 0) {
setMinYear(year);
setMaxYear(year);
} else {
setMinYear(null);
setMaxYear(null);
}
},
];
const handleYearFilter = debounce((e: number | string) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
year: e === '' ? undefined : (e as number),
},
},
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
}, 500);
[setMinYear, setMaxYear],
);
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<YesNoSelect onChange={filter.onChange} size="xs" value={filter.value} />
</Group>
{yesNoUndefinedFilters.map((filter) => (
<YesNoSelect
key={`nd-filter-${filter.label}`}
label={filter.label}
onChange={filter.onChange}
value={filter.value ?? undefined}
/>
))}
<Divider my="0.5rem" />
<Group grow>
<NumberInput
label={t('common.year', { postProcess: 'titleCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e)}
value={filter._custom?.navidrome?.year}
width={50}
/>
{!isGenrePage && !hasBFR && (
<SelectWithInvalidData
clearable
data={genreList}
defaultValue={filter.genreIds ? filter.genreIds[0] : undefined}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={(value) => handleGenresFilter(value !== null ? [value] : null)}
searchable
width={150}
/>
)}
</Group>
{!isGenrePage && hasBFR && (
<Group grow>
<MultiSelectWithInvalidData
clearable
data={genreList}
defaultValue={filter.genreIds}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={handleGenresFilter}
searchable
/>
</Group>
)}
{tagsQuery.data?.enumTags?.length &&
tagsQuery.data.enumTags.length > 0 &&
tagsQuery.data.enumTags.map((tag) => (
<Group grow key={tag.name}>
<SelectWithInvalidData
clearable
data={tag.options}
defaultValue={
filter._custom?.navidrome?.[tag.name] as string | undefined
}
label={
NDSongQueryFields.find((i) => i.value === tag.name)?.label ||
tag.name
}
onChange={(value) => handleTagFilter(tag.name, value)}
searchable
width={150}
/>
</Group>
))}
<NumberInput
defaultValue={query.minYear ?? undefined}
hideControls={false}
label={t('common.year', { postProcess: 'titleCase' })}
max={5000}
min={0}
onBlur={(e) => handleYearFilter(e.currentTarget.value)}
/>
<MultiSelectWithInvalidData
clearable
data={genreList}
defaultValue={query.genreId}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))}
searchable
/>
<TagFilters />
</Stack>
);
};
interface TagFilterItemProps {
label: string;
onChange: (value: null | string) => void;
options: string[];
tagValue: string;
value: string | undefined;
}
const TagFilterItem = memo(
({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
return (
<SelectWithInvalidData
clearable
data={options}
defaultValue={value}
key={tagValue}
label={label}
limit={100}
onChange={onChange}
searchable
/>
);
},
(prevProps, nextProps) => {
// Only re-render if the specific tag's value or options change
// We don't compare onChange since it's a stable wrapper around handleTagFilter
// and handleTagFilter itself is memoized and stable
return (
prevProps.tagValue === nextProps.tagValue &&
prevProps.label === nextProps.label &&
prevProps.value === nextProps.value &&
prevProps.options === nextProps.options
);
},
);
TagFilterItem.displayName = 'TagFilterItem';
const TagFilters = () => {
const { query, setCustom } = useSongListFilters();
const serverId = useCurrentServerId();
const tagsQuery = useQuery(
sharedQueries.tags({
options: {
gcTime: 1000 * 60 * 60,
staleTime: 1000 * 60 * 60,
},
query: {
type: LibraryItem.SONG,
},
serverId,
}),
);
const handleTagFilter = useMemo(
() => (tag: string, e: null | string) => {
setCustom((prev) => {
if (!prev) {
return e ? { [tag]: e } : null;
}
if (e === null) {
const rest = Object.fromEntries(
Object.entries(prev).filter(([key]) => key !== tag),
);
return Object.keys(rest).length === 0 ? null : rest;
}
return {
...prev,
[tag]: e,
};
});
},
[setCustom],
);
const tags = useMemo(() => {
return (
tagsQuery.data?.enumTags?.map((tag) => ({
label: titleCase(tag.name),
options: tag.options,
value: tag.name,
})) || []
);
}, [tagsQuery.data?.enumTags]);
// Create stable onChange handlers for each tag using useMemo
const tagHandlers = useMemo(() => {
const handlers = new Map<string, (value: null | string) => void>();
tags.forEach((tag) => {
handlers.set(tag.value, (value: null | string) => handleTagFilter(tag.value, value));
});
return handlers;
}, [tags, handleTagFilter]);
if (tagsQuery.isLoading) {
return <Spinner container />;
}
return (
<>
{tags.map((tag) => (
<TagFilterItem
key={tag.value}
label={tag.label}
onChange={tagHandlers.get(tag.value)!}
options={tag.options}
tagValue={tag.value}
value={query._custom?.[tag.value] as string | undefined}
/>
))}
</>
);
};
@@ -1,10 +1,13 @@
import { lazy, Suspense, useMemo } from 'react';
import { useListContext } from '/@/renderer/context/list-context';
import { ListFilters } from '/@/renderer/features/shared/components/list-filters';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { SongListQuery } from '/@/shared/types/domain-types';
import { LibraryItem, SongListQuery } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types';
const SongListInfiniteGrid = lazy(() =>
@@ -34,16 +37,23 @@ export const SongListContent = () => {
const { customFilters } = useListContext();
return (
<Suspense fallback={<Spinner container />}>
<SongListView
display={display}
grid={grid}
itemsPerPage={itemsPerPage}
overrideQuery={customFilters}
pagination={pagination}
table={table}
/>
</Suspense>
<>
<ListWithSidebarContainer.SidebarPortal>
<ScrollArea>
<ListFilters itemType={LibraryItem.SONG} />
</ScrollArea>
</ListWithSidebarContainer.SidebarPortal>
<Suspense fallback={<Spinner container />}>
<SongListView
display={display}
grid={grid}
itemsPerPage={itemsPerPage}
overrideQuery={customFilters}
pagination={pagination}
table={table}
/>
</Suspense>
</>
);
};
@@ -6,7 +6,7 @@ import {
parseAsString,
useQueryState,
} from 'nuqs';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
@@ -75,18 +75,32 @@ export const useSongListFilters = () => {
setSortOrder,
]);
const query = {
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
[FILTER_KEYS.SONG.ALBUM_IDS]: albumIds ?? undefined,
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
};
const query = useMemo(
() => ({
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
[FILTER_KEYS.SONG._CUSTOM]: custom ?? undefined,
[FILTER_KEYS.SONG.ALBUM_IDS]: albumIds ?? undefined,
[FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined,
[FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined,
[FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined,
[FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined,
[FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined,
}),
[
searchTerm,
sortBy,
sortOrder,
custom,
albumIds,
artistIds,
favorite,
genreId,
maxYear,
minYear,
],
);
return {
clear,
@@ -3,6 +3,7 @@ import { useParams } from 'react-router';
import { ListContext } from '/@/renderer/context/list-context';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { SongListContent } from '/@/renderer/features/songs/components/song-list-content';
import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header';
@@ -28,10 +29,19 @@ const SongListRoute = () => {
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
const customFilters: Partial<SongListQuery> = useMemo(() => {
return {
artistIds: albumArtistId ? [albumArtistId] : undefined,
genreIds: genreId ? [genreId] : undefined,
};
if (albumArtistId) {
return {
artistIds: [albumArtistId],
};
}
if (genreId) {
return {
genreIds: [genreId],
};
}
return {};
}, [albumArtistId, genreId]);
const providerValue = useMemo(() => {
@@ -48,7 +58,9 @@ const SongListRoute = () => {
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<SongListHeader />
<SongListContent />
<ListWithSidebarContainer>
<SongListContent />
</ListWithSidebarContainer>
</ListContext.Provider>
</AnimatedPage>
);