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] ?? [];