fix(library): per-library filter memory + reset-to-top, reset on app close

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.
This commit is contained in:
Gauvain
2026-06-25 00:20:30 +02:00
parent 8a37ec46bf
commit bd5b95c835
4 changed files with 135 additions and 77 deletions

View File

@@ -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;

View File

@@ -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<FlashListRef<BaseItemDto>>(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({

View File

@@ -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 };

View File

@@ -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<SortPreference>(
"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<SortPreference>(defaultSortPreference);
export const FilterByPreferenceAtom = atomWithStorage<FilterPreference>(
"filterByPreference",
export const FilterByPreferenceAtom = atom<FilterPreference>(
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>(
"sortOrderPreference",
export const sortOrderPreferenceAtom = atom<SortOrderPreference>(
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<MultiFilterPreference>({});
export const yearPreferenceAtom = atom<MultiFilterPreference>({});
export const tagPreferenceAtom = atom<MultiFilterPreference>({});
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] ?? [];