From bd5b95c835c3e2f613b812d9acf0a6843c6d7042 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Thu, 25 Jun 2026 00:20:30 +0200 Subject: [PATCH] fix(library): per-library filter memory + reset-to-top, reset on app close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Genre/year/tag filters lived in global atoms with no per-library memory and no reset on library switch, so a selection in one library (e.g. Anime) bled into another (e.g. Shows). Sort/order/filterBy were already per-library but persisted across app restarts. Move all per-library filter state to in-memory preferences — genres/years/tags get their own, and sort/order/filterBy stop using atomWithStorage: each library remembers its own filters for the session and everything resets when the app is fully closed. The library mount effect restores the active library's selection, the shared useFilterReset clears it, and collections open with a clean slate. Also fix the grid not returning to the top on a filter/sort change or reset: it now pins to the top instantly and re-pins once the filtered fetch settles (FlashList was restoring the previous offset as the new content grew); pagination is left untouched. --- .../collections/[collectionId].tsx | 6 + app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 110 +++++++++++++----- hooks/useFilterReset.ts | 30 ++++- utils/atoms/filters.ts | 66 ++++------- 4 files changed, 135 insertions(+), 77 deletions(-) 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 80144416..49ea6afe 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 @@ -134,6 +134,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; diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 0d04e136..ca3b2233 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -51,7 +51,9 @@ import { FilterByPreferenceAtom, filterByAtom, genreFilterAtom, + genrePreferenceAtom, getFilterByPreference, + getMultiFilterPreference, getSortByPreference, getSortOrderPreference, SortByOption, @@ -62,9 +64,11 @@ 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"; @@ -103,6 +107,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(); @@ -184,6 +191,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, @@ -192,6 +206,12 @@ const Page = () => { _setSortBy, filterByPreference, _setFilterBy, + genrePreference, + yearPreference, + tagPreference, + setSelectedGenres, + setSelectedYears, + setSelectedTags, searchParams.sortBy, searchParams.sortOrder, searchParams.filterBy, @@ -236,6 +256,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 @@ -379,18 +425,26 @@ const Page = () => { const flashListRef = useRef>(null); - // Reset the grid to the top whenever the active filters/sort change (e.g. - // pressing reset) — otherwise the list stays stuck at the previous offset. + // 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 }); - }, [ - selectedGenres, - selectedYears, - selectedTags, - sortBy, - sortOrder, - filterBy, - ]); + 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 }) => ( @@ -541,7 +595,7 @@ const Page = () => { }); return response.data.Genres || []; }} - set={setSelectedGenres} + set={setGenres} values={selectedGenres} title={t("library.filters.genres")} renderItemLabel={(item) => item.toString()} @@ -568,7 +622,7 @@ const Page = () => { }); return response.data.Years || []; }} - set={setSelectedYears} + set={setYears} values={selectedYears} title={t("library.filters.years")} renderItemLabel={(item) => item.toString()} @@ -593,7 +647,7 @@ const Page = () => { }); return response.data.Tags || []; }} - set={setSelectedTags} + set={setTags} values={selectedTags} title={t("library.filters.tags")} renderItemLabel={(item) => item.toString()} @@ -673,11 +727,11 @@ const Page = () => { api, user?.Id, selectedGenres, - setSelectedGenres, + setGenres, selectedYears, - setSelectedYears, + setYears, selectedTags, - setSelectedTags, + setTags, sortBy, setSortBy, sortOrder, @@ -785,15 +839,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({ @@ -801,15 +855,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({ @@ -817,15 +871,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({ diff --git a/hooks/useFilterReset.ts b/hooks/useFilterReset.ts index aad25f85..96ac66bf 100644 --- a/hooks/useFilterReset.ts +++ b/hooks/useFilterReset.ts @@ -4,14 +4,17 @@ import { FilterByPreferenceAtom, filterByAtom, genreFilterAtom, + genrePreferenceAtom, SortByOption, SortOrderOption, sortByAtom, sortByPreferenceAtom, sortOrderAtom, sortOrderPreferenceAtom, + tagPreferenceAtom, tagsFilterAtom, yearFilterAtom, + yearPreferenceAtom, } from "@/utils/atoms/filters"; /** @@ -20,9 +23,9 @@ import { * 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 persisted preferences - * (sort, order, filterBy); otherwise the saved preference resurfaces when the - * library's mount effect re-applies it on the next entry. + * 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); @@ -34,6 +37,9 @@ export const useFilterReset = (libraryId: string) => { 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. @@ -67,6 +73,21 @@ export const useFilterReset = (libraryId: string) => { 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, @@ -78,6 +99,9 @@ export const useFilterReset = (libraryId: string) => { setSortByPreference, setSortOrderPreference, setFilterByPreference, + setGenrePreference, + setYearPreference, + setTagPreference, ]); return { hasActiveFilters, resetAllFilters }; diff --git a/utils/atoms/filters.ts b/utils/atoms/filters.ts index 1a016182..7c4ffef8 100644 --- a/utils/atoms/filters.ts +++ b/utils/atoms/filters.ts @@ -1,7 +1,5 @@ import { atom } from "jotai"; -import { atomWithStorage } from "jotai/utils"; import { useMemo } from "react"; -import { storage } from "../mmkv"; import { useSettings } from "./settings"; export enum SortByOption { @@ -125,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, @@ -196,3 +165,8 @@ export const getFilterByPreference = ( ) => { return preferences?.[libraryId] || null; }; + +export const getMultiFilterPreference = ( + libraryId: string, + preferences: MultiFilterPreference, +) => preferences?.[libraryId] ?? [];