Files
streamyfin/components/MediaSourceSheet.tsx
Gauvain d12d62863e fix(filters): present the filter sheet from the press handler
On the new architecture with Reanimated 4, BottomSheetModal.present()
called from a useEffect after a state update silently no-ops: the press
registered, open flipped to true, the effect called present() on a
valid ref - and nothing mounted (no onChange, nothing in the native
tree). Sheets that present() directly inside their press handler
(downloads, account picker) kept working, which is what pinned it down.

FilterSheet now takes a modalRef and the opener presents imperatively
from the gesture handler. The [open] effect only handles closing, and
never dismisses a modal that was never presented. The sheet also opens
immediately with a loader while options load, instead of the old
data-loaded press gate that left the button silently dead.

This restores genre/year/tag/sort filters in libraries and collections,
and the same pattern is applied to the bitrate/media-source/track
sheets that share FilterSheet.
2026-06-11 00:42:56 +02:00

82 lines
2.6 KiB
TypeScript

import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
import { FilterSheet } from "./filters/FilterSheet";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: MediaSourceInfo) => void;
selected?: MediaSourceInfo | null;
}
export const MediaSourceSheet: React.FC<Props> = ({
item,
onChange,
selected,
...props
}) => {
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const getDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
if (source.Name) return source.Name;
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
return `Source ${source.Id}`;
}, []);
const selectedName = useMemo(() => {
if (!selected) return "";
return getDisplayName(selected);
}, [selected, getDisplayName]);
if (isTv || (item.MediaSources && item.MediaSources.length <= 1)) return null;
return (
<View className='flex shrink' style={{ minWidth: 75 }}>
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.video")}
data={item.MediaSources || []}
values={selected ? [selected] : []}
multiple={false}
searchFilter={(src, query) =>
getDisplayName(src as MediaSourceInfo)
.toLowerCase()
.includes(query.toLowerCase())
}
renderItemLabel={(src) => (
<Text>{getDisplayName(src as MediaSourceInfo)}</Text>
)}
set={(vals) => {
const chosen = vals[0] as MediaSourceInfo | undefined;
if (chosen) onChange(chosen);
}}
/>
</View>
);
};