diff --git a/components/BitRateSheet.tsx b/components/BitRateSheet.tsx index 0589cd05..6a9a89c5 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 = ({ = ({ return streams; }, [streams, streamType, noneOption]); const [open, setOpen] = useState(false); + const sheetModalRef = useRef(null); if (isTv || (streams && streams.length === 0)) return null; @@ -58,7 +60,10 @@ export const TrackSheet: React.FC = ({ {title} setOpen(true)} + onPress={() => { + setOpen(true); + sheetModalRef.current?.present(); + }} > {selected === -1 && streamType === "Subtitle" @@ -70,6 +75,7 @@ export const TrackSheet: React.FC = ({ ({ ...props }: FilterButtonProps) => { const [open, setOpen] = useState(false); + const sheetModalRef = useRef(null); - const { data: filters } = useQuery({ + const { data: filters, isLoading } = useQuery({ queryKey: ["filters", title, queryKey, id], queryFn, staleTime: 0, @@ -44,9 +46,15 @@ export const FilterButton = ({ 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). */} { - filters?.length && setOpen(true); + setOpen(true); + sheetModalRef.current?.present(); }} > ({ title={title} open={open} setOpen={setOpen} + modalRef={sheetModalRef} + loading={isLoading} data={filters} values={values} set={set} diff --git a/components/filters/FilterSheet.tsx b/components/filters/FilterSheet.tsx index 27075f8b..116d8ec5 100644 --- a/components/filters/FilterSheet.tsx +++ b/components/filters/FilterSheet.tsx @@ -19,11 +19,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 +76,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(); @@ -127,13 +139,20 @@ 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); } }, []); @@ -182,9 +201,15 @@ export const FilterSheet = ({ }} > {title} - - {t("search.x_items", { count: _data?.length })} - + {loading ? ( + + + + ) : ( + + {t("search.x_items", { count: _data?.length })} + + )} {showSearch && (