From 8cf9a8d584eb8a4f7cac520730f3cdc28320ffce Mon Sep 17 00:00:00 2001 From: Alex Kim Date: Sun, 31 May 2026 21:49:32 +1000 Subject: [PATCH] chore(bottom-sheet): Migrate to expo-ui bottom sheet --- app/(auth)/(tabs)/(home)/downloads/index.tsx | 4 +- .../jellyseerr/page.tsx | 31 +-- app/(auth)/now-playing.tsx | 237 +++++++++--------- app/_layout.tsx | 237 +++++++++--------- bun.lock | 6 - components/AccountsSheet.tsx | 27 +- components/DownloadItem.tsx | 29 +-- components/GlobalModal.tsx | 26 +- components/IntroSheet.tsx | 28 +-- components/ItemTechnicalDetails.tsx | 50 ++-- components/PINEntryModal.tsx | 30 +-- components/PasswordEntryModal.tsx | 32 +-- components/PlatformDropdown.tsx | 2 +- components/PlayButton.tsx | 2 +- components/SaveAccountModal.tsx | 30 +-- components/filters/FilterSheet.tsx | 27 +- components/inputs/PinInput.tsx | 2 +- components/jellyseerr/RequestModal.tsx | 29 +-- components/music/CreatePlaylistModal.tsx | 32 +-- components/music/PlaylistOptionsSheet.tsx | 35 ++- components/music/PlaylistPickerSheet.tsx | 29 +-- components/music/PlaylistSortSheet.tsx | 30 +-- components/music/TrackOptionsSheet.tsx | 28 +-- components/settings/LibraryOptionsSheet.tsx | 28 +-- components/settings/QuickConnect.tsx | 28 +-- components/watchlists/WatchlistSheet.tsx | 28 +-- package.json | 1 - providers/GlobalModalProvider.tsx | 6 +- utils/expoUiBottomSheet.ts | 40 +++ 29 files changed, 477 insertions(+), 637 deletions(-) create mode 100644 utils/expoUiBottomSheet.ts diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 884b1fbb2..36a4bd300 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -1,4 +1,3 @@ -import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -18,6 +17,7 @@ import { useDownload } from "@/providers/DownloadProvider"; import { type DownloadedItem } from "@/providers/Downloads/types"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { queueAtom } from "@/utils/atoms/queue"; +import type { BottomSheetMethods } from "@/utils/expoUiBottomSheet"; import { writeToLog } from "@/utils/log"; export default function DownloadsPage() { @@ -26,7 +26,7 @@ export default function DownloadsPage() { const [_queue, _setQueue] = useAtom(queueAtom); const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload(); const router = useRouter(); - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const [showMigration, setShowMigration] = useState(false); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx index 519d5e5cc..f775497ac 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx @@ -1,11 +1,4 @@ import { Ionicons } from "@expo/vector-icons"; -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetTextInput, - BottomSheetView, -} from "@gorhom/bottom-sheet"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; @@ -31,6 +24,12 @@ import { ItemActions } from "@/components/series/SeriesActions"; import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; +import { + type BottomSheetMethods, + BottomSheetModal, + BottomSheetTextInput, + BottomSheetView, +} from "@/utils/expoUiBottomSheet"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { type IssueType, @@ -76,8 +75,8 @@ const MobilePage: React.FC = () => { const [issueMessage, setIssueMessage] = useState(); const [requestBody, _setRequestBody] = useState(); const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false); - const advancedReqModalRef = useRef(null); - const bottomSheetModalRef = useRef(null); + const advancedReqModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const { data: details, @@ -143,17 +142,6 @@ const MobilePage: React.FC = () => { } }, [jellyseerrApi, pendingRequest, refetch, t]); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const submitIssue = useCallback(() => { if (result.id && issueType && issueMessage && details) { jellyseerrApi @@ -478,6 +466,7 @@ const MobilePage: React.FC = () => { // This is till it's fixed because the menu isn't selectable on TV { backgroundStyle={{ backgroundColor: "#171717", }} - backdropComponent={renderBackdrop} - stackBehavior='push' onDismiss={handleIssueModalDismiss} > diff --git a/app/(auth)/now-playing.tsx b/app/(auth)/now-playing.tsx index 934175fb8..d215e7f4f 100644 --- a/app/(auth)/now-playing.tsx +++ b/app/(auth)/now-playing.tsx @@ -1,6 +1,5 @@ import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view"; import { Ionicons } from "@expo/vector-icons"; -import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import type { BaseItemDto, MediaSourceInfo, @@ -222,133 +221,129 @@ export default function NowPlayingScreen() { if (!currentTrack) { return ( - - - No track playing - - - ); - } - - return ( - - {/* Header */} - - - - - - - setViewMode("player")} - className='px-3 py-1' - > - - Now Playing - - - setViewMode("queue")} - className='px-3 py-1' - > - - Queue ({queue.length}) - - - - {/* Empty placeholder to balance header layout */} - - - - {viewMode === "player" ? ( - - ) : ( - - )} - - - - + No track playing - + ); + } + + return ( + + {/* Header */} + + + + + + + setViewMode("player")} + className='px-3 py-1' + > + + Now Playing + + + setViewMode("queue")} + className='px-3 py-1' + > + + Queue ({queue.length}) + + + + {/* Empty placeholder to balance header layout */} + + + + {viewMode === "player" ? ( + + ) : ( + + )} + + + + + ); } diff --git a/app/_layout.tsx b/app/_layout.tsx index 3d75e67c9..037050059 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,6 +1,5 @@ import "@/augmentations"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; -import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import NetInfo from "@react-native-community/netinfo"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { onlineManager, QueryClient } from "@tanstack/react-query"; @@ -412,127 +411,125 @@ function Layout() { - - - - - + null, + }} + /> + + + + + + + + + + + + + + {!Platform.isTV && } + + diff --git a/bun.lock b/bun.lock index 70f387822..9bf9fee83 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "streamyfin", @@ -11,7 +10,6 @@ "@expo/react-native-action-sheet": "^4.1.1", "@expo/ui": "~56.0.14", "@expo/vector-icons": "^15.0.3", - "@gorhom/bottom-sheet": "5.2.8", "@jellyfin/sdk": "^0.13.0", "@react-native-community/netinfo": "^12.0.0", "@react-navigation/material-top-tabs": "7.4.28", @@ -365,10 +363,6 @@ "@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="], - "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="], - - "@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="], - "@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="], "@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="], diff --git a/components/AccountsSheet.tsx b/components/AccountsSheet.tsx index 9f0894554..dc7978e73 100644 --- a/components/AccountsSheet.tsx +++ b/components/AccountsSheet.tsx @@ -1,10 +1,4 @@ import { Ionicons } from "@expo/vector-icons"; -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetView, -} from "@gorhom/bottom-sheet"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -12,6 +6,11 @@ import { Alert, Platform, TouchableOpacity, View } from "react-native"; import { Swipeable } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Colors } from "@/constants/Colors"; +import { + type BottomSheetMethods, + BottomSheetModal, + BottomSheetView, +} from "@/utils/expoUiBottomSheet"; import { deleteAccountCredential, type SavedServer, @@ -39,7 +38,7 @@ export const AccountsSheet: React.FC = ({ }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const isAndroid = Platform.OS === "android"; const snapPoints = useMemo( @@ -64,17 +63,6 @@ export const AccountsSheet: React.FC = ({ [setOpen], ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const handleDeleteAccount = async (account: SavedServerAccount) => { if (!server) return; @@ -118,15 +106,16 @@ export const AccountsSheet: React.FC = ({ ); if (!server) return null; + if (Platform.isTV) return null; return ( = ({ [user], ); - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const handlePresentModalPress = useCallback(() => { bottomSheetModalRef.current?.present(); @@ -317,17 +316,6 @@ export const DownloadItems: React.FC = ({ } }, [closeModal, initiateDownload, itemsToDownload, userCanDownload]); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const renderButtonContent = () => { // For single item downloads, show progress if item is being processed // For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued @@ -375,6 +363,8 @@ export const DownloadItems: React.FC = ({ } }; + if (Platform.isTV) return null; + return ( @@ -390,10 +380,7 @@ export const DownloadItems: React.FC = ({ backgroundColor: "#171717", }} onChange={handleSheetChanges} - backdropComponent={renderBackdrop} enablePanDownToClose - enableDismissOnClose - android_keyboardInputMode='adjustResize' keyboardBehavior='interactive' keyboardBlurBehavior='restore' > diff --git a/components/GlobalModal.tsx b/components/GlobalModal.tsx index 107f78e40..8c34666ee 100644 --- a/components/GlobalModal.tsx +++ b/components/GlobalModal.tsx @@ -1,10 +1,7 @@ -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, -} from "@gorhom/bottom-sheet"; import { useCallback, useEffect } from "react"; +import { Platform } from "react-native"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; +import { BottomSheetModal } from "@/utils/expoUiBottomSheet"; /** * GlobalModal Component @@ -12,8 +9,7 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider"; * This component renders a global bottom sheet modal that can be controlled * from anywhere in the app using the useGlobalModal hook. * - * Place this component at the root level of your app (in _layout.tsx) - * after BottomSheetModalProvider. + * Place this component at the root level of your app (in _layout.tsx). */ export const GlobalModal = () => { const { hideModal, modalState, modalRef, isVisible } = useGlobalModal(); @@ -33,17 +29,6 @@ export const GlobalModal = () => { [hideModal], ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const defaultOptions = { enableDynamicSizing: true, enablePanDownToClose: true, @@ -58,6 +43,8 @@ export const GlobalModal = () => { // Merge default options with provided options const modalOptions = { ...defaultOptions, ...modalState.options }; + if (Platform.isTV) return null; + return ( { ? { snapPoints: modalOptions.snapPoints } : { enableDynamicSizing: modalOptions.enableDynamicSizing })} onChange={handleSheetChanges} - backdropComponent={renderBackdrop} handleIndicatorStyle={modalOptions.handleIndicatorStyle} backgroundStyle={modalOptions.backgroundStyle} enablePanDownToClose={modalOptions.enablePanDownToClose} - enableDismissOnClose - stackBehavior='push' style={{ zIndex: 1000 }} > {modalState.content} diff --git a/components/IntroSheet.tsx b/components/IntroSheet.tsx index 0a744e329..3023df63a 100644 --- a/components/IntroSheet.tsx +++ b/components/IntroSheet.tsx @@ -1,10 +1,4 @@ import { Feather, Ionicons } from "@expo/vector-icons"; -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetScrollView, -} from "@gorhom/bottom-sheet"; import { Image } from "expo-image"; import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -13,6 +7,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import useRouter from "@/hooks/useAppRouter"; +import { + type BottomSheetMethods, + BottomSheetModal, + BottomSheetScrollView, +} from "@/utils/expoUiBottomSheet"; import { storage } from "@/utils/mmkv"; export interface IntroSheetRef { @@ -21,7 +20,7 @@ export interface IntroSheetRef { } export const IntroSheet = forwardRef((_, ref) => { - const bottomSheetRef = useRef(null); + const bottomSheetRef = useRef(null); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const router = useRouter(); @@ -36,17 +35,6 @@ export const IntroSheet = forwardRef((_, ref) => { }, })); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const handleDismiss = useCallback(() => { bottomSheetRef.current?.dismiss(); }, []); @@ -56,11 +44,13 @@ export const IntroSheet = forwardRef((_, ref) => { router.push("/settings"); }, []); + if (Platform.isTV) return null; + return ( diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx index 5708318d7..2d37251ad 100644 --- a/components/ItemTechnicalDetails.tsx +++ b/components/ItemTechnicalDetails.tsx @@ -1,10 +1,4 @@ import { Ionicons } from "@expo/vector-icons"; -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetScrollView, -} from "@gorhom/bottom-sheet"; import type { MediaSourceInfo, MediaStream, @@ -12,8 +6,13 @@ import type { import type React from "react"; import { useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { TouchableOpacity, View } from "react-native"; +import { Platform, TouchableOpacity, View } from "react-native"; import { formatBitrate } from "@/utils/bitrate"; +import { + type BottomSheetMethods, + BottomSheetModal, + BottomSheetScrollView, +} from "@/utils/expoUiBottomSheet"; import { Badge } from "./Badge"; import { Text } from "./common/Text"; @@ -22,9 +21,20 @@ interface Props { } export const ItemTechnicalDetails: React.FC = ({ source }) => { - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const { t } = useTranslation(); + if (Platform.isTV) { + return ( + + {t("item_card.video")} + + + + + ); + } + return ( {t("item_card.video")} @@ -36,27 +46,27 @@ export const ItemTechnicalDetails: React.FC = ({ source }) => { ( - - )} > - + + + {t("item_card.video")} + bottomSheetModalRef.current?.dismiss()} + hitSlop={12} + > + + + - - {t("item_card.video")} - diff --git a/components/PINEntryModal.tsx b/components/PINEntryModal.tsx index f450f5eb8..35e5540c1 100644 --- a/components/PINEntryModal.tsx +++ b/components/PINEntryModal.tsx @@ -1,9 +1,3 @@ -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetView, -} from "@gorhom/bottom-sheet"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -17,6 +11,11 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useHaptic } from "@/hooks/useHaptic"; +import { + type BottomSheetMethods, + BottomSheetModal, + BottomSheetView, +} from "@/utils/expoUiBottomSheet"; import { verifyAccountPIN } from "@/utils/secureCredentials"; import { Button } from "./Button"; import { Text } from "./common/Text"; @@ -43,7 +42,7 @@ export const PINEntryModal: React.FC = ({ }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const [pinCode, setPinCode] = useState(""); const [error, setError] = useState(null); const [isVerifying, setIsVerifying] = useState(false); @@ -78,17 +77,6 @@ export const PINEntryModal: React.FC = ({ [onClose], ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const shake = () => { Animated.sequence([ Animated.timing(shakeAnimation, { @@ -159,18 +147,18 @@ export const PINEntryModal: React.FC = ({ ]); }; + if (Platform.isTV) return null; + return ( = ({ }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -62,17 +61,6 @@ export const PasswordEntryModal: React.FC = ({ [onClose], ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const handleSubmit = async () => { if (!password) { setError(t("password.enter_password")); @@ -93,18 +81,18 @@ export const PasswordEntryModal: React.FC = ({ } }; + if (Platform.isTV) return null; + return ( = ({ }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const [selectedType, setSelectedType] = useState("none"); const [pinCode, setPinCode] = useState(""); const [pinError, setPinError] = useState(null); @@ -93,17 +92,6 @@ export const SaveAccountModal: React.FC = ({ setPinError(null); }; - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const handleOptionSelect = (type: AccountSecurityType) => { setSelectedType(type); setPinCode(""); @@ -135,18 +123,18 @@ export const SaveAccountModal: React.FC = ({ return true; }; + if (Platform.isTV) return null; + return ( ({ disableSearch = false, multiple = false, }: Props) => { - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const snapPoints = useMemo(() => ["85%"], []); const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -143,24 +143,15 @@ export const FilterSheet = ({ return data; }, [search, filteredData, data]); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); + if (Platform.isTV) return null; return ( { diff --git a/components/jellyseerr/RequestModal.tsx b/components/jellyseerr/RequestModal.tsx index 5bc44177c..6b6730760 100644 --- a/components/jellyseerr/RequestModal.tsx +++ b/components/jellyseerr/RequestModal.tsx @@ -1,23 +1,22 @@ -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetView, -} from "@gorhom/bottom-sheet"; -import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types"; import { useQuery } from "@tanstack/react-query"; import { forwardRef, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { View, type ViewProps } from "react-native"; +import { Platform, View, type ViewProps } from "react-native"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { PlatformDropdown } from "@/components/PlatformDropdown"; import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { + type BottomSheetMethods, + BottomSheetModal, + BottomSheetView, +} from "@/utils/expoUiBottomSheet"; import type { QualityProfile, RootFolder, Tag, } from "@/utils/jellyseerr/server/api/servarr/base"; + import type { MediaType } from "@/utils/jellyseerr/server/constants/media"; import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import { writeDebugLog } from "@/utils/log"; @@ -34,7 +33,7 @@ interface Props { } const RequestModal = forwardRef< - BottomSheetModalMethods, + BottomSheetMethods, Props & Omit >( ( @@ -283,11 +282,13 @@ const RequestModal = forwardRef< defaultTags, ]); + if (Platform.isTV) return null; + return ( ( - - )} - stackBehavior='push' > diff --git a/components/music/CreatePlaylistModal.tsx b/components/music/CreatePlaylistModal.tsx index fea1421f7..506c164ac 100644 --- a/components/music/CreatePlaylistModal.tsx +++ b/components/music/CreatePlaylistModal.tsx @@ -1,10 +1,3 @@ -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetTextInput, - BottomSheetView, -} from "@gorhom/bottom-sheet"; import React, { useCallback, useEffect, @@ -13,11 +6,17 @@ import React, { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, Keyboard } from "react-native"; +import { ActivityIndicator, Keyboard, Platform } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { useCreatePlaylist } from "@/hooks/usePlaylistMutations"; +import { + type BottomSheetMethods, + BottomSheetModal, + BottomSheetTextInput, + BottomSheetView, +} from "@/utils/expoUiBottomSheet"; interface Props { open: boolean; @@ -32,7 +31,7 @@ export const CreatePlaylistModal: React.FC = ({ onPlaylistCreated, initialTrackId, }) => { - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const insets = useSafeAreaInsets(); const { t } = useTranslation(); const createPlaylist = useCreatePlaylist(); @@ -59,17 +58,6 @@ export const CreatePlaylistModal: React.FC = ({ [setOpen], ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const handleCreate = useCallback(async () => { if (!name.trim()) return; @@ -86,13 +74,15 @@ export const CreatePlaylistModal: React.FC = ({ const isValid = name.trim().length > 0; + if (Platform.isTV) return null; + return ( = ({ setOpen, playlist, }) => { - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const router = useRouter(); const insets = useSafeAreaInsets(); const { t } = useTranslation(); @@ -47,17 +52,6 @@ export const PlaylistOptionsSheet: React.FC = ({ [setOpen], ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const handleDeletePlaylist = useCallback(() => { if (!playlist?.Id) return; @@ -89,14 +83,15 @@ export const PlaylistOptionsSheet: React.FC = ({ }, [playlist, deletePlaylist, setOpen, router, t]); if (!playlist) return null; + if (Platform.isTV) return null; return ( = ({ trackToAdd, onCreateNew, }) => { - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const insets = useSafeAreaInsets(); @@ -101,17 +101,6 @@ export const PlaylistPickerSheet: React.FC = ({ [setOpen], ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const handleSelectPlaylist = useCallback( async (playlist: BaseItemDto) => { if (!trackToAdd?.Id || !playlist.Id) return; @@ -142,13 +131,15 @@ export const PlaylistPickerSheet: React.FC = ({ [api], ); + if (Platform.isTV) return null; + return ( = ({ sortOrder, onSortChange, }) => { - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const insets = useSafeAreaInsets(); const { t } = useTranslation(); @@ -63,17 +62,6 @@ export const PlaylistSortSheet: React.FC = ({ [setOpen], ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const handleSortSelect = useCallback( (option: PlaylistSortOption) => { // If selecting same option, toggle order; otherwise use sensible default @@ -93,13 +81,15 @@ export const PlaylistSortSheet: React.FC = ({ [sortBy, sortOrder, onSortChange, setOpen], ); + if (Platform.isTV) return null; + return ( = ({ playlistId, onRemoveFromPlaylist, }) => { - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const router = useRouter(); @@ -128,17 +128,6 @@ export const TrackOptionsSheet: React.FC = ({ [setOpen], ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const handlePlayNext = useCallback(() => { if (track) { playNext(track); @@ -227,13 +216,14 @@ export const TrackOptionsSheet: React.FC = ({ const hasAlbum = !!(track?.AlbumId || track?.ParentId); if (!track) return null; + if (Platform.isTV) return null; return ( = ({ updateSettings, disabled = false, }) => { - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -161,25 +161,14 @@ export const LibraryOptionsSheet: React.FC = ({ [setOpen], ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - if (disabled) return null; + if (Platform.isTV) return null; return ( = ({ backgroundColor: "#171717", }} enablePanDownToClose - enableDismissOnClose > = ({ ...props }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [quickConnectCode, setQuickConnectCode] = useState(); - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const successHapticFeedback = useHaptic("success"); const errorHapticFeedback = useHaptic("error"); const snapPoints = useMemo( @@ -36,17 +35,6 @@ export const QuickConnect: React.FC = ({ ...props }) => { const { t } = useTranslation(); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - const authorizeQuickConnect = useCallback(async () => { if (quickConnectCode) { try { @@ -97,6 +85,7 @@ export const QuickConnect: React.FC = ({ ...props }) => { = ({ ...props }) => { backgroundStyle={{ backgroundColor: "#171717", }} - backdropComponent={renderBackdrop} keyboardBehavior={isAndroid ? "fillParent" : "interactive"} keyboardBlurBehavior='restore' - android_keyboardInputMode='adjustResize' - topInset={isAndroid ? 0 : undefined} > diff --git a/components/watchlists/WatchlistSheet.tsx b/components/watchlists/WatchlistSheet.tsx index 9d585909b..57b4182ee 100644 --- a/components/watchlists/WatchlistSheet.tsx +++ b/components/watchlists/WatchlistSheet.tsx @@ -1,10 +1,4 @@ import { Ionicons } from "@expo/vector-icons"; -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetView, -} from "@gorhom/bottom-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import React, { forwardRef, @@ -16,6 +10,7 @@ import React, { import { useTranslation } from "react-i18next"; import { ActivityIndicator, + Platform, StyleSheet, TouchableOpacity, View, @@ -31,6 +26,11 @@ import { useItemInWatchlists, useMyWatchlistsQuery, } from "@/hooks/useWatchlists"; +import { + type BottomSheetMethods, + BottomSheetModal, + BottomSheetView, +} from "@/utils/expoUiBottomSheet"; import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; export interface WatchlistSheetRef { @@ -263,7 +263,7 @@ const WatchlistSheetContent: React.FC = ({ export const WatchlistSheet = forwardRef( (_props, ref) => { - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); const [currentItem, setCurrentItem] = React.useState( null, ); @@ -283,23 +283,13 @@ export const WatchlistSheet = forwardRef( bottomSheetModalRef.current?.dismiss(); }, []); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); + if (Platform.isTV) return null; return ( void; isVisible: boolean; modalState: GlobalModalState; - modalRef: React.RefObject; + modalRef: React.RefObject; } const GlobalModalContext = createContext( @@ -57,7 +57,7 @@ export const GlobalModalProvider: React.FC = ({ options: undefined, }); const [isVisible, setIsVisible] = useState(false); - const modalRef = useRef(null); + const modalRef = useRef(null); const showModal = useCallback( (content: ReactNode, options?: ModalOptions) => { diff --git a/utils/expoUiBottomSheet.ts b/utils/expoUiBottomSheet.ts new file mode 100644 index 000000000..35e6a32bc --- /dev/null +++ b/utils/expoUiBottomSheet.ts @@ -0,0 +1,40 @@ +import { Platform } from "react-native"; + +/** + * TV-safe re-exports of `@expo/ui/community/bottom-sheet`. + * + * `@expo/ui` resolves its SwiftUI bridge at module load via + * `requireNativeModule('ExpoUI')`. That native module does not exist on tvOS, + * so a static top-level import from any route file crashes the whole route + * tree (expo-router eagerly loads every route). + * + * We `require()` the module lazily and only when *not* on tvOS. On TV the + * exported components are `undefined`, which is fine because every call site + * must early-return (`if (Platform.isTV) return null;`) before rendering a + * bottom sheet. + * + * Usage: + * + * import { + * BottomSheetModal, + * BottomSheetView, + * type BottomSheetMethods, + * } from "@/utils/expoUiBottomSheet"; + * + * const ref = useRef(null); + * + * if (Platform.isTV) return null; + * return ...; + */ +type BottomSheetMod = typeof import("@expo/ui/community/bottom-sheet"); + +const mod: BottomSheetMod = Platform.isTV + ? ({} as BottomSheetMod) + : (require("@expo/ui/community/bottom-sheet") as BottomSheetMod); + +export const BottomSheetModal = mod.BottomSheetModal; +export const BottomSheetView = mod.BottomSheetView; +export const BottomSheetScrollView = mod.BottomSheetScrollView; +export const BottomSheetTextInput = mod.BottomSheetTextInput; + +export type { BottomSheetMethods } from "@expo/ui/community/bottom-sheet";