From 1e7f5a4ef8568ae70f8e919fe364ad7f175c8507 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Sun, 28 Jun 2026 01:23:00 +0200 Subject: [PATCH] fix(library): host filters in a single shared sheet to stop stacked sheets Each FilterButton owned its own bottom-sheet modal, so tapping two filter buttons quickly could present two sheets at once. Add a FilterSheetProvider that hosts one shared sheet per screen; buttons just swap its content via openFilter(). A first-wins guard in the provider (one source of truth, reset on the single close path) ignores a second tap while a sheet is opening. The generic FilterSheet is untouched, so the bitrate/source/track selectors and provider-less screens (logs, discover) keep their standalone sheet. Also drop the header deps that remounted the whole filter bar on every fetch. --- .../collections/[collectionId].tsx | 141 ++++----- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 79 +++-- components/filters/FilterButton.tsx | 93 ++++-- components/filters/FilterSheetProvider.tsx | 74 +++++ components/filters/SharedFilterSheet.tsx | 286 ++++++++++++++++++ 5 files changed, 535 insertions(+), 138 deletions(-) create mode 100644 components/filters/FilterSheetProvider.tsx create mode 100644 components/filters/SharedFilterSheet.tsx 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 49ea6afe..c46fa00b 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"; @@ -210,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 ( @@ -472,7 +472,6 @@ const page: React.FC = () => { setSortBy, sortOrder, setSortOrder, - isFetching, ], ); @@ -639,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 ca3b2233..9d0bea55 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -30,6 +30,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"; @@ -70,7 +71,6 @@ import { 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"; @@ -561,7 +561,6 @@ const Page = () => { const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const generalFilters = useFilterOptions(); - const settings = useSettings(); const ListHeaderComponent = useCallback( () => ( { setSortBy, sortOrder, setSortOrder, - isFetching, filterBy, setFilter, - settings, ], ); @@ -927,43 +924,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/filters/FilterButton.tsx b/components/filters/FilterButton.tsx index fb21cb07..34082816 100644 --- a/components/filters/FilterButton.tsx +++ b/components/filters/FilterButton.tsx @@ -1,10 +1,11 @@ import { FontAwesome, Ionicons } from "@expo/vector-icons"; import type { BottomSheetModal } from "@gorhom/bottom-sheet"; import { useQuery } from "@tanstack/react-query"; -import { useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { TouchableOpacity, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; import { FilterSheet } from "./FilterSheet"; +import { useFilterSheet } from "./FilterSheetProvider"; interface FilterButtonProps extends ViewProps { id: string; @@ -34,8 +35,10 @@ export const FilterButton = ({ icon = "filter", ...props }: FilterButtonProps) => { - const [open, setOpen] = useState(false); - const sheetModalRef = useRef(null); + // 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, isLoading } = useQuery({ queryKey: ["filters", title, queryKey, id], @@ -44,19 +47,51 @@ export const FilterButton = ({ 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 ( <> - {/* present() must be called here, inside the press handler: calling it - from an effect after a state update silently no-ops on the new - architecture and the sheet never appears. Opening immediately also - replaces the old data-loaded gate that left the button silently - dead while options were still loading (the sheet shows a loader). */} - { - setOpen(true); - sheetModalRef.current?.present(); - }} - > + ({ )} - - title={title} - open={open} - setOpen={setOpen} - modalRef={sheetModalRef} - loading={isLoading} - 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/FilterSheetProvider.tsx b/components/filters/FilterSheetProvider.tsx new file mode 100644 index 00000000..9a37338a --- /dev/null +++ b/components/filters/FilterSheetProvider.tsx @@ -0,0 +1,74 @@ +import type { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import { type FilterConfig, SharedFilterSheet } from "./SharedFilterSheet"; + +interface FilterSheetContextType { + openFilter: (config: FilterConfig) => void; +} + +const FilterSheetContext = createContext(null); + +/** + * Returns the shared-sheet controller, or null when rendered outside a + * FilterSheetProvider — FilterButton then falls back to its own standalone + * sheet (used by screens that don't host a provider, e.g. logs / discover). + */ +export const useFilterSheet = (): FilterSheetContextType | null => + useContext(FilterSheetContext); + +/** + * Hosts the single shared filter sheet for a screen. Every FilterButton under + * it calls openFilter() to show its options in that one sheet — so two sheets + * can never stack regardless of how fast the buttons are tapped. present() runs + * synchronously from the button's press handler (the modal is always mounted). + */ +export const FilterSheetProvider: React.FC = ({ + children, +}) => { + const modalRef = useRef(null); + const [open, setOpen] = useState(false); + const [config, setConfig] = useState(null); + + // First-wins guard. With a single shared sheet there is exactly one source of + // truth (this ref) reset on the one close path — so unlike a per-button guard + // it can't get stuck on remounts or multiple instances. A second tap during + // the first sheet's open animation is ignored; the first tapped filter wins. + const openRef = useRef(false); + + const openFilter = useCallback((next: FilterConfig) => { + if (openRef.current) return; + openRef.current = true; + setConfig(next); + setOpen(true); + modalRef.current?.present(); + }, []); + + // Single close path for every dismissal (select / swipe / backdrop) — frees + // the guard reliably. + const closeSheet = useCallback(() => { + openRef.current = false; + setOpen(false); + }, []); + + const value = useMemo(() => ({ openFilter }), [openFilter]); + + return ( + + {children} + + + ); +}; diff --git a/components/filters/SharedFilterSheet.tsx b/components/filters/SharedFilterSheet.tsx new file mode 100644 index 00000000..3fe8392b --- /dev/null +++ b/components/filters/SharedFilterSheet.tsx @@ -0,0 +1,286 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import { useQuery } from "@tanstack/react-query"; +import { isEqual } from "lodash"; +import type React from "react"; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, TouchableOpacity, View } from "react-native"; +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"; + +/** + * Config for the filter currently shown by the shared sheet. Generics are erased + * at the FilterButton → provider boundary, so item-typed callbacks use `any`. + */ +export interface FilterConfig { + /** Stable identity — changing it remounts the content with fresh state. */ + key: string; + id: string; + queryKey: string; + queryFn: (params: any) => Promise; + title: string; + values: any[]; + set: (value: any[]) => void; + renderItemLabel: (item: any) => React.ReactNode; + searchFilter?: (item: any, query: string) => boolean; + disableSearch?: boolean; + multiple?: boolean; +} + +const LIMIT = 100; + +interface SharedFilterSheetProps { + modalRef: React.RefObject; + open: boolean; + setOpen: (open: boolean) => void; + config: FilterConfig | null; +} + +/** + * The single shared filter sheet — one BottomSheetModal hosted by + * FilterSheetProvider for a whole screen; FilterButtons only swap its `config`. + * Because only one modal ever exists, rapid taps across buttons can never stack + * two sheets, so no guard/timer is needed. The modal shell stays mounted with a + * stable ref (present() can run synchronously from the tapping button); the + * inner content is keyed by the active filter so its pagination/search reset + * cleanly between filters. + */ +export const SharedFilterSheet: React.FC = ({ + modalRef, + open, + setOpen, + config, +}) => { + const snapPoints = useMemo(() => ["85%"], []); + const insets = useSafeAreaInsets(); + + // Opening is imperative (the provider calls present()); this effect only + // closes, and never dismisses a modal that was never presented. + const wasPresentedRef = useRef(false); + useEffect(() => { + if (!open && wasPresentedRef.current) { + modalRef.current?.dismiss(); + } + }, [open, modalRef]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index >= 0) { + wasPresentedRef.current = true; + } else if (index === -1) { + wasPresentedRef.current = false; + setOpen(false); + } + }, + [setOpen], + ); + + const requestClose = useCallback(() => setOpen(false), [setOpen]); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + return ( + + + + {config && ( + + )} + + + + ); +}; + +interface SharedFilterSheetContentProps { + config: FilterConfig; + onRequestClose: () => void; +} + +const SharedFilterSheetContent: React.FC = ({ + config, + onRequestClose, +}) => { + const { + id, + queryKey, + queryFn, + title, + values, + set, + renderItemLabel, + searchFilter, + disableSearch = false, + multiple = false, + } = config; + const { t } = useTranslation(); + + // The options query lives here (deduped with the FilterButton's own query via + // the shared React Query key), so the list stays live after the sheet opens. + const { data: _data, isLoading: loading } = useQuery({ + queryKey: ["filters", title, queryKey, id], + queryFn, + staleTime: 0, + enabled: !!id && !!queryFn && !!queryKey, + }); + + const [data, setData] = useState([]); + const [offset, setOffset] = useState(0); + + const [search, setSearch] = useState(""); + // Filtering on every keystroke blocks the JS thread on large lists; defer the + // value so the keystroke render stays cheap and the list update runs after. + const deferredSearch = useDeferredValue(search); + const [showSearch, setShowSearch] = useState(false); + + const filteredData = useMemo(() => { + if (!deferredSearch) return _data; + const results = []; + for (let i = 0; i < (_data?.length || 0); i++) { + if (_data && searchFilter?.(_data[i], deferredSearch)) { + results.push(_data[i]); + } + } + return results.slice(0, 100); + }, [deferredSearch, _data, searchFilter]); + + useEffect(() => { + if (!data || data.length === 0 || disableSearch) return; + if (data.length > 15) { + setShowSearch(true); + } + }, [data, disableSearch]); + + // Loads data in batches of LIMIT from offset (efficient "load more"). + useEffect(() => { + if (!_data || _data.length === 0) return; + + const newData = [...data]; + for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) { + const item = _data[i]; + // Option objects are recreated across renders → dedupe by value. + const exists = newData.some((existingItem) => + isEqual(existingItem, item), + ); + if (!exists) { + newData.push(item); + } + } + setData(newData); + }, [offset, _data]); + + const renderData = useMemo(() => { + if (deferredSearch.length > 0 && showSearch) return filteredData; + return data; + }, [deferredSearch, showSearch, filteredData, data]); + + const renderedRows = useMemo( + () => + renderData?.map((item, index) => ( + + { + 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(() => onRequestClose(), 250); + } else if (!isSelected) { + set([item]); + setTimeout(() => onRequestClose(), 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, renderItemLabel, onRequestClose], + ); + + return ( + <> + {title} + {loading ? ( + + + + ) : ( + + {t("search.x_items", { count: _data?.length })} + + )} + {showSearch && ( + + )} + + {renderedRows} + + {data.length < (_data?.length || 0) && ( + + )} + + ); +};