From aff7a61bcadb6fa80b7500d3c6f5c41cdfaf7af5 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 2 Dec 2025 00:11:42 -0800 Subject: [PATCH] fix list filters --- package.json | 1 - pnpm-lock.yaml | 14 -- .../api/jellyfin/jellyfin-controller.ts | 3 +- .../api/navidrome/navidrome-controller.ts | 73 +++++-- .../helpers/use-item-list-scroll-persist.ts | 11 +- .../use-item-list-pagination.ts | 17 +- .../components/jellyfin-album-filters.tsx | 74 +++---- .../components/navidrome-album-filters.tsx | 159 +++++++-------- .../components/subsonic-album-filters.tsx | 66 +++---- .../albums/hooks/use-album-list-filters.ts | 184 ++++++++++++++---- .../hooks/use-playlist-list-filters.ts | 59 +++++- .../hooks/use-playlist-song-list-filters.ts | 143 +++++++++++--- .../hooks/use-music-folder-id-filter.ts | 39 ++-- .../shared/hooks/use-search-term-filter.ts | 28 ++- .../shared/hooks/use-select-filter.ts | 34 ++-- .../shared/hooks/use-sort-by-filter.ts | 35 ++-- .../shared/hooks/use-sort-order-filter.ts | 35 ++-- src/renderer/features/shared/utils.ts | 9 +- .../components/jellyfin-song-filters.tsx | 101 ++++------ .../components/navidrome-song-filters.tsx | 159 +++++++-------- .../components/subsonic-song-filters.tsx | 27 ++- .../songs/hooks/use-song-list-filters.ts | 142 +++++++++++--- src/renderer/router/app-outlet.tsx | 7 +- src/renderer/utils/query-params.ts | 141 ++++++++++++++ .../yes-no-select/yes-no-select.tsx | 11 +- src/shared/types/domain-types.ts | 15 +- 26 files changed, 1022 insertions(+), 565 deletions(-) create mode 100644 src/renderer/utils/query-params.ts diff --git a/package.json b/package.json index 2cf49d926..15c8d5b15 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,6 @@ "@mantine/hooks": "^8.3.8", "@mantine/modals": "^8.3.8", "@mantine/notifications": "^8.3.8", - "@offlegacy/nuqs-hash-router": "^0.1.1", "@radix-ui/react-context-menu": "^2.2.16", "@tanstack/react-query": "^5.90.9", "@tanstack/react-query-devtools": "^5.90.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f583609a..1a47ff011 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,6 @@ importers: '@mantine/notifications': specifier: ^8.3.8 version: 8.3.8(@mantine/core@8.3.8(@mantine/hooks@8.3.8(react@19.1.0))(@types/react@19.2.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@8.3.8(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@offlegacy/nuqs-hash-router': - specifier: ^0.1.1 - version: 0.1.1(nuqs@2.7.1(react-router-dom@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.9.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0))(react@19.1.0) '@radix-ui/react-context-menu': specifier: ^2.2.16 version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1398,12 +1395,6 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This functionality has been moved to @npmcli/fs - '@offlegacy/nuqs-hash-router@0.1.1': - resolution: {integrity: sha512-dRTyovVxKBjRQrFU3qR7zBW/AvnVtYtBXWqIrkhIJXXLwytT05dIAz3dKxhXN9WvLuGdbzOP66p1ML2WR60CSQ==} - peerDependencies: - nuqs: '2' - react: '>=18' - '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -7111,11 +7102,6 @@ snapshots: mkdirp: 1.0.4 rimraf: 3.0.2 - '@offlegacy/nuqs-hash-router@0.1.1(nuqs@2.7.1(react-router-dom@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.9.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0))(react@19.1.0)': - dependencies: - nuqs: 2.7.1(react-router-dom@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.9.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) - react: 19.1.0 - '@pkgjs/parseargs@0.11.0': optional: true diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 4da330a25..f4b59aab0 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -857,7 +857,7 @@ export const JellyfinController: InternalControllerEndpoint = { const { apiClientProps, query } = args; if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) { - return { boolTags: undefined, enumTags: undefined }; + return { boolTags: undefined, enumTags: undefined, excluded: { album: [], song: [] } }; } const res = await jfApiClient(apiClientProps).getFilterList({ @@ -876,6 +876,7 @@ export const JellyfinController: InternalControllerEndpoint = { boolTags: res.body.Tags?.sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()), ), + excluded: { album: [], song: [] }, }; }, getTopSongs: async (args) => { diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 17f0509f3..985eca474 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -45,7 +45,31 @@ const NAVIDROME_ROLES: Array = [ 'remixer', ]; -const EXCLUDED_TAGS = new Set(['disctotal', 'genre', 'tracktotal']); +// Tags that are irrelevant or non-functional as filters +const EXCLUDED_TAGS = new Set([ + 'genre', // Duplicate of genre filter +]); + +const EXCLUDED_ALBUM_TAGS = new Set([ + 'asin', + 'barcode', + 'copyright', + 'disctotal', + 'encodedby', + 'isrc', + 'key', + 'language', + 'musicbrainz_workid', + 'script', + 'tracktotal', + 'website', + 'work', +]); + +const EXCLUDED_SONG_TAGS = new Set([]); + +// Tags that use IDs as values as opposed to the tag value +const ID_TAGS = new Set(['albumversion', 'mood']); const excludeMissing = (server?: null | ServerListItemWithCredential) => { if (!server) { @@ -297,8 +321,10 @@ export const NavidromeController: InternalControllerEndpoint = { artist_id: query.artistIds?.[0], compilation: query.compilation, genre_id: genres, + has_rating: query.hasRating, library_id: getLibraryId(query.musicFolderId), name: query.searchTerm, + recently_played: query.isRecentlyPlayed, year: query.maxYear || query.minYear, ...query._custom, starred: query.favorite, @@ -615,7 +641,7 @@ export const NavidromeController: InternalControllerEndpoint = { const { apiClientProps } = args; if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) { - return { boolTags: undefined, enumTags: undefined }; + return { boolTags: undefined, enumTags: undefined, excluded: { album: [], song: [] } }; } const res = await ndApiClient(apiClientProps).getTags(); @@ -624,30 +650,47 @@ export const NavidromeController: InternalControllerEndpoint = { throw new Error('failed to get tags'); } - const tagsToValues = new Map(); + const tagsToValues = new Map(); for (const tag of res.body.data) { if (!EXCLUDED_TAGS.has(tag.tagName)) { if (tagsToValues.has(tag.tagName)) { - tagsToValues.get(tag.tagName)!.push(tag.tagValue); + tagsToValues.get(tag.tagName)!.push({ + id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue, + name: tag.tagValue, + }); } else { - tagsToValues.set(tag.tagName, [tag.tagValue]); + tagsToValues.set(tag.tagName, [ + { + id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue, + name: tag.tagValue, + }, + ]); } } } + const enumTags = Array.from(tagsToValues) + .map((data) => ({ + name: data[0], + options: data[1] + .sort((a, b) => + a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()), + ) + .map((option) => ({ id: option.id, name: option.name })), + })) + .sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase())); + + const excludedAlbumTags = Array.from(EXCLUDED_ALBUM_TAGS.values()); + const excludedSongTags = Array.from(EXCLUDED_SONG_TAGS.values()); + return { boolTags: undefined, - enumTags: Array.from(tagsToValues) - .map((data) => ({ - name: data[0], - options: data[1].sort((a, b) => - a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()), - ), - })) - .sort((a, b) => - a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()), - ), + enumTags, + excluded: { + album: excludedAlbumTags, + song: excludedSongTags, + }, }; }, getTopSongs: SubsonicController.getTopSongs, diff --git a/src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts b/src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts index a80ef0c99..92453b359 100644 --- a/src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts +++ b/src/renderer/components/item-list/helpers/use-item-list-scroll-persist.ts @@ -1,16 +1,21 @@ -import { parseAsInteger, useQueryState } from 'nuqs'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router'; + +import { parseIntParam, setSearchParam } from '/@/renderer/utils/query-params'; interface UseItemListScrollPersistProps { enabled: boolean; } export const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => { - const [scrollOffset, setScrollOffset] = useQueryState('scrollOffset', parseAsInteger); + const [searchParams, setSearchParams] = useSearchParams(); + + const scrollOffset = useMemo(() => parseIntParam(searchParams, 'scrollOffset'), [searchParams]); const handleOnScrollEnd = (offset: number) => { if (!enabled) return; - setScrollOffset(offset); + setSearchParams((prev) => setSearchParam(prev, 'scrollOffset', offset), { replace: true }); }; return { handleOnScrollEnd, scrollOffset }; diff --git a/src/renderer/components/item-list/item-list-pagination/use-item-list-pagination.ts b/src/renderer/components/item-list/item-list-pagination/use-item-list-pagination.ts index bbbd5f168..20cd45b04 100644 --- a/src/renderer/components/item-list/item-list-pagination/use-item-list-pagination.ts +++ b/src/renderer/components/item-list/item-list-pagination/use-item-list-pagination.ts @@ -1,13 +1,18 @@ -import { parseAsInteger, useQueryState } from 'nuqs'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router'; + +import { parseIntParam, setSearchParam } from '/@/renderer/utils/query-params'; export const useItemListPagination = () => { - const [currentPage, setCurrentPage] = useQueryState( - 'currentPage', - parseAsInteger.withDefault(0), - ); + const [searchParams, setSearchParams] = useSearchParams(); + + const currentPage = useMemo(() => { + const value = parseIntParam(searchParams, 'currentPage'); + return value ?? 0; + }, [searchParams]); const onChange = (index: number) => { - setCurrentPage(index); + setSearchParams((prev) => setSearchParam(prev, 'currentPage', index), { replace: true }); }; return { currentPage, onChange }; diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index 9fe7075e1..9369f843a 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -3,17 +3,19 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; +import { useListContext } from '/@/renderer/context/list-context'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; -import { AlbumListFilter, useCurrentServerId } from '/@/renderer/store'; +import { useCurrentServerId } from '/@/renderer/store'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { NumberInput } from '/@/shared/components/number-input/number-input'; import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; import { Stack } from '/@/shared/components/stack/stack'; import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select'; +import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; import { AlbumArtistListSort, GenreListSort, @@ -22,15 +24,17 @@ import { } from '/@/shared/types/domain-types'; interface JellyfinAlbumFiltersProps { - customFilters?: Partial; disableArtistFilter?: boolean; - onFilterChange: (filters: AlbumListFilter) => void; } export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFiltersProps) => { const { t } = useTranslation(); const serverId = useCurrentServerId(); + const { customFilters } = useListContext(); + + const isGenrePage = customFilters?.genreIds !== undefined; + const { query, setAlbumArtist, @@ -180,46 +184,25 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte const handleTagFilter = useMemo( () => (e: string[] | undefined) => { - setCustom((prev) => { - if (!prev) { - return e && e.length > 0 ? { [e.join('|')]: e.join('|') } : null; - } - - if (!e || e.length === 0) { - // Remove all tag-related properties (they use '|' joined keys) - const rest = Object.fromEntries( - Object.entries(prev).filter(([key]) => !key.includes('|')), - ); - - return Object.keys(rest).length === 0 ? null : rest; - } - - // Remove old tag entries and add new one - const rest = Object.fromEntries( - Object.entries(prev).filter(([key]) => !key.includes('|')), - ); - const tagKey = e.join('|'); - - return { - ...rest, - [tagKey]: tagKey, - }; - }); + setCustom({ Tags: e?.join('|') ?? null }); }, [setCustom], ); + const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300); + const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300); + return ( - + {yesNoFilter.map((filter) => ( filter.onChange(e ? e === 'true' : undefined)} /> ))} - + handleMinYearFilter(e.currentTarget.value)} + onChange={(e) => debouncedHandleMinYearFilter(e)} required={!!query.minYear} /> handleMaxYearFilter(e.currentTarget.value)} + onChange={(e) => debouncedHandleMaxYearFilter(e)} required={!!query.minYear} /> - handleGenresFilter(e)} - searchable - /> - + {!isGenrePage && ( + + )} + {yesNoUndefinedFilters.map((filter) => ( filter.onChange(e ? e === 'true' : undefined)} /> ))} {toggleFilters.map((filter) => ( {filter.label} - + ))} - + handleYearFilter(e.currentTarget.value)} - /> - (e && e.length > 0 ? setGenreId(e) : setGenreId(null))} - searchable + onChange={(e) => debouncedHandleYearFilter(e)} /> + {!isGenrePage && ( + (e && e.length > 0 ? setGenreId(e) : setGenreId(null))} + searchable + /> + )} : undefined} searchable /> + ); @@ -194,38 +206,34 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil interface TagFilterItemProps { label: string; onChange: (value: null | string) => void; - options: string[]; + options: Array<{ id: string; name: 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 - ); - }, -); +const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterItemProps) => { + const selectData = useMemo( + () => + options.map((option) => ({ + label: option.name, + value: option.id, + })), + [options], + ); + + return ( + + ); +}; TagFilterItem.displayName = 'TagFilterItem'; @@ -234,7 +242,7 @@ const TagFilters = () => { const serverId = useCurrentServerId(); - const tagsQuery = useQuery( + const tagsQuery = useSuspenseQuery( sharedQueries.tags({ options: { gcTime: 1000 * 60 * 60, @@ -249,50 +257,27 @@ const TagFilters = () => { 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({ [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]); + const results: { label: string; options: { id: string; name: string }[]; value: string }[] = + []; - // 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]); + for (const tag of tagsQuery.data?.enumTags || []) { + if (!tagsQuery.data?.excluded.album.includes(tag.name)) { + results.push({ + label: titleCase(tag.name), + options: tag.options, + value: tag.name, + }); + } + } - if (tagsQuery.isLoading) { - return ; - } + return results; + }, [tagsQuery.data]); return ( <> @@ -300,7 +285,7 @@ const TagFilters = () => { handleTagFilter(tag.value, e)} options={tag.options} tagValue={tag.value} value={query._custom?.[tag.value] as string | undefined} diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx index ff0e79583..608ece2ce 100644 --- a/src/renderer/features/albums/components/subsonic-album-filters.tsx +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -1,11 +1,12 @@ -import { useQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { ChangeEvent, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; +import { useListContext } from '/@/renderer/context/list-context'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; -import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; +import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { useCurrentServerId } from '/@/renderer/store'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; @@ -15,7 +16,8 @@ import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; import { Text } from '/@/shared/components/text/text'; -import { AlbumArtistListSort, GenreListSort, SortOrder } from '/@/shared/types/domain-types'; +import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; +import { AlbumArtistListSort, SortOrder } from '/@/shared/types/domain-types'; interface SubsonicAlbumFiltersProps { disableArtistFilter?: boolean; @@ -26,12 +28,16 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte const serverId = useCurrentServerId(); + const { customFilters } = useListContext(); + + const isGenrePage = customFilters?.genreIds !== undefined; + const { query, setAlbumArtist, setFavorite, setGenreId, setMaxYear, setMinYear } = useAlbumListFilters(); const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState(''); - const albumArtistListQuery = useQuery( + const albumArtistListQuery = useSuspenseQuery( artistsQueries.albumArtistList({ options: { gcTime: 1000 * 60 * 2, @@ -64,20 +70,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte [setAlbumArtist], ); - const genreListQuery = useQuery( - genresQueries.list({ - options: { - gcTime: 1000 * 60 * 2, - staleTime: 1000 * 60 * 1, - }, - query: { - sortBy: GenreListSort.NAME, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, - serverId, - }), - ); + const genreListQuery = useGenreList(); const genreList = useMemo(() => { if (!genreListQuery?.data) return []; @@ -146,15 +139,18 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte [setMaxYear], ); + const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300); + const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300); + return ( - + {toggleFilters.map((filter) => ( - + {filter.label} - + ))} - + handleMinYearFilter(e.currentTarget.value)} + onChange={(e) => debouncedHandleMinYearFilter(e)} /> handleMaxYearFilter(e.currentTarget.value)} + onChange={(e) => debouncedHandleMaxYearFilter(e)} /> - handleGenresFilter(e)} + searchable + /> + )} { const { searchTerm, setSearchTerm } = useSearchTermFilter(''); - const [genreId, setGenreId] = useQueryState( - FILTER_KEYS.ALBUM.GENRE_ID, - parseAsArrayOf(parseAsString), + const [searchParams, setSearchParams] = useSearchParams(); + + const genreId = useMemo( + () => parseArrayParam(searchParams, FILTER_KEYS.ALBUM.GENRE_ID), + [searchParams], ); - const [albumArtist, setAlbumArtist] = useQueryState( - FILTER_KEYS.ALBUM.ARTIST_IDS, - parseAsArrayOf(parseAsString), + const albumArtist = useMemo( + () => parseArrayParam(searchParams, FILTER_KEYS.ALBUM.ARTIST_IDS), + [searchParams], ); - const [minYear, setMinYear] = useQueryState(FILTER_KEYS.ALBUM.MIN_YEAR, parseAsInteger); - - const [maxYear, setMaxYear] = useQueryState(FILTER_KEYS.ALBUM.MAX_YEAR, parseAsInteger); - - const [favorite, setFavorite] = useQueryState(FILTER_KEYS.ALBUM.FAVORITE, parseAsBoolean); - - const [compilation, setCompilation] = useQueryState( - FILTER_KEYS.ALBUM.COMPILATION, - parseAsBoolean, + const minYear = useMemo( + () => parseIntParam(searchParams, FILTER_KEYS.ALBUM.MIN_YEAR), + [searchParams], ); - const [hasRating, setHasRating] = useQueryState(FILTER_KEYS.ALBUM.HAS_RATING, parseAsBoolean); - - const [recentlyPlayed, setRecentlyPlayed] = useQueryState( - FILTER_KEYS.ALBUM.RECENTLY_PLAYED, - parseAsBoolean, + const maxYear = useMemo( + () => parseIntParam(searchParams, FILTER_KEYS.ALBUM.MAX_YEAR), + [searchParams], ); - const [custom, setCustom] = useQueryState( - FILTER_KEYS.ALBUM._CUSTOM, - parseAsJson(customFiltersSchema), + const favorite = useMemo( + () => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.FAVORITE), + [searchParams], + ); + + const compilation = useMemo( + () => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.COMPILATION), + [searchParams], + ); + + const hasRating = useMemo( + () => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.HAS_RATING), + [searchParams], + ); + + const recentlyPlayed = useMemo( + () => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.RECENTLY_PLAYED), + [searchParams], + ); + + const custom = useMemo( + () => parseCustomFiltersParam(searchParams, FILTER_KEYS.ALBUM._CUSTOM), + [searchParams], + ); + + // Use a ref to track the latest custom filters to avoid stale state during batched updates + const customRef = useRef | undefined>(custom); + useEffect(() => { + customRef.current = custom; + }, [custom]); + + const setGenreId = useCallback( + (value: null | string[]) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setAlbumArtist = useCallback( + (value: null | string[]) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.ARTIST_IDS, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setMinYear = useCallback( + (value: null | number) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MIN_YEAR, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setMaxYear = useCallback( + (value: null | number) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MAX_YEAR, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setFavorite = useCallback( + (value: boolean | null) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.FAVORITE, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setCompilation = useCallback( + (value: boolean | null) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.COMPILATION, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setHasRating = useCallback( + (value: boolean | null) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.HAS_RATING, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setRecentlyPlayed = useCallback( + (value: boolean | null) => { + setSearchParams( + (prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.RECENTLY_PLAYED, value), + { + replace: true, + }, + ); + }, + [setSearchParams], + ); + + const setCustom = useCallback( + (value: null | Record) => { + setSearchParams((prev) => { + const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM); + + const newCustom = { + ...(previousValue ? JSON.parse(previousValue) : {}), + ...value, + }; + + const filteredNewCustom = Object.fromEntries( + Object.entries(newCustom).filter( + ([, value]) => value !== null && value !== undefined, + ), + ); + + prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom)); + return prev; + }); + }, + [setSearchParams], ); const clear = useCallback(() => { diff --git a/src/renderer/features/playlists/hooks/use-playlist-list-filters.ts b/src/renderer/features/playlists/hooks/use-playlist-list-filters.ts index a9d1c3550..57674ed7d 100644 --- a/src/renderer/features/playlists/hooks/use-playlist-list-filters.ts +++ b/src/renderer/features/playlists/hooks/use-playlist-list-filters.ts @@ -1,9 +1,11 @@ -import { parseAsJson, useQueryState } from 'nuqs'; +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router'; import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; -import { customFiltersSchema, FILTER_KEYS } from '/@/renderer/features/shared/utils'; +import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; +import { parseCustomFiltersParam, setJsonSearchParam } from '/@/renderer/utils/query-params'; import { PlaylistListSort } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; @@ -13,14 +15,53 @@ export const usePlaylistListFilters = () => { const { searchTerm, setSearchTerm } = useSearchTermFilter(''); - const [custom, setCustom] = useQueryState('playlistCustom', parseAsJson(customFiltersSchema)); + const [searchParams, setSearchParams] = useSearchParams(); - const query = { - _custom: custom ?? undefined, - searchTerm: searchTerm ?? undefined, - sortBy: sortByFilter[FILTER_KEYS.SHARED.SORT_BY] ?? undefined, - sortOrder: sortOrderFilter[FILTER_KEYS.SHARED.SORT_ORDER] ?? undefined, - }; + const custom = useMemo( + () => parseCustomFiltersParam(searchParams, FILTER_KEYS.PLAYLIST.CUSTOM), + [searchParams], + ); + + const setCustom = useCallback( + ( + value: + | ((prev: null | Record) => null | Record) + | null + | Record, + ) => { + setSearchParams( + (prev) => { + const currentCustom = parseCustomFiltersParam( + prev, + FILTER_KEYS.PLAYLIST.CUSTOM, + ); + let newValue = + typeof value === 'function' ? value(currentCustom ?? null) : value; + // Convert empty objects to null to clear them from URL + if ( + newValue && + typeof newValue === 'object' && + Object.keys(newValue).length === 0 + ) { + newValue = null; + } + return setJsonSearchParam(prev, FILTER_KEYS.PLAYLIST.CUSTOM, newValue); + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const query = useMemo( + () => ({ + _custom: custom ?? undefined, + searchTerm: searchTerm ?? undefined, + sortBy: sortByFilter.sortBy ?? undefined, + sortOrder: sortOrderFilter.sortOrder ?? undefined, + }), + [custom, searchTerm, sortByFilter.sortBy, sortOrderFilter.sortOrder], + ); return { query, diff --git a/src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts b/src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts index 1350d3cb6..4d4b21323 100644 --- a/src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts +++ b/src/renderer/features/playlists/hooks/use-playlist-song-list-filters.ts @@ -1,16 +1,18 @@ -import { - parseAsArrayOf, - parseAsBoolean, - parseAsInteger, - parseAsJson, - parseAsString, - useQueryState, -} from 'nuqs'; +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router'; import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; -import { customFiltersSchema, FILTER_KEYS } from '/@/renderer/features/shared/utils'; +import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; +import { + parseArrayParam, + parseBooleanParam, + parseCustomFiltersParam, + parseIntParam, + setJsonSearchParam, + setSearchParam, +} from '/@/renderer/utils/query-params'; import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; @@ -21,30 +23,123 @@ export const usePlaylistSongListFilters = () => { const { searchTerm, setSearchTerm } = useSearchTermFilter(''); - const [albumIds, setAlbumIds] = useQueryState( - FILTER_KEYS.SONG.ALBUM_IDS, - parseAsArrayOf(parseAsString), + const [searchParams, setSearchParams] = useSearchParams(); + + const albumIds = useMemo( + () => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS), + [searchParams], ); - const [genreId, setGenreId] = useQueryState( - FILTER_KEYS.SONG.GENRE_ID, - parseAsArrayOf(parseAsString), + const genreId = useMemo( + () => parseArrayParam(searchParams, FILTER_KEYS.SONG.GENRE_ID), + [searchParams], ); - const [artistIds, setArtistIds] = useQueryState( - FILTER_KEYS.SONG.ARTIST_IDS, - parseAsArrayOf(parseAsString), + const artistIds = useMemo( + () => parseArrayParam(searchParams, FILTER_KEYS.SONG.ARTIST_IDS), + [searchParams], ); - const [minYear, setMinYear] = useQueryState(FILTER_KEYS.SONG.MIN_YEAR, parseAsInteger); + const minYear = useMemo( + () => parseIntParam(searchParams, FILTER_KEYS.SONG.MIN_YEAR), + [searchParams], + ); - const [maxYear, setMaxYear] = useQueryState(FILTER_KEYS.SONG.MAX_YEAR, parseAsInteger); + const maxYear = useMemo( + () => parseIntParam(searchParams, FILTER_KEYS.SONG.MAX_YEAR), + [searchParams], + ); - const [favorite, setFavorite] = useQueryState(FILTER_KEYS.SONG.FAVORITE, parseAsBoolean); + const favorite = useMemo( + () => parseBooleanParam(searchParams, FILTER_KEYS.SONG.FAVORITE), + [searchParams], + ); - const [custom, setCustom] = useQueryState( - FILTER_KEYS.SONG._CUSTOM, - parseAsJson(customFiltersSchema), + const custom = useMemo( + () => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM), + [searchParams], + ); + + const setAlbumIds = useCallback( + (value: null | string[]) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setGenreId = useCallback( + (value: null | string[]) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setArtistIds = useCallback( + (value: null | string[]) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ARTIST_IDS, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setMinYear = useCallback( + (value: null | number) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MIN_YEAR, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setMaxYear = useCallback( + (value: null | number) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MAX_YEAR, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setFavorite = useCallback( + (value: boolean | null) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.FAVORITE, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setCustom = useCallback( + ( + value: + | ((prev: null | Record) => null | Record) + | null + | Record, + ) => { + setSearchParams( + (prev) => { + const currentCustom = parseCustomFiltersParam(prev, FILTER_KEYS.SONG._CUSTOM); + let newValue = + typeof value === 'function' ? value(currentCustom ?? null) : value; + // Convert empty objects to null to clear them from URL + if ( + newValue && + typeof newValue === 'object' && + Object.keys(newValue).length === 0 + ) { + newValue = null; + } + return setJsonSearchParam(prev, FILTER_KEYS.SONG._CUSTOM, newValue); + }, + { replace: true }, + ); + }, + [setSearchParams], ); const query = { diff --git a/src/renderer/features/shared/hooks/use-music-folder-id-filter.ts b/src/renderer/features/shared/hooks/use-music-folder-id-filter.ts index cc5707c18..8d16839ef 100644 --- a/src/renderer/features/shared/hooks/use-music-folder-id-filter.ts +++ b/src/renderer/features/shared/hooks/use-music-folder-id-filter.ts @@ -1,40 +1,41 @@ -import { parseAsString, useQueryState } from 'nuqs'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router'; import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { useCurrentServer } from '/@/renderer/store'; +import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params'; import { ItemListKey } from '/@/shared/types/types'; export const useMusicFolderIdFilter = (defaultValue: null | string, listKey: ItemListKey) => { const server = useCurrentServer(); const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey); + const [searchParams, setSearchParams] = useSearchParams(); const persisted = getFilter(FILTER_KEYS.SHARED.MUSIC_FOLDER_ID); - const [musicFolderId, setMusicFolderId] = useQueryState( - FILTER_KEYS.SHARED.MUSIC_FOLDER_ID, - getDefaultMusicFolderId(defaultValue, persisted), - ); + const musicFolderId = useMemo(() => { + const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.MUSIC_FOLDER_ID); + return value ?? persisted ?? defaultValue ?? undefined; + }, [searchParams, persisted, defaultValue]); const handleSetMusicFolderId = (musicFolderId: string) => { - setMusicFolderId(musicFolderId); + setSearchParams( + (prev) => { + const newParams = setSearchParam( + prev, + FILTER_KEYS.SHARED.MUSIC_FOLDER_ID, + musicFolderId, + ); + return newParams; + }, + { replace: true }, + ); setFilter(FILTER_KEYS.SHARED.MUSIC_FOLDER_ID, musicFolderId); }; return { - [FILTER_KEYS.SHARED.MUSIC_FOLDER_ID]: musicFolderId ?? undefined, + musicFolderId, setMusicFolderId: handleSetMusicFolderId, }; }; - -const getDefaultMusicFolderId = (defaultValue: null | string, persisted: string | undefined) => { - if (persisted) { - return parseAsString.withDefault(persisted); - } - - if (defaultValue) { - return parseAsString.withDefault(defaultValue); - } - - return parseAsString; -}; diff --git a/src/renderer/features/shared/hooks/use-search-term-filter.ts b/src/renderer/features/shared/hooks/use-search-term-filter.ts index f7ad10aa7..53053e062 100644 --- a/src/renderer/features/shared/hooks/use-search-term-filter.ts +++ b/src/renderer/features/shared/hooks/use-search-term-filter.ts @@ -1,22 +1,36 @@ -import { parseAsString, useQueryState } from 'nuqs'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; +import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params'; import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; export const useSearchTermFilter = (defaultValue?: string) => { - const [searchTerm, setSearchTerm] = useQueryState( - FILTER_KEYS.SHARED.SEARCH_TERM, - defaultValue ? parseAsString.withDefault(defaultValue) : parseAsString, - ); + const [searchParams, setSearchParams] = useSearchParams(); + + const searchTerm = useMemo(() => { + const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.SEARCH_TERM); + return value ?? defaultValue ?? undefined; + }, [searchParams, defaultValue]); const handleSetSearchTerm = (value: null | string) => { - setSearchTerm(value === '' ? null : value); + setSearchParams( + (prev) => { + const newParams = setSearchParam( + prev, + FILTER_KEYS.SHARED.SEARCH_TERM, + value === '' ? null : value, + ); + return newParams; + }, + { replace: true }, + ); }; const debouncedSetSearchTerm = useDebouncedCallback(handleSetSearchTerm, 300); return { - [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm || undefined, + searchTerm: searchTerm || undefined, setSearchTerm: debouncedSetSearchTerm, }; }; diff --git a/src/renderer/features/shared/hooks/use-select-filter.ts b/src/renderer/features/shared/hooks/use-select-filter.ts index 4fa228469..549a7908d 100644 --- a/src/renderer/features/shared/hooks/use-select-filter.ts +++ b/src/renderer/features/shared/hooks/use-select-filter.ts @@ -1,7 +1,9 @@ -import { parseAsString, useQueryState } from 'nuqs'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router'; import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence'; import { useCurrentServer } from '/@/renderer/store'; +import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params'; import { ItemListKey } from '/@/shared/types/types'; export const useSelectFilter = ( @@ -11,31 +13,29 @@ export const useSelectFilter = ( ) => { const server = useCurrentServer(); const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey); + const [searchParams, setSearchParams] = useSearchParams(); const persisted = getFilter(filterKey); - const [value, setValue] = useQueryState(filterKey, getDefaultValue(defaultValue, persisted)); + const value = useMemo(() => { + const paramValue = parseStringParam(searchParams, filterKey); + return paramValue ?? persisted ?? defaultValue ?? undefined; + }, [searchParams, filterKey, persisted, defaultValue]); const handleSetValue = (newValue: string) => { - setValue(newValue); + setSearchParams( + (prev) => { + const newParams = setSearchParam(prev, filterKey, newValue); + return newParams; + }, + { replace: true }, + ); setFilter(filterKey, newValue); }; return { - [filterKey]: value ?? undefined, + [filterKey]: value, setValue: handleSetValue, - value: value ?? undefined, + value, }; }; - -const getDefaultValue = (defaultValue: null | string, persisted: string | undefined) => { - if (persisted) { - return parseAsString.withDefault(persisted); - } - - if (defaultValue) { - return parseAsString.withDefault(defaultValue); - } - - return parseAsString; -}; diff --git a/src/renderer/features/shared/hooks/use-sort-by-filter.ts b/src/renderer/features/shared/hooks/use-sort-by-filter.ts index 593e7de52..87ae11266 100644 --- a/src/renderer/features/shared/hooks/use-sort-by-filter.ts +++ b/src/renderer/features/shared/hooks/use-sort-by-filter.ts @@ -1,40 +1,37 @@ -import { parseAsString, useQueryState } from 'nuqs'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router'; import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { useCurrentServer } from '/@/renderer/store'; +import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params'; import { ItemListKey } from '/@/shared/types/types'; export const useSortByFilter = (defaultValue: null | string, listKey: ItemListKey) => { const server = useCurrentServer(); const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey); + const [searchParams, setSearchParams] = useSearchParams(); const persisted = getFilter(FILTER_KEYS.SHARED.SORT_BY); - const [sortBy, setSortBy] = useQueryState( - FILTER_KEYS.SHARED.SORT_BY, - getDefaultSortBy(defaultValue, persisted), - ); + const sortBy = useMemo(() => { + const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.SORT_BY); + return (value ?? persisted ?? defaultValue ?? undefined) as TSortBy; + }, [searchParams, persisted, defaultValue]); const handleSetSortBy = (sortBy: string) => { - setSortBy(sortBy); + setSearchParams( + (prev) => { + const newParams = setSearchParam(prev, FILTER_KEYS.SHARED.SORT_BY, sortBy); + return newParams; + }, + { replace: true }, + ); setFilter(FILTER_KEYS.SHARED.SORT_BY, sortBy); }; return { - [FILTER_KEYS.SHARED.SORT_BY]: sortBy as TSortBy, setSortBy: handleSetSortBy, + sortBy, }; }; - -const getDefaultSortBy = (defaultValue: null | string, persisted: string | undefined) => { - if (persisted) { - return parseAsString.withDefault(persisted); - } - - if (defaultValue) { - return parseAsString.withDefault(defaultValue); - } - - return parseAsString; -}; diff --git a/src/renderer/features/shared/hooks/use-sort-order-filter.ts b/src/renderer/features/shared/hooks/use-sort-order-filter.ts index 27fd0f5cf..84f1cfa76 100644 --- a/src/renderer/features/shared/hooks/use-sort-order-filter.ts +++ b/src/renderer/features/shared/hooks/use-sort-order-filter.ts @@ -1,41 +1,38 @@ -import { parseAsString, useQueryState } from 'nuqs'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router'; import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { useCurrentServer } from '/@/renderer/store'; +import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params'; import { SortOrder } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; export const useSortOrderFilter = (defaultValue: null | string, listKey: ItemListKey) => { const server = useCurrentServer(); const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey); + const [searchParams, setSearchParams] = useSearchParams(); const persisted = getFilter(FILTER_KEYS.SHARED.SORT_ORDER); - const [sortOrder, setSortOrder] = useQueryState( - FILTER_KEYS.SHARED.SORT_ORDER, - getDefaultSortOrder(defaultValue, persisted), - ); + const sortOrder = useMemo(() => { + const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.SORT_ORDER); + return (value ?? persisted ?? defaultValue ?? undefined) as SortOrder; + }, [searchParams, persisted, defaultValue]); const handleSetSortOrder = (sortOrder: SortOrder) => { - setSortOrder(sortOrder); + setSearchParams( + (prev) => { + const newParams = setSearchParam(prev, FILTER_KEYS.SHARED.SORT_ORDER, sortOrder); + return newParams; + }, + { replace: true }, + ); setFilter(FILTER_KEYS.SHARED.SORT_ORDER, sortOrder); }; return { - [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder as SortOrder, setSortOrder: handleSetSortOrder, + sortOrder, }; }; - -const getDefaultSortOrder = (defaultValue: null | string, persisted: string | undefined) => { - if (persisted) { - return parseAsString.withDefault(persisted); - } - - if (defaultValue) { - return parseAsString.withDefault(defaultValue); - } - - return parseAsString; -}; diff --git a/src/renderer/features/shared/utils.ts b/src/renderer/features/shared/utils.ts index 3056ef62d..d54c4ce28 100644 --- a/src/renderer/features/shared/utils.ts +++ b/src/renderer/features/shared/utils.ts @@ -44,7 +44,7 @@ enum AlbumFilterKeys { HAS_RATING = 'hasRating', MAX_YEAR = 'maxYear', MIN_YEAR = 'minYear', - RECENTLY_PLAYED = 'recentlyPlayed', + RECENTLY_PLAYED = 'isRecentlyPlayed', } enum ArtistFilterKeys { @@ -63,7 +63,7 @@ enum SongFilterKeys { ALBUM_IDS = 'albumIds', ARTIST_IDS = 'artistIds', FAVORITE = 'favorite', - GENRE_ID = 'genreId', + GENRE_ID = 'genreIds', MAX_YEAR = 'maxYear', MIN_YEAR = 'minYear', } @@ -73,10 +73,15 @@ const PaginationFilterKeys = { SCROLL_OFFSET: 'scrollOffset', }; +enum PlaylistFilterKeys { + CUSTOM = '_custom', +} + export const FILTER_KEYS = { ALBUM: AlbumFilterKeys, ARTIST: ArtistFilterKeys, PAGINATION: PaginationFilterKeys, + PLAYLIST: PlaylistFilterKeys, SHARED: SharedFilterKeys, SONG: SongFilterKeys, }; diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx index ca9ca97d7..448c89fa7 100644 --- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx +++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx @@ -3,27 +3,26 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; +import { useListContext } from '/@/renderer/context/list-context'; import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; -import { SongListFilter, useCurrentServerId } from '/@/renderer/store'; +import { useCurrentServerId } from '/@/renderer/store'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { NumberInput } from '/@/shared/components/number-input/number-input'; import { Stack } from '/@/shared/components/stack/stack'; -import { Text } from '/@/shared/components/text/text'; import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select'; +import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; import { LibraryItem } from '/@/shared/types/domain-types'; -interface JellyfinSongFiltersProps { - customFilters?: Partial; -} - -export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps) => { +export const JellyfinSongFilters = () => { const serverId = useCurrentServerId(); const { t } = useTranslation(); const { query, setCustom, setFavorite, setMaxYear, setMinYear } = useSongListFilters(); + const { customFilters } = useListContext(); + const isGenrePage = customFilters?.genreIds !== undefined; // Despite the fact that getTags returns genres, it only returns genre names. @@ -103,23 +102,27 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps) [setMaxYear], ); + const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300); + const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300); + const handleGenresFilter = useMemo( () => (e: string[] | undefined) => { setCustom((prev) => { + const current = prev ?? {}; + if (!e || e.length === 0) { // Remove GenreIds and IncludeItemTypes if genres are cleared - const rest = { ...prev }; + const rest = { ...current }; delete rest.GenreIds; delete rest.IncludeItemTypes; - // Keep jellyfin-specific properties + // Return null if object is empty, otherwise return the rest return Object.keys(rest).length === 0 ? null : rest; } return { - ...prev, + ...current, GenreIds: e.join(','), IncludeItemTypes: 'Audio', - ...prev?.jellyfin, }; }); }, @@ -128,38 +131,22 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps) const handleTagFilter = useMemo( () => (e: string[] | undefined) => { - setCustom((prev) => { - if (!e || e.length === 0) { - // Remove Tags if cleared - const rest = { ...prev }; - delete rest.Tags; - // Keep IncludeItemTypes and jellyfin-specific properties - if (rest.IncludeItemTypes) { - return rest; - } - return Object.keys(rest).length === 0 ? null : rest; - } - - return { - ...prev, - IncludeItemTypes: 'Audio', - Tags: e.join('|'), - ...prev?.jellyfin, - }; - }); + setCustom({ Tags: e?.join('|') ?? null }); }, [setCustom], ); return ( - + {yesNoFilters.map((filter) => ( - - {filter.label} - - + filter.onChange(e ? e === 'true' : undefined)} + /> ))} - + handleMinYearFilter(e.currentTarget.value)} + onChange={(e) => debouncedHandleMinYearFilter(e)} required={!!query.minYear} /> handleMaxYearFilter(e.currentTarget.value)} + onChange={(e) => debouncedHandleMaxYearFilter(e)} required={!!query.minYear} /> {!isGenrePage && ( - - handleGenresFilter(e)} - searchable - width={250} - /> - + handleGenresFilter(e)} + searchable + /> )} {tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && ( - - handleTagFilter(e)} - searchable - width={250} - /> - + handleTagFilter(e)} + searchable + /> )} ); diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index bb4621783..b4160f1a4 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -1,11 +1,12 @@ -import { useQuery } from '@tanstack/react-query'; -import { memo, useMemo } from 'react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData, SelectWithInvalidData, } from '/@/renderer/components/select-with-invalid-data'; +import { useListContext } from '/@/renderer/context/list-context'; import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; @@ -13,16 +14,19 @@ import { useCurrentServerId } from '/@/renderer/store'; import { titleCase } from '/@/renderer/utils'; import { Divider } from '/@/shared/components/divider/divider'; import { NumberInput } from '/@/shared/components/number-input/number-input'; -import { Spinner } from '/@/shared/components/spinner/spinner'; import { Stack } from '/@/shared/components/stack/stack'; import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select'; +import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback'; import { LibraryItem } from '/@/shared/types/domain-types'; export const NavidromeSongFilters = () => { const { t } = useTranslation(); - const { query, setFavorite, setGenreId, setMaxYear, setMinYear } = useSongListFilters(); + const { customFilters } = useListContext(); + + const isGenrePage = customFilters?.genreIds !== undefined; + const genreListQuery = useGenreList(); const genreList = useMemo(() => { @@ -69,33 +73,39 @@ export const NavidromeSongFilters = () => { [setMinYear, setMaxYear], ); + const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300); + return ( - + {yesNoUndefinedFilters.map((filter) => ( filter.onChange(e ? e === 'true' : undefined)} /> ))} - + handleYearFilter(e.currentTarget.value)} - /> - (e && e.length > 0 ? setGenreId(e) : setGenreId(null))} - searchable + onChange={(e) => debouncedHandleYearFilter(e)} /> + {!isGenrePage && ( + (e && e.length > 0 ? setGenreId(e) : setGenreId(null))} + searchable + /> + )} + ); @@ -104,38 +114,34 @@ export const NavidromeSongFilters = () => { interface TagFilterItemProps { label: string; onChange: (value: null | string) => void; - options: string[]; + options: Array<{ id: string; name: 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 - ); - }, -); +const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterItemProps) => { + const selectData = useMemo( + () => + options.map((option) => ({ + label: option.name, + value: option.id, + })), + [options], + ); + + return ( + + ); +}; TagFilterItem.displayName = 'TagFilterItem'; @@ -144,65 +150,36 @@ const TagFilters = () => { const serverId = useCurrentServerId(); - const tagsQuery = useQuery( + const tagsQuery = useSuspenseQuery( sharedQueries.tags({ - options: { - gcTime: 1000 * 60 * 60, - staleTime: 1000 * 60 * 60, - }, - query: { - type: LibraryItem.SONG, - }, + 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({ [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]); + const results: { label: string; options: { id: string; name: string }[]; value: string }[] = + []; - // 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]); + for (const tag of tagsQuery.data?.enumTags || []) { + if (!tagsQuery.data?.excluded.song.includes(tag.name)) { + results.push({ + label: titleCase(tag.name), + options: tag.options, + value: tag.name, + }); + } + } - if (tagsQuery.isLoading) { - return ; - } + return results; + }, [tagsQuery.data]); return ( <> @@ -210,7 +187,7 @@ const TagFilters = () => { handleTagFilter(tag.value, e)} options={tag.options} tagValue={tag.value} value={query._custom?.[tag.value] as string | undefined} diff --git a/src/renderer/features/songs/components/subsonic-song-filters.tsx b/src/renderer/features/songs/components/subsonic-song-filters.tsx index ccd7a16aa..a58144dbf 100644 --- a/src/renderer/features/songs/components/subsonic-song-filters.tsx +++ b/src/renderer/features/songs/components/subsonic-song-filters.tsx @@ -2,23 +2,21 @@ import { ChangeEvent, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; +import { useListContext } from '/@/renderer/context/list-context'; import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; -import { SongListFilter } from '/@/renderer/store'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; import { Text } from '/@/shared/components/text/text'; -interface SubsonicSongFiltersProps { - customFilters?: Partial; -} - -export const SubsonicSongFilters = ({ customFilters }: SubsonicSongFiltersProps) => { +export const SubsonicSongFilters = () => { const { t } = useTranslation(); const { query, setFavorite, setGenreId } = useSongListFilters(); + const { customFilters } = useListContext(); + const isGenrePage = customFilters?.genreIds !== undefined; const genreListQuery = useGenreList(); @@ -53,27 +51,26 @@ export const SubsonicSongFilters = ({ customFilters }: SubsonicSongFiltersProps) ); return ( - + {toggleFilters.map((filter) => ( {filter.label} - + ))} - - - {!isGenrePage && ( + {!isGenrePage && ( + <> + - )} - + + )} ); }; 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 ed6b59219..d3160395d 100644 --- a/src/renderer/features/songs/hooks/use-song-list-filters.ts +++ b/src/renderer/features/songs/hooks/use-song-list-filters.ts @@ -1,17 +1,18 @@ -import { - parseAsArrayOf, - parseAsBoolean, - parseAsInteger, - parseAsJson, - parseAsString, - useQueryState, -} from 'nuqs'; import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router'; import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; -import { customFiltersSchema, FILTER_KEYS } from '/@/renderer/features/shared/utils'; +import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; +import { + parseArrayParam, + parseBooleanParam, + parseCustomFiltersParam, + parseIntParam, + setJsonSearchParam, + setSearchParam, +} from '/@/renderer/utils/query-params'; import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; @@ -25,30 +26,123 @@ export const useSongListFilters = () => { const { searchTerm, setSearchTerm } = useSearchTermFilter(''); - const [albumIds, setAlbumIds] = useQueryState( - FILTER_KEYS.SONG.ALBUM_IDS, - parseAsArrayOf(parseAsString), + const [searchParams, setSearchParams] = useSearchParams(); + + const albumIds = useMemo( + () => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS), + [searchParams], ); - const [genreId, setGenreId] = useQueryState( - FILTER_KEYS.SONG.GENRE_ID, - parseAsArrayOf(parseAsString), + const genreId = useMemo( + () => parseArrayParam(searchParams, FILTER_KEYS.SONG.GENRE_ID), + [searchParams], ); - const [artistIds, setArtistIds] = useQueryState( - FILTER_KEYS.SONG.ARTIST_IDS, - parseAsArrayOf(parseAsString), + const artistIds = useMemo( + () => parseArrayParam(searchParams, FILTER_KEYS.SONG.ARTIST_IDS), + [searchParams], ); - const [minYear, setMinYear] = useQueryState(FILTER_KEYS.SONG.MIN_YEAR, parseAsInteger); + const minYear = useMemo( + () => parseIntParam(searchParams, FILTER_KEYS.SONG.MIN_YEAR), + [searchParams], + ); - const [maxYear, setMaxYear] = useQueryState(FILTER_KEYS.SONG.MAX_YEAR, parseAsInteger); + const maxYear = useMemo( + () => parseIntParam(searchParams, FILTER_KEYS.SONG.MAX_YEAR), + [searchParams], + ); - const [favorite, setFavorite] = useQueryState(FILTER_KEYS.SONG.FAVORITE, parseAsBoolean); + const favorite = useMemo( + () => parseBooleanParam(searchParams, FILTER_KEYS.SONG.FAVORITE), + [searchParams], + ); - const [custom, setCustom] = useQueryState( - FILTER_KEYS.SONG._CUSTOM, - parseAsJson(customFiltersSchema), + const custom = useMemo( + () => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM), + [searchParams], + ); + + const setAlbumIds = useCallback( + (value: null | string[]) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setGenreId = useCallback( + (value: null | string[]) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setArtistIds = useCallback( + (value: null | string[]) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ARTIST_IDS, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setMinYear = useCallback( + (value: null | number) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MIN_YEAR, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setMaxYear = useCallback( + (value: null | number) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MAX_YEAR, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setFavorite = useCallback( + (value: boolean | null) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.FAVORITE, value), { + replace: true, + }); + }, + [setSearchParams], + ); + + const setCustom = useCallback( + ( + value: + | ((prev: null | Record) => null | Record) + | null + | Record, + ) => { + setSearchParams( + (prev) => { + const currentCustom = parseCustomFiltersParam(prev, FILTER_KEYS.SONG._CUSTOM); + let newValue = + typeof value === 'function' ? value(currentCustom ?? null) : value; + // Convert empty objects to null to clear them from URL + if ( + newValue && + typeof newValue === 'object' && + Object.keys(newValue).length === 0 + ) { + newValue = null; + } + return setJsonSearchParam(prev, FILTER_KEYS.SONG._CUSTOM, newValue); + }, + { replace: true }, + ); + }, + [setSearchParams], ); const clear = useCallback(() => { diff --git a/src/renderer/router/app-outlet.tsx b/src/renderer/router/app-outlet.tsx index b97309677..f4ed622b2 100644 --- a/src/renderer/router/app-outlet.tsx +++ b/src/renderer/router/app-outlet.tsx @@ -1,4 +1,3 @@ -import { NuqsAdapter } from '@offlegacy/nuqs-hash-router'; import isElectron from 'is-electron'; import { useMemo } from 'react'; import { Navigate, Outlet } from 'react-router'; @@ -43,9 +42,5 @@ export const AppOutlet = () => { return ; } - return ( - - - - ); + return ; }; diff --git a/src/renderer/utils/query-params.ts b/src/renderer/utils/query-params.ts new file mode 100644 index 000000000..7762d9284 --- /dev/null +++ b/src/renderer/utils/query-params.ts @@ -0,0 +1,141 @@ +import { customFiltersSchema } from '/@/renderer/features/shared/utils'; + +/** + * Parse a string array from URLSearchParams + * Returns undefined if the key doesn't exist or array is empty + */ +export const parseArrayParam = ( + searchParams: URLSearchParams, + key: string, +): string[] | undefined => { + const values = searchParams.getAll(key); + return values.length > 0 ? values : undefined; +}; + +/** + * Parse a boolean from URLSearchParams + * Returns undefined if the key doesn't exist + */ +export const parseBooleanParam = ( + searchParams: URLSearchParams, + key: string, +): boolean | undefined => { + const value = searchParams.get(key); + if (value === null) return undefined; + return value === 'true'; +}; + +/** + * Parse an integer from URLSearchParams + * Returns undefined if the key doesn't exist or value is invalid + */ +export const parseIntParam = (searchParams: URLSearchParams, key: string): number | undefined => { + const value = searchParams.get(key); + if (value === null) return undefined; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? undefined : parsed; +}; + +/** + * Parse a string from URLSearchParams + * Returns undefined if the key doesn't exist + */ +export const parseStringParam = ( + searchParams: URLSearchParams, + key: string, +): string | undefined => { + const value = searchParams.get(key); + return value === null ? undefined : value; +}; + +/** + * Parse JSON from URLSearchParams + * Returns undefined if the key doesn't exist or parsing fails + */ +export const parseJsonParam = ( + searchParams: URLSearchParams, + key: string, +): T | undefined => { + const value = searchParams.get(key); + if (value === null) return undefined; + try { + const parsed = JSON.parse(value); + // Validate against schema if provided + return parsed; + } catch { + return undefined; + } +}; + +/** + * Set or remove a value in URLSearchParams + * If value is null or undefined, removes the key + */ +export const setSearchParam = ( + searchParams: URLSearchParams, + key: string, + value: boolean | null | number | Record | string | string[] | undefined, +): URLSearchParams => { + const newParams = new URLSearchParams(searchParams); + + if (value === null || value === undefined) { + newParams.delete(key); + return newParams; + } + + if (Array.isArray(value)) { + newParams.delete(key); + value.forEach((v) => newParams.append(key, String(v))); + return newParams; + } + + if (typeof value === 'boolean') { + newParams.set(key, String(value)); + return newParams; + } + + if (typeof value === 'number') { + newParams.set(key, String(value)); + return newParams; + } + + newParams.set(key, value as string); + return newParams; +}; + +/** + * Set or remove a JSON value in URLSearchParams + * If value is null or undefined, removes the key + */ +export const setJsonSearchParam = ( + searchParams: URLSearchParams, + key: string, + value: null | Record | undefined, +): URLSearchParams => { + const newParams = new URLSearchParams(searchParams); + + if (value === null || value === undefined) { + newParams.delete(key); + return newParams; + } + + newParams.set(key, JSON.stringify(value)); + return newParams; +}; + +/** + * Parse custom filters from URLSearchParams with validation + */ +export const parseCustomFiltersParam = ( + searchParams: URLSearchParams, + key: string, +): Record | undefined => { + const value = parseJsonParam(searchParams, key); + if (value === undefined) return undefined; + + try { + return customFiltersSchema.parse(value); + } catch { + return undefined; + } +}; diff --git a/src/shared/components/yes-no-select/yes-no-select.tsx b/src/shared/components/yes-no-select/yes-no-select.tsx index 07c693ecb..c3031387b 100644 --- a/src/shared/components/yes-no-select/yes-no-select.tsx +++ b/src/shared/components/yes-no-select/yes-no-select.tsx @@ -2,12 +2,9 @@ import { useTranslation } from 'react-i18next'; import { Select, SelectProps } from '/@/shared/components/select/select'; -export interface YesNoSelectProps extends Omit { - onChange: (e?: boolean) => void; - value?: boolean; -} +export interface YesNoSelectProps extends SelectProps {} -export const YesNoSelect = ({ onChange, value, ...props }: YesNoSelectProps) => { +export const YesNoSelect = ({ ...props }: YesNoSelectProps) => { const { t } = useTranslation(); return ( @@ -23,10 +20,6 @@ export const YesNoSelect = ({ onChange, value, ...props }: YesNoSelectProps) => value: 'true', }, ]} - onChange={(e) => { - onChange(e ? e === 'true' : undefined); - }} - value={value !== undefined ? value.toString() : null} {...props} /> ); diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index d921e3939..080be8348 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1224,7 +1224,7 @@ export type ControllerEndpoint = { getSongListCount: (args: SongListCountArgs) => Promise; getStreamUrl: (args: StreamArgs) => string; getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise; - getTags?: (args: TagArgs) => Promise; + getTags?: (args: TagArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; getUserList?: (args: UserListArgs) => Promise; movePlaylistItem?: (args: MoveItemArgs) => Promise; @@ -1316,7 +1316,7 @@ export type InternalControllerEndpoint = { getStructuredLyrics?: ( args: ReplaceApiClientProps, ) => Promise; - getTags?: (args: ReplaceApiClientProps) => Promise; + getTags?: (args: ReplaceApiClientProps) => Promise; getTopSongs: (args: ReplaceApiClientProps) => Promise; getUserList?: (args: ReplaceApiClientProps) => Promise; movePlaylistItem?: (args: ReplaceApiClientProps) => Promise; @@ -1408,8 +1408,9 @@ export type StructuredUnsyncedLyric = Omit & { }; export type Tag = { + id: string; name: string; - options: string[]; + options: { id: string; name: string }[]; }; export type TagArgs = BaseEndpointArgs & { @@ -1421,9 +1422,13 @@ export type TagQuery = { type: LibraryItem.ALBUM | LibraryItem.SONG; }; -export type TagResponses = { +export type TagsResponse = { boolTags?: string[]; - enumTags?: Tag[]; + enumTags?: { name: string; options: { id: string; name: string }[] }[]; + excluded: { + album: string[]; + song: string[]; + }; }; type BaseEndpointArgsWithServer = {