diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index d0bc9a0c0..17f0509f3 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -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), }, diff --git a/src/renderer/features/albums/components/album-list-content.tsx b/src/renderer/features/albums/components/album-list-content.tsx index d79f5ab2d..014d28568 100644 --- a/src/renderer/features/albums/components/album-list-content.tsx +++ b/src/renderer/features/albums/components/album-list-content.tsx @@ -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) { diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index 9bb58cb60..c45ce162a 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -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); } diff --git a/src/renderer/features/shared/components/component-error-boundary.tsx b/src/renderer/features/shared/components/component-error-boundary.tsx index dda165c0f..4d48fc19b 100644 --- a/src/renderer/features/shared/components/component-error-boundary.tsx +++ b/src/renderer/features/shared/components/component-error-boundary.tsx @@ -17,10 +17,6 @@ interface ComponentErrorFallbackProps { const ComponentErrorFallback = ({ resetErrorBoundary }: ComponentErrorFallbackProps) => { const { t } = useTranslation(); - const handleRefresh = () => { - window.location.reload(); - }; - return (
@@ -35,9 +31,6 @@ const ComponentErrorFallback = ({ resetErrorBoundary }: ComponentErrorFallbackPr -
diff --git a/src/renderer/features/shared/components/list-filters.tsx b/src/renderer/features/shared/components/list-filters.tsx index 4821c1d8d..044a9cf72 100644 --- a/src/renderer/features/shared/components/list-filters.tsx +++ b/src/renderer/features/shared/components/list-filters.tsx @@ -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 ; + return ( + + + + ); }; const FILTERS = { diff --git a/src/renderer/features/shared/components/list-with-sidebar-container.module.css b/src/renderer/features/shared/components/list-with-sidebar-container.module.css index d4d88bb16..727dc5c42 100644 --- a/src/renderer/features/shared/components/list-with-sidebar-container.module.css +++ b/src/renderer/features/shared/components/list-with-sidebar-container.module.css @@ -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; diff --git a/src/renderer/features/shared/components/list-with-sidebar-container.tsx b/src/renderer/features/shared/components/list-with-sidebar-container.tsx index a5f42dded..06f95b3d4 100644 --- a/src/renderer/features/shared/components/list-with-sidebar-container.tsx +++ b/src/renderer/features/shared/components/list-with-sidebar-container.tsx @@ -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; } @@ -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(null); - const { isLg, ref: containerQueryRef } = useContainerQuery({ - lg: sidebarBreakpoint, - }); - - const showSidebar = isLg; const contextValue = useMemo( () => ({ - showSidebar, sidebarRef, }), - [showSidebar], + [], ); return ( -
-
+
+
{children}
diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index aae594aa7..bb4621783 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -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; - 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({ 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 ( - {toggleFilters.map((filter) => ( - - {filter.label} - - + {yesNoUndefinedFilters.map((filter) => ( + ))} - - handleYearFilter(e)} - value={filter._custom?.navidrome?.year} - width={50} - /> - {!isGenrePage && !hasBFR && ( - handleGenresFilter(value !== null ? [value] : null)} - searchable - width={150} - /> - )} - - {!isGenrePage && hasBFR && ( - - - - )} - {tagsQuery.data?.enumTags?.length && - tagsQuery.data.enumTags.length > 0 && - tagsQuery.data.enumTags.map((tag) => ( - - i.value === tag.name)?.label || - tag.name - } - onChange={(value) => handleTagFilter(tag.name, value)} - searchable - width={150} - /> - - ))} + handleYearFilter(e.currentTarget.value)} + /> + (e && e.length > 0 ? setGenreId(e) : setGenreId(null))} + searchable + /> + ); }; + +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 ( + + ); + }, + (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 void>(); + tags.forEach((tag) => { + handlers.set(tag.value, (value: null | string) => handleTagFilter(tag.value, value)); + }); + return handlers; + }, [tags, handleTagFilter]); + + if (tagsQuery.isLoading) { + return ; + } + + return ( + <> + {tags.map((tag) => ( + + ))} + + ); +}; diff --git a/src/renderer/features/songs/components/song-list-content.tsx b/src/renderer/features/songs/components/song-list-content.tsx index 5817036b9..cf5e616fb 100644 --- a/src/renderer/features/songs/components/song-list-content.tsx +++ b/src/renderer/features/songs/components/song-list-content.tsx @@ -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 ( - }> - - + <> + + + + + + }> + + + ); }; diff --git a/src/renderer/features/songs/hooks/use-song-list-filters.ts b/src/renderer/features/songs/hooks/use-song-list-filters.ts index eb2ad3b84..ed6b59219 100644 --- a/src/renderer/features/songs/hooks/use-song-list-filters.ts +++ b/src/renderer/features/songs/hooks/use-song-list-filters.ts @@ -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, diff --git a/src/renderer/features/songs/routes/song-list-route.tsx b/src/renderer/features/songs/routes/song-list-route.tsx index a4090d613..a6034763f 100644 --- a/src/renderer/features/songs/routes/song-list-route.tsx +++ b/src/renderer/features/songs/routes/song-list-route.tsx @@ -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(undefined); const customFilters: Partial = 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 = () => { - + + + );