diff --git a/GLOBAL_MODAL_GUIDE.md b/GLOBAL_MODAL_GUIDE.md
new file mode 100644
index 00000000..28e96cf5
--- /dev/null
+++ b/GLOBAL_MODAL_GUIDE.md
@@ -0,0 +1,193 @@
+# Global Modal System with Gorhom Bottom Sheet
+
+This guide explains how to use the global modal system implemented in this project.
+
+## Overview
+
+The global modal system allows you to trigger a bottom sheet modal from anywhere in your app programmatically, and render any component inside it.
+
+## Architecture
+
+The system consists of three main parts:
+
+1. **GlobalModalProvider** (`providers/GlobalModalProvider.tsx`) - Context provider that manages modal state
+2. **GlobalModal** (`components/GlobalModal.tsx`) - The actual modal component rendered at root level
+3. **useGlobalModal** hook - Hook to interact with the modal from anywhere
+
+## Setup (Already Configured)
+
+The system is already integrated into your app:
+
+```tsx
+// In app/_layout.tsx
+
+
+ {/* Your app content */}
+
+
+
+```
+
+## Usage
+
+### Basic Usage
+
+```tsx
+import { useGlobalModal } from "@/providers/GlobalModalProvider";
+import { View, Text } from "react-native";
+
+function MyComponent() {
+ const { showModal, hideModal } = useGlobalModal();
+
+ const handleOpenModal = () => {
+ showModal(
+
+ Hello from Modal!
+
+ );
+ };
+
+ return (
+
+ );
+}
+```
+
+### With Custom Options
+
+```tsx
+const handleOpenModal = () => {
+ showModal(
+ ,
+ {
+ snapPoints: ["25%", "50%", "90%"], // Custom snap points
+ enablePanDownToClose: true, // Allow swipe to close
+ backgroundStyle: { // Custom background
+ backgroundColor: "#000000",
+ },
+ }
+ );
+};
+```
+
+### Programmatic Control
+
+```tsx
+// Open modal
+showModal();
+
+// Close modal from within the modal content
+function ModalContent() {
+ const { hideModal } = useGlobalModal();
+
+ return (
+
+
+
+ );
+}
+
+// Close modal from outside
+hideModal();
+```
+
+### In Event Handlers or Functions
+
+```tsx
+function useApiCall() {
+ const { showModal } = useGlobalModal();
+
+ const fetchData = async () => {
+ try {
+ const result = await api.fetch();
+
+ // Show success modal
+ showModal(
+
+ );
+ } catch (error) {
+ // Show error modal
+ showModal(
+
+ );
+ }
+ };
+
+ return fetchData;
+}
+```
+
+## API Reference
+
+### `useGlobalModal()`
+
+Returns an object with the following properties:
+
+- **`showModal(content, options?)`** - Show the modal with given content
+ - `content: ReactNode` - Any React component or element to render
+ - `options?: ModalOptions` - Optional configuration object
+
+- **`hideModal()`** - Programmatically hide the modal
+
+- **`isVisible: boolean`** - Current visibility state of the modal
+
+### `ModalOptions`
+
+```typescript
+interface ModalOptions {
+ enableDynamicSizing?: boolean; // Auto-size based on content (default: true)
+ snapPoints?: (string | number)[]; // Fixed snap points (e.g., ["50%", "90%"])
+ enablePanDownToClose?: boolean; // Allow swipe down to close (default: true)
+ backgroundStyle?: object; // Custom background styles
+ handleIndicatorStyle?: object; // Custom handle indicator styles
+}
+```
+
+## Examples
+
+See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
+- Simple content modal
+- Modal with custom snap points
+- Complex component in modal
+- Success/error modals triggered from functions
+
+## Default Styling
+
+The modal uses these default styles (can be overridden via options):
+
+```typescript
+{
+ enableDynamicSizing: true,
+ enablePanDownToClose: true,
+ backgroundStyle: {
+ backgroundColor: "#171717", // Dark background
+ },
+ handleIndicatorStyle: {
+ backgroundColor: "white",
+ },
+}
+```
+
+## Best Practices
+
+1. **Keep content in separate components** - Don't inline large JSX in `showModal()` calls
+2. **Use the hook in custom hooks** - Create specialized hooks like `useShowSuccessModal()` for reusable modal patterns
+3. **Handle cleanup** - The modal automatically clears content when closed
+4. **Avoid nesting** - Don't show modals from within modals
+5. **Consider UX** - Only use for important, contextual information that requires user attention
+
+## Troubleshooting
+
+### Modal doesn't appear
+- Ensure `GlobalModalProvider` is above the component calling `useGlobalModal()`
+- Check that `BottomSheetModalProvider` is present in the tree
+- Verify `GlobalModal` component is rendered
+
+### Content is cut off
+- Use `enableDynamicSizing: true` for auto-sizing
+- Or specify appropriate `snapPoints`
+
+### Modal won't close
+- Ensure `enablePanDownToClose` is `true`
+- Check that backdrop is clickable
+- Use `hideModal()` for programmatic closing
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 677fc6ce..9a864846 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -2,8 +2,10 @@ import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { Platform } from "react-native";
+import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
+import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import {
apiAtom,
getOrSetDeviceId,
@@ -26,9 +28,13 @@ import {
} from "@/utils/log";
import { storage } from "@/utils/mmkv";
-const BackGroundDownloader = !Platform.isTV
- ? require("@kesha-antonov/react-native-background-downloader")
- : null;
+// TEMPORARILY DISABLED
+// To re-enable: Move package from "disabledDependencies" to "dependencies" in package.json,
+// run "bun install", then uncomment the require below and remove the null assignment
+// const BackGroundDownloader = !Platform.isTV
+// ? require("@kesha-antonov/react-native-background-downloader")
+// : null;
+const BackGroundDownloader = null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -386,7 +392,7 @@ function Layout() {
]);
useEffect(() => {
- if (Platform.isTV) {
+ if (Platform.isTV || !BackGroundDownloader) {
return;
}
@@ -395,7 +401,7 @@ function Layout() {
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
- BackGroundDownloader.checkForExistingDownloads().catch(
+ BackGroundDownloader?.checkForExistingDownloads().catch(
(error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
},
@@ -403,9 +409,11 @@ function Layout() {
}
});
- BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
- writeErrorLog("Failed to resume background downloads", error);
- });
+ BackGroundDownloader?.checkForExistingDownloads().catch(
+ (error: unknown) => {
+ writeErrorLog("Failed to resume background downloads", error);
+ },
+ );
return () => {
subscription.remove();
};
@@ -419,50 +427,53 @@ function Layout() {
-
-
-
- null,
+
+
+
+
+ null,
+ }}
+ />
+ null,
+ }}
+ />
+
+
+
+
- null,
- }}
- />
-
-
-
-
-
+
+
+
diff --git a/bun.lock b/bun.lock
index a42da693..9191042d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,9 +11,7 @@
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0",
- "@kesha-antonov/react-native-background-downloader": "github:fredrikburmester/react-native-background-downloader#f3bf69ad124b6ec6adbc30c7f688935d0376fc56",
"@react-native-community/netinfo": "^11.4.1",
- "@react-native-menu/menu": "^1.2.4",
"@react-navigation/material-top-tabs": "^7.2.14",
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "2.0.2",
@@ -453,8 +451,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
- "@kesha-antonov/react-native-background-downloader": ["@kesha-antonov/react-native-background-downloader@github:fredrikburmester/react-native-background-downloader#f3bf69a", { "peerDependencies": { "react-native": ">=0.57.0" } }, "fredrikburmester-react-native-background-downloader-f3bf69a"],
-
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -531,8 +527,6 @@
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
- "@react-native-menu/menu": ["@react-native-menu/menu@1.2.4", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-63NU9NHd/KcBa7lhhV69xQmY3ORUxjMRmlzTY/qrY56GHnuKX+16dvUpKAuRSPK5kebBuRw5X4MFe2L9JHUlWA=="],
-
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="],
"@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.4-0", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-Y3iFcyRBzgxX+BVuvmQ+Dxfwfo22AFEQbTMwqEbyqnfzMWi9aoxyc9YAZa26CJ0UONgk+/jRZK6BzDfwa4PRxg=="],
diff --git a/components/ExampleGlobalModalUsage.tsx b/components/ExampleGlobalModalUsage.tsx
new file mode 100644
index 00000000..ccebb823
--- /dev/null
+++ b/components/ExampleGlobalModalUsage.tsx
@@ -0,0 +1,203 @@
+/**
+ * Example Usage of Global Modal
+ *
+ * This file demonstrates how to use the global modal system from anywhere in your app.
+ * You can delete this file after understanding how it works.
+ */
+
+import { Ionicons } from "@expo/vector-icons";
+import { TouchableOpacity, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useGlobalModal } from "@/providers/GlobalModalProvider";
+
+/**
+ * Example 1: Simple Content Modal
+ */
+export const SimpleModalExample = () => {
+ const { showModal } = useGlobalModal();
+
+ const handleOpenModal = () => {
+ showModal(
+
+ Simple Modal
+
+ This is a simple modal with just some text content.
+
+
+ Swipe down or tap outside to close.
+
+ ,
+ );
+ };
+
+ return (
+
+ Open Simple Modal
+
+ );
+};
+
+/**
+ * Example 2: Modal with Custom Snap Points
+ */
+export const CustomSnapPointsExample = () => {
+ const { showModal } = useGlobalModal();
+
+ const handleOpenModal = () => {
+ showModal(
+
+
+ Custom Snap Points
+
+
+ This modal has custom snap points (25%, 50%, 90%).
+
+
+
+ Try dragging the modal to different heights!
+
+
+ ,
+ {
+ snapPoints: ["25%", "50%", "90%"],
+ enableDynamicSizing: false,
+ },
+ );
+ };
+
+ return (
+
+ Custom Snap Points
+
+ );
+};
+
+/**
+ * Example 3: Complex Component in Modal
+ */
+const SettingsModalContent = () => {
+ const { hideModal } = useGlobalModal();
+
+ const settings = [
+ {
+ id: 1,
+ title: "Notifications",
+ icon: "notifications-outline" as const,
+ enabled: true,
+ },
+ { id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
+ {
+ id: 3,
+ title: "Auto-play",
+ icon: "play-outline" as const,
+ enabled: false,
+ },
+ ];
+
+ return (
+
+ Settings
+
+ {settings.map((setting, index) => (
+
+
+
+ {setting.title}
+
+
+
+
+
+ ))}
+
+
+ Close
+
+
+ );
+};
+
+export const ComplexModalExample = () => {
+ const { showModal } = useGlobalModal();
+
+ const handleOpenModal = () => {
+ showModal();
+ };
+
+ return (
+
+ Complex Component
+
+ );
+};
+
+/**
+ * Example 4: Modal Triggered from Function (e.g., API response)
+ */
+export const useShowSuccessModal = () => {
+ const { showModal } = useGlobalModal();
+
+ return (message: string) => {
+ showModal(
+
+
+
+
+ Success!
+ {message}
+ ,
+ );
+ };
+};
+
+/**
+ * Main Demo Component
+ */
+export const GlobalModalDemo = () => {
+ const showSuccess = useShowSuccessModal();
+
+ return (
+
+
+ Global Modal Examples
+
+
+
+
+
+
+ showSuccess("Operation completed successfully!")}
+ className='bg-orange-600 px-4 py-2 rounded-lg'
+ >
+ Show Success Modal
+
+
+ );
+};
diff --git a/components/GlobalModal.tsx b/components/GlobalModal.tsx
new file mode 100644
index 00000000..db80675d
--- /dev/null
+++ b/components/GlobalModal.tsx
@@ -0,0 +1,72 @@
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import { useCallback } from "react";
+import { useGlobalModal } from "@/providers/GlobalModalProvider";
+
+/**
+ * GlobalModal Component
+ *
+ * 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.
+ */
+export const GlobalModal = () => {
+ const { hideModal, modalState, modalRef } = useGlobalModal();
+
+ const handleSheetChanges = useCallback(
+ (index: number) => {
+ if (index === -1) {
+ hideModal();
+ }
+ },
+ [hideModal],
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const defaultOptions = {
+ enableDynamicSizing: true,
+ enablePanDownToClose: true,
+ backgroundStyle: {
+ backgroundColor: "#171717",
+ },
+ handleIndicatorStyle: {
+ backgroundColor: "white",
+ },
+ };
+
+ // Merge default options with provided options
+ const modalOptions = { ...defaultOptions, ...modalState.options };
+
+ return (
+
+ {modalState.content}
+
+ );
+};
diff --git a/package.json b/package.json
index c4db322e..147bba61 100644
--- a/package.json
+++ b/package.json
@@ -29,9 +29,7 @@
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0",
- "@kesha-antonov/react-native-background-downloader": "github:fredrikburmester/react-native-background-downloader#f3bf69ad124b6ec6adbc30c7f688935d0376fc56",
"@react-native-community/netinfo": "^11.4.1",
- "@react-native-menu/menu": "^1.2.4",
"@react-navigation/material-top-tabs": "^7.2.14",
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "2.0.2",
@@ -141,6 +139,9 @@
}
},
"private": true,
+ "disabledDependencies": {
+ "@kesha-antonov/react-native-background-downloader": "^3.2.6"
+ },
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"biome check --write --unsafe --no-errors-on-unmatched"
diff --git a/providers/GlobalModalProvider.tsx b/providers/GlobalModalProvider.tsx
new file mode 100644
index 00000000..f8281f4b
--- /dev/null
+++ b/providers/GlobalModalProvider.tsx
@@ -0,0 +1,95 @@
+import type { BottomSheetModal } from "@gorhom/bottom-sheet";
+import type React from "react";
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useRef,
+ useState,
+} from "react";
+
+interface ModalOptions {
+ enableDynamicSizing?: boolean;
+ snapPoints?: (string | number)[];
+ enablePanDownToClose?: boolean;
+ backgroundStyle?: object;
+ handleIndicatorStyle?: object;
+}
+
+interface GlobalModalState {
+ content: ReactNode | null;
+ options?: ModalOptions;
+}
+
+interface GlobalModalContextType {
+ showModal: (content: ReactNode, options?: ModalOptions) => void;
+ hideModal: () => void;
+ isVisible: boolean;
+ modalState: GlobalModalState;
+ modalRef: React.RefObject;
+}
+
+const GlobalModalContext = createContext(
+ undefined,
+);
+
+export const useGlobalModal = () => {
+ const context = useContext(GlobalModalContext);
+ if (!context) {
+ throw new Error("useGlobalModal must be used within GlobalModalProvider");
+ }
+ return context;
+};
+
+interface GlobalModalProviderProps {
+ children: ReactNode;
+}
+
+export const GlobalModalProvider: React.FC = ({
+ children,
+}) => {
+ const [modalState, setModalState] = useState({
+ content: null,
+ options: undefined,
+ });
+ const [isVisible, setIsVisible] = useState(false);
+ const modalRef = useRef(null);
+
+ const showModal = useCallback(
+ (content: ReactNode, options?: ModalOptions) => {
+ setModalState({ content, options });
+ setIsVisible(true);
+ // Small delay to ensure state is updated before presenting
+ setTimeout(() => {
+ modalRef.current?.present();
+ }, 100);
+ },
+ [],
+ );
+
+ const hideModal = useCallback(() => {
+ modalRef.current?.dismiss();
+ setIsVisible(false);
+ // Clear content after animation completes
+ setTimeout(() => {
+ setModalState({ content: null, options: undefined });
+ }, 300);
+ }, []);
+
+ const value = {
+ showModal,
+ hideModal,
+ isVisible,
+ modalState,
+ modalRef,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export type { GlobalModalContextType, ModalOptions };