diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx index 5fd125c96..c46fa00b3 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx @@ -23,6 +23,7 @@ import { TouchableItemRouter, } from "@/components/common/TouchableItemRouter"; import { FilterButton } from "@/components/filters/FilterButton"; +import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; @@ -134,6 +135,12 @@ const page: React.FC = () => { useEffect(() => { navigation.setOptions({ title: collection?.Name || "" }); setSortOrder([SortOrderOption.Ascending]); + // Collections open with a clean filter slate: the genre/year/tag atoms are + // global, so without this the previously viewed library's selection bleeds + // in (libraries now keep their own per-library memory). + setSelectedGenres([]); + setSelectedYears([]); + setSelectedTags([]); if (!collection) return; @@ -204,40 +211,39 @@ const page: React.FC = () => { ], ); - const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = - useInfiniteQuery({ - queryKey: [ - "collection-items", - collectionId, - selectedGenres, - selectedYears, - selectedTags, - sortBy, - sortOrder, - ], - queryFn: fetchItems, - getNextPageParam: (lastPage, pages) => { - if ( - !lastPage?.Items || - !lastPage?.TotalRecordCount || - lastPage?.TotalRecordCount === 0 - ) - return undefined; - - const totalItems = lastPage.TotalRecordCount; - const accumulatedItems = pages.reduce( - (acc, curr) => acc + (curr?.Items?.length || 0), - 0, - ); - - if (accumulatedItems < totalItems) { - return lastPage?.Items?.length * pages.length; - } + const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({ + queryKey: [ + "collection-items", + collectionId, + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ], + queryFn: fetchItems, + getNextPageParam: (lastPage, pages) => { + if ( + !lastPage?.Items || + !lastPage?.TotalRecordCount || + lastPage?.TotalRecordCount === 0 + ) return undefined; - }, - initialPageParam: 0, - enabled: !!api && !!user?.Id && !!collection, - }); + + const totalItems = lastPage.TotalRecordCount; + const accumulatedItems = pages.reduce( + (acc, curr) => acc + (curr?.Items?.length || 0), + 0, + ); + + if (accumulatedItems < totalItems) { + return lastPage?.Items?.length * pages.length; + } + return undefined; + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id && !!collection, + }); const flatData = useMemo(() => { return ( @@ -326,7 +332,7 @@ const page: React.FC = () => { data={[ { key: "reset", - component: , + component: , }, { key: "genre", @@ -466,7 +472,6 @@ const page: React.FC = () => { setSortBy, sortOrder, setSortOrder, - isFetching, ], ); @@ -633,43 +638,45 @@ const page: React.FC = () => { // Mobile return if (!Platform.isTV) { return ( - - - {t("search.no_results")} - - - } - extraData={[ - selectedGenres, - selectedYears, - selectedTags, - sortBy, - sortOrder, - ]} - contentInsetAdjustmentBehavior='automatic' - data={flatData} - renderItem={renderItem} - keyExtractor={keyExtractor} - numColumns={nrOfCols} - onEndReached={() => { - if (hasNextPage) { - fetchNextPage(); + + + + {t("search.no_results")} + + } - }} - onEndReachedThreshold={0.5} - ListHeaderComponent={ListHeaderComponent} - contentContainerStyle={{ paddingBottom: 24 }} - ItemSeparatorComponent={() => ( - - )} - /> + extraData={[ + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ]} + contentInsetAdjustmentBehavior='automatic' + data={flatData} + renderItem={renderItem} + keyExtractor={keyExtractor} + numColumns={nrOfCols} + onEndReached={() => { + if (hasNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={0.5} + ListHeaderComponent={ListHeaderComponent} + contentContainerStyle={{ paddingBottom: 24 }} + ItemSeparatorComponent={() => ( + + )} + /> + ); } diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index cdb1a181f..89e1261c8 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -9,7 +9,7 @@ import { getItemsApi, getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; -import { FlashList } from "@shopify/flash-list"; +import { FlashList, type FlashListRef } from "@shopify/flash-list"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { @@ -35,6 +35,7 @@ import { TouchableItemRouter, } from "@/components/common/TouchableItemRouter"; import { FilterButton } from "@/components/filters/FilterButton"; +import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; @@ -44,6 +45,7 @@ import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; +import { useFilterReset } from "@/hooks/useFilterReset"; import { useOrientation } from "@/hooks/useOrientation"; import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; @@ -55,7 +57,9 @@ import { FilterByPreferenceAtom, filterByAtom, genreFilterAtom, + genrePreferenceAtom, getFilterByPreference, + getMultiFilterPreference, getSortByPreference, getSortOrderPreference, SortByOption, @@ -66,11 +70,12 @@ import { sortOrderAtom, sortOrderOptions, sortOrderPreferenceAtom, + tagPreferenceAtom, tagsFilterAtom, useFilterOptions, yearFilterAtom, + yearPreferenceAtom, } from "@/utils/atoms/filters"; -import { useSettings } from "@/utils/atoms/settings"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -108,6 +113,9 @@ const Page = () => { const [sortOrderPreference, setOrderByPreference] = useAtom( sortOrderPreferenceAtom, ); + const [genrePreference, setGenrePreference] = useAtom(genrePreferenceAtom); + const [yearPreference, setYearPreference] = useAtom(yearPreferenceAtom); + const [tagPreference, setTagPreference] = useAtom(tagPreferenceAtom); const { orientation } = useOrientation(); @@ -205,6 +213,13 @@ const Page = () => { const fp = getFilterByPreference(libraryId, filterByPreference); _setFilterBy(fp ? [fp] : []); } + + // Genres / years / tags: per-library saved preference (no URL params), so + // switching libraries restores each library's own selection instead of + // bleeding the previous one. + setSelectedGenres(getMultiFilterPreference(libraryId, genrePreference)); + setSelectedYears(getMultiFilterPreference(libraryId, yearPreference)); + setSelectedTags(getMultiFilterPreference(libraryId, tagPreference)); }, [ libraryId, sortOrderPreference, @@ -213,6 +228,12 @@ const Page = () => { _setSortBy, filterByPreference, _setFilterBy, + genrePreference, + yearPreference, + tagPreference, + setSelectedGenres, + setSelectedYears, + setSelectedTags, searchParams.sortBy, searchParams.sortOrder, searchParams.filterBy, @@ -257,6 +278,32 @@ const Page = () => { [libraryId, filterByPreference, setFilterByPreference, _setFilterBy], ); + // Genres / years / tags: save the per-library memory then update the active + // atom (mirrors setSortBy; avoids a save-effect that would corrupt on switch). + const setGenres = useCallback( + (genres: string[]) => { + setGenrePreference({ ...genrePreference, [libraryId]: genres }); + setSelectedGenres(genres); + }, + [libraryId, genrePreference, setGenrePreference, setSelectedGenres], + ); + + const setYears = useCallback( + (years: string[]) => { + setYearPreference({ ...yearPreference, [libraryId]: years }); + setSelectedYears(years); + }, + [libraryId, yearPreference, setYearPreference, setSelectedYears], + ); + + const setTags = useCallback( + (tags: string[]) => { + setTagPreference({ ...tagPreference, [libraryId]: tags }); + setSelectedTags(tags); + }, + [libraryId, tagPreference, setTagPreference, setSelectedTags], + ); + const nrOfCols = useMemo(() => { if (Platform.isTV) { // TV uses flexWrap, so nrOfCols is just for mobile @@ -415,6 +462,29 @@ const Page = () => { ); }, [data]); + const flashListRef = useRef>(null); + + // Jump the grid to the top when the filters/sort change (incl. reset). + const filterSignature = `${selectedGenres}|${selectedYears}|${selectedTags}|${sortBy[0]}|${sortOrder[0]}|${filterBy}`; + const pendingScrollTopRef = useRef(false); + + // Instant feedback: pin to the top the moment the filters change, without + // waiting for the new fetch — and flag a re-pin for once it settles. + useEffect(() => { + flashListRef.current?.scrollToOffset({ offset: 0, animated: false }); + pendingScrollTopRef.current = true; + }, [filterSignature]); + + // Safety net: FlashList can restore the previous offset as the filtered list + // grows, so re-pin once the fetch settles. Pagination keeps the same + // signature, so it never re-pins. + useEffect(() => { + if (pendingScrollTopRef.current && !isFetching) { + pendingScrollTopRef.current = false; + flashListRef.current?.scrollToOffset({ offset: 0, animated: false }); + } + }, [isFetching, flatData]); + const renderItem = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => ( { const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const generalFilters = useFilterOptions(); - const settings = useSettings(); const ListHeaderComponent = useCallback( () => ( { data={[ { key: "reset", - component: , + component: , }, { key: "genre", @@ -564,7 +633,7 @@ const Page = () => { }); return response.data.Genres || []; }} - set={setSelectedGenres} + set={setGenres} values={selectedGenres} title={t("library.filters.genres")} renderItemLabel={(item) => item.toString()} @@ -591,7 +660,7 @@ const Page = () => { }); return response.data.Years || []; }} - set={setSelectedYears} + set={setYears} values={selectedYears} title={t("library.filters.years")} renderItemLabel={(item) => item.toString()} @@ -616,7 +685,7 @@ const Page = () => { }); return response.data.Tags || []; }} - set={setSelectedTags} + set={setTags} values={selectedTags} title={t("library.filters.tags")} renderItemLabel={(item) => item.toString()} @@ -696,35 +765,23 @@ const Page = () => { api, user?.Id, selectedGenres, - setSelectedGenres, + setGenres, selectedYears, - setSelectedYears, + setYears, selectedTags, - setSelectedTags, + setTags, sortBy, setSortBy, sortOrder, setSortOrder, - isFetching, filterBy, setFilter, - settings, ], ); - // TV Filter bar header - const hasActiveFilters = - selectedGenres.length > 0 || - selectedYears.length > 0 || - selectedTags.length > 0 || - filterBy.length > 0; - - const resetAllFilters = useCallback(() => { - setSelectedGenres([]); - setSelectedYears([]); - setSelectedTags([]); - _setFilterBy([]); - }, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]); + // Filter bar reset + visibility, shared with the mobile ResetFiltersButton so + // sort/order can't be forgotten on one path (it used to be reset on neither). + const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId); // TV Filter options - with "All" option for clearable filters const tvGenreFilterOptions = useMemo( @@ -818,15 +875,15 @@ const Page = () => { options: tvGenreFilterOptions, onSelect: (value: string) => { if (value === "__all__") { - setSelectedGenres([]); + setGenres([]); } else if (selectedGenres.includes(value)) { - setSelectedGenres(selectedGenres.filter((g) => g !== value)); + setGenres(selectedGenres.filter((g) => g !== value)); } else { - setSelectedGenres([...selectedGenres, value]); + setGenres([...selectedGenres, value]); } }, }); - }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]); + }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setGenres]); const handleShowYearFilter = useCallback(() => { showOptions({ @@ -834,15 +891,15 @@ const Page = () => { options: tvYearFilterOptions, onSelect: (value: string) => { if (value === "__all__") { - setSelectedYears([]); + setYears([]); } else if (selectedYears.includes(value)) { - setSelectedYears(selectedYears.filter((y) => y !== value)); + setYears(selectedYears.filter((y) => y !== value)); } else { - setSelectedYears([...selectedYears, value]); + setYears([...selectedYears, value]); } }, }); - }, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]); + }, [showOptions, t, tvYearFilterOptions, selectedYears, setYears]); const handleShowTagFilter = useCallback(() => { showOptions({ @@ -850,15 +907,15 @@ const Page = () => { options: tvTagFilterOptions, onSelect: (value: string) => { if (value === "__all__") { - setSelectedTags([]); + setTags([]); } else if (selectedTags.includes(value)) { - setSelectedTags(selectedTags.filter((tag) => tag !== value)); + setTags(selectedTags.filter((tag) => tag !== value)); } else { - setSelectedTags([...selectedTags, value]); + setTags([...selectedTags, value]); } }, }); - }, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]); + }, [showOptions, t, tvTagFilterOptions, selectedTags, setTags]); const handleShowSortByFilter = useCallback(() => { showOptions({ @@ -906,42 +963,45 @@ const Page = () => { // Mobile return if (!Platform.isTV) { return ( - - - {t("library.no_results")} - - - } - contentInsetAdjustmentBehavior='automatic' - data={flatData} - renderItem={renderItem} - extraData={[orientation, nrOfCols]} - keyExtractor={keyExtractor} - numColumns={nrOfCols} - onEndReached={() => { - if (hasNextPage) { - fetchNextPage(); + + + + {t("library.no_results")} + + } - }} - onEndReachedThreshold={1} - ListHeaderComponent={ListHeaderComponent} - contentContainerStyle={{ - paddingBottom: 24, - paddingLeft: insets.left, - paddingRight: insets.right, - }} - ItemSeparatorComponent={() => ( - - )} - /> + contentInsetAdjustmentBehavior='automatic' + data={flatData} + renderItem={renderItem} + extraData={[orientation, nrOfCols]} + keyExtractor={keyExtractor} + numColumns={nrOfCols} + onEndReached={() => { + if (hasNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={1} + ListHeaderComponent={ListHeaderComponent} + contentContainerStyle={{ + paddingBottom: 24, + paddingLeft: insets.left, + paddingRight: insets.right, + }} + ItemSeparatorComponent={() => ( + + )} + /> + ); } diff --git a/components/BitRateSheet.tsx b/components/BitRateSheet.tsx index 0589cd059..6a9a89c5c 100644 --- a/components/BitRateSheet.tsx +++ b/components/BitRateSheet.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from "react"; +import type { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Platform, TouchableOpacity, View } from "react-native"; import { Text } from "./common/Text"; @@ -61,6 +62,7 @@ export const BitrateSheet: React.FC = ({ const isTv = Platform.isTV; const { t } = useTranslation(); const [open, setOpen] = useState(false); + const sheetModalRef = useRef(null); const sorted = useMemo(() => { if (inverted) @@ -92,7 +94,10 @@ export const BitrateSheet: React.FC = ({ setOpen(true)} + onPress={() => { + setOpen(true); + sheetModalRef.current?.present(); + }} > {BITRATES.find((b) => b.value === selected?.value)?.key} @@ -103,6 +108,7 @@ export const BitrateSheet: React.FC = ({ = ({ const isTv = Platform.isTV; const { t } = useTranslation(); const [open, setOpen] = useState(false); + const sheetModalRef = useRef(null); const getDisplayName = useCallback((source: MediaSourceInfo) => { const videoStream = source.MediaStreams?.find((x) => x.Type === "Video"); @@ -44,7 +46,10 @@ export const MediaSourceSheet: React.FC = ({ {t("item_card.video")} setOpen(true)} + onPress={() => { + setOpen(true); + sheetModalRef.current?.present(); + }} > {selectedName} @@ -53,6 +58,7 @@ export const MediaSourceSheet: React.FC = ({ extends ViewProps { id: string; @@ -33,22 +35,63 @@ export const FilterButton = ({ icon = "filter", ...props }: FilterButtonProps) => { - const [open, setOpen] = useState(false); + // When a FilterSheetProvider is present (library / collections), all buttons + // share one sheet so two can never stack. Outside a provider (e.g. logs, + // discover), fall back to this button's own standalone sheet. + const shared = useFilterSheet(); - const { data: filters } = useQuery({ + const { data: filters, isLoading } = useQuery({ queryKey: ["filters", title, queryKey, id], queryFn, staleTime: 0, enabled: !!id && !!queryFn && !!queryKey, }); + // Standalone-mode state (unused in shared mode). + const [open, setOpen] = useState(false); + const sheetModalRef = useRef(null); + + const onButtonPress = useCallback(() => { + if (shared) { + shared.openFilter({ + key: `${id}:${queryKey}`, + id, + queryKey, + queryFn, + title, + values: values as unknown[], + set: set as (value: unknown[]) => void, + renderItemLabel: renderItemLabel as (item: unknown) => React.ReactNode, + searchFilter: searchFilter as + | ((item: unknown, query: string) => boolean) + | undefined, + disableSearch, + multiple, + }); + return; + } + // present() must run from the press handler: from an effect after a state + // update it silently no-ops on the new architecture and the sheet never + // appears. + setOpen(true); + sheetModalRef.current?.present(); + }, [ + shared, + id, + queryKey, + queryFn, + title, + values, + set, + renderItemLabel, + searchFilter, + disableSearch, + multiple, + ]); + return ( <> - { - filters?.length && setOpen(true); - }} - > + ({ )} - - title={title} - open={open} - setOpen={setOpen} - data={filters} - values={values} - set={set} - renderItemLabel={renderItemLabel} - searchFilter={searchFilter} - disableSearch={disableSearch} - multiple={multiple} - /> + {!shared && ( + + title={title} + open={open} + setOpen={setOpen} + modalRef={sheetModalRef} + loading={isLoading} + data={filters} + values={values} + set={set} + renderItemLabel={renderItemLabel} + searchFilter={searchFilter} + disableSearch={disableSearch} + multiple={multiple} + /> + )} ); }; diff --git a/components/filters/FilterSheet.tsx b/components/filters/FilterSheet.tsx index 27075f8b5..8f87ac221 100644 --- a/components/filters/FilterSheet.tsx +++ b/components/filters/FilterSheet.tsx @@ -7,7 +7,14 @@ import { } from "@gorhom/bottom-sheet"; import { isEqual } from "lodash"; import type React from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { StyleSheet, @@ -19,11 +26,21 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { Button } from "../Button"; import { Input } from "../common/Input"; +import { Loader } from "../Loader"; interface Props extends ViewProps { open: boolean; setOpen: (open: boolean) => void; + /** + * Modal ref the opener must use to present() the sheet from inside its + * press handler. On the new architecture with Reanimated 4, present() + * called from an effect after a state update silently no-ops — the sheet + * mounts nothing. Presenting straight from the gesture handler works. + */ + modalRef: React.RefObject; data?: T[] | null; + /** True while the options are loading — shows a loader inside the sheet. */ + loading?: boolean; values: T[]; set: (value: T[]) => void; title: string; @@ -66,16 +83,18 @@ const LIMIT = 100; export const FilterSheet = ({ values, data: _data, + loading = false, open, set, setOpen, + modalRef, title, searchFilter, renderItemLabel, disableSearch = false, multiple = false, }: Props) => { - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = modalRef; const snapPoints = useMemo(() => ["85%"], []); const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -84,19 +103,24 @@ export const FilterSheet = ({ const [offset, setOffset] = useState(0); const [search, setSearch] = useState(""); + // Filtering and re-rendering the option list on every keystroke blocks the + // JS thread on large lists (2000+ tags); the controlled input then snaps the + // native text back to a stale value (lost/reappearing letters). Deferring the + // value keeps the keystroke render cheap and runs the list update after. + const deferredSearch = useDeferredValue(search); const [showSearch, setShowSearch] = useState(false); const filteredData = useMemo(() => { - if (!search) return _data; + if (!deferredSearch) return _data; const results = []; for (let i = 0; i < (_data?.length || 0); i++) { - if (_data && searchFilter?.(_data[i], search)) { + if (_data && searchFilter?.(_data[i], deferredSearch)) { results.push(_data[i]); } } return results.slice(0, 100); - }, [search, _data, searchFilter]); + }, [deferredSearch, _data, searchFilter]); useEffect(() => { if (!data || data.length === 0 || disableSearch) return; @@ -127,21 +151,28 @@ export const FilterSheet = ({ setData(newData); }, [offset, _data]); + // Opening is imperative (see the modalRef prop); this effect only closes. + // It also never calls dismiss() on a modal that was never presented. + const wasPresentedRef = useRef(false); useEffect(() => { - if (open) bottomSheetModalRef.current?.present(); - else bottomSheetModalRef.current?.dismiss(); + if (!open && wasPresentedRef.current) { + bottomSheetModalRef.current?.dismiss(); + } }, [open]); const handleSheetChanges = useCallback((index: number) => { - if (index === -1) { + if (index >= 0) { + wasPresentedRef.current = true; + } else if (index === -1) { + wasPresentedRef.current = false; setOpen(false); } }, []); const renderData = useMemo(() => { - if (search.length > 0 && showSearch) return filteredData; + if (deferredSearch.length > 0 && showSearch) return filteredData; return data; - }, [search, filteredData, data]); + }, [deferredSearch, showSearch, filteredData, data]); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( @@ -154,6 +185,54 @@ export const FilterSheet = ({ [], ); + // Memoized so typing in the search input (urgent render with an unchanged + // deferred value) doesn't rebuild up to 100 row elements per keystroke. + const renderedRows = useMemo( + () => + renderData?.map((item, index) => ( + + { + // Match the deep-equality rule used to render the selected + // state below — option objects are recreated across renders, + // so reference checks would re-add an already selected item. + const isSelected = values.some((value) => isEqual(value, item)); + if (multiple) { + if (!isSelected) set(values.concat(item)); + else set(values.filter((value) => !isEqual(value, item))); + + setTimeout(() => { + setOpen(false); + }, 250); + } else { + if (!isSelected) { + set([item]); + setTimeout(() => { + setOpen(false); + }, 250); + } + } + }} + className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' + > + {renderItemLabel(item)} + {values.some((i) => isEqual(i, item)) ? ( + + ) : ( + + )} + + + + )), + [renderData, values, multiple, set, setOpen, renderItemLabel], + ); + return ( ({ }} > {title} - - {t("search.x_items", { count: _data?.length })} - + {loading ? ( + + + + ) : ( + + {t("search.x_items", { count: _data?.length })} + + )} {showSearch && ( ({ }} className='mb-4 flex flex-col rounded-xl overflow-hidden' > - {renderData?.map((item, index) => ( - - { - if (multiple) { - if (!values.includes(item)) set(values.concat(item)); - else set(values.filter((v) => v !== item)); - - setTimeout(() => { - setOpen(false); - }, 250); - } else { - if (!values.includes(item)) { - set([item]); - setTimeout(() => { - setOpen(false); - }, 250); - } - } - }} - className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' - > - {renderItemLabel(item)} - {values.some((i) => isEqual(i, item)) ? ( - - ) : ( - - )} - - - - ))} + {renderedRows} {data.length < (_data?.length || 0) && ( + )} + + ); +}; diff --git a/hooks/useFilterReset.ts b/hooks/useFilterReset.ts new file mode 100644 index 000000000..96ac66bf8 --- /dev/null +++ b/hooks/useFilterReset.ts @@ -0,0 +1,108 @@ +import { useAtom } from "jotai"; +import { useCallback } from "react"; +import { + FilterByPreferenceAtom, + filterByAtom, + genreFilterAtom, + genrePreferenceAtom, + SortByOption, + SortOrderOption, + sortByAtom, + sortByPreferenceAtom, + sortOrderAtom, + sortOrderPreferenceAtom, + tagPreferenceAtom, + tagsFilterAtom, + yearFilterAtom, + yearPreferenceAtom, +} from "@/utils/atoms/filters"; + +/** + * Single source of truth for the library filter bar's "reset" action and its + * visibility. The mobile ResetFiltersButton and the TV filter header both use + * this so they can't drift — sort/order used to be reset on neither path, so + * the reset (X) never reflected a changed sort. + * + * A reset clears the session filters AND the per-library in-memory preferences + * (sort, order, filterBy, genres, years, tags); otherwise the saved preference + * resurfaces when the library's mount effect re-applies it on the next entry. + */ +export const useFilterReset = (libraryId: string) => { + const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); + const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); + const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); + const [filterBy, setFilterBy] = useAtom(filterByAtom); + const [sortBy, setSortBy] = useAtom(sortByAtom); + const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); + const [, setSortByPreference] = useAtom(sortByPreferenceAtom); + const [, setSortOrderPreference] = useAtom(sortOrderPreferenceAtom); + const [, setFilterByPreference] = useAtom(FilterByPreferenceAtom); + const [, setGenrePreference] = useAtom(genrePreferenceAtom); + const [, setYearPreference] = useAtom(yearPreferenceAtom); + const [, setTagPreference] = useAtom(tagPreferenceAtom); + + // SortName / Ascending is the baseline a library opens with (mount-effect + // fallback), so any other value counts as an active, resettable sort. + const hasActiveFilters = + selectedGenres.length > 0 || + selectedYears.length > 0 || + selectedTags.length > 0 || + filterBy.length > 0 || + sortBy[0] !== SortByOption.SortName || + sortOrder[0] !== SortOrderOption.Ascending; + + const resetAllFilters = useCallback(() => { + setSelectedGenres([]); + setSelectedYears([]); + setSelectedTags([]); + setFilterBy([]); + setSortBy([SortByOption.SortName]); + setSortOrder([SortOrderOption.Ascending]); + setSortByPreference((prev) => { + const next = { ...prev }; + delete next[libraryId]; + return next; + }); + setSortOrderPreference((prev) => { + const next = { ...prev }; + delete next[libraryId]; + return next; + }); + setFilterByPreference((prev) => { + const next = { ...prev }; + delete next[libraryId]; + return next; + }); + setGenrePreference((prev) => { + const next = { ...prev }; + delete next[libraryId]; + return next; + }); + setYearPreference((prev) => { + const next = { ...prev }; + delete next[libraryId]; + return next; + }); + setTagPreference((prev) => { + const next = { ...prev }; + delete next[libraryId]; + return next; + }); + }, [ + libraryId, + setSelectedGenres, + setSelectedYears, + setSelectedTags, + setFilterBy, + setSortBy, + setSortOrder, + setSortByPreference, + setSortOrderPreference, + setFilterByPreference, + setGenrePreference, + setYearPreference, + setTagPreference, + ]); + + return { hasActiveFilters, resetAllFilters }; +}; diff --git a/utils/atoms/filters.ts b/utils/atoms/filters.ts index 2c04e917f..7c4ffef8f 100644 --- a/utils/atoms/filters.ts +++ b/utils/atoms/filters.ts @@ -1,6 +1,5 @@ import { atom } from "jotai"; -import { atomWithStorage } from "jotai/utils"; -import { storage } from "../mmkv"; +import { useMemo } from "react"; import { useSettings } from "./settings"; export enum SortByOption { @@ -59,32 +58,36 @@ export const sortOptions: { export const useFilterOptions = () => { const { settings } = useSettings(); - // We want to only show the watchlist option if someone has ticked that setting. - const filterOptions = settings?.useKefinTweaks - ? [ - { - key: FilterByOption.IsFavoriteOrLiked, - value: "Is Favorite Or Liked", - }, - { key: FilterByOption.IsUnplayed, value: "Is Unplayed" }, - { key: FilterByOption.IsPlayed, value: "Is Played" }, - { key: FilterByOption.IsFavorite, value: "Is Favorite" }, - { key: FilterByOption.IsResumable, value: "Is Resumable" }, - { key: FilterByOption.Likes, value: "Watchlist" }, - ] - : [ - { - key: FilterByOption.IsFavoriteOrLiked, - value: "Is Favorite Or Liked", - }, - { key: FilterByOption.IsUnplayed, value: "Is Unplayed" }, - { key: FilterByOption.IsPlayed, value: "Is Played" }, - { key: FilterByOption.IsFavorite, value: "Is Favorite" }, - { key: FilterByOption.IsResumable, value: "Is Resumable" }, - ]; - console.log("filterOptions"); - console.log(filterOptions); - return filterOptions; + // Memoized so the array identity stays stable across renders. A fresh array + // each render cascades into ListHeaderComponent re-creation and, under heavy + // re-rendering (active downloads), trips React's max-update-depth guard. + // We only show the watchlist option if someone has ticked that setting. + return useMemo( + () => + settings?.useKefinTweaks + ? [ + { + key: FilterByOption.IsFavoriteOrLiked, + value: "Is Favorite Or Liked", + }, + { key: FilterByOption.IsUnplayed, value: "Is Unplayed" }, + { key: FilterByOption.IsPlayed, value: "Is Played" }, + { key: FilterByOption.IsFavorite, value: "Is Favorite" }, + { key: FilterByOption.IsResumable, value: "Is Resumable" }, + { key: FilterByOption.Likes, value: "Watchlist" }, + ] + : [ + { + key: FilterByOption.IsFavoriteOrLiked, + value: "Is Favorite Or Liked", + }, + { key: FilterByOption.IsUnplayed, value: "Is Unplayed" }, + { key: FilterByOption.IsPlayed, value: "Is Played" }, + { key: FilterByOption.IsFavorite, value: "Is Favorite" }, + { key: FilterByOption.IsResumable, value: "Is Resumable" }, + ], + [settings?.useKefinTweaks], + ); }; export const sortOrderOptions: { @@ -120,57 +123,28 @@ const defaultSortPreference: SortPreference = {}; const defaultSortOrderPreference: SortOrderPreference = {}; const defaultFilterPreference: FilterPreference = {}; -export const sortByPreferenceAtom = atomWithStorage( - "sortByPreference", - defaultSortPreference, - { - getItem: (key) => { - const value = storage.getString(key); - return value ? JSON.parse(value) : null; - }, - setItem: (key, value) => { - storage.set(key, JSON.stringify(value)); - }, - removeItem: (key) => { - storage.remove(key); - }, - }, -); +// Per-library filter memory is intentionally in-memory (NOT atomWithStorage): +// each library keeps its own filters for the session, and everything resets +// when the app is fully closed. +export const sortByPreferenceAtom = atom(defaultSortPreference); -export const FilterByPreferenceAtom = atomWithStorage( - "filterByPreference", +export const FilterByPreferenceAtom = atom( defaultFilterPreference, - { - getItem: (key) => { - const value = storage.getString(key); - return value ? JSON.parse(value) : null; - }, - setItem: (key, value) => { - storage.set(key, JSON.stringify(value)); - }, - removeItem: (key) => { - storage.remove(key); - }, - }, ); -export const sortOrderPreferenceAtom = atomWithStorage( - "sortOrderPreference", +export const sortOrderPreferenceAtom = atom( defaultSortOrderPreference, - { - getItem: (key) => { - const value = storage.getString(key); - return value ? JSON.parse(value) : null; - }, - setItem: (key, value) => { - storage.set(key, JSON.stringify(value)); - }, - removeItem: (key) => { - storage.remove(key); - }, - }, ); +// Genres / years / tags are multi-select, so each library remembers an array. +export interface MultiFilterPreference { + [libraryId: string]: string[]; +} + +export const genrePreferenceAtom = atom({}); +export const yearPreferenceAtom = atom({}); +export const tagPreferenceAtom = atom({}); + export const getSortByPreference = ( libraryId: string, preferences: SortPreference, @@ -191,3 +165,8 @@ export const getFilterByPreference = ( ) => { return preferences?.[libraryId] || null; }; + +export const getMultiFilterPreference = ( + libraryId: string, + preferences: MultiFilterPreference, +) => preferences?.[libraryId] ?? [];