mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: Expo 54 (new arch) support + new in-house download module (#1174)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: sarendsen <coding-mosses0z@icloud.com> Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
154788cf91
commit
485dc6eeac
1
.gitignore
vendored
1
.gitignore
vendored
@@ -65,3 +65,4 @@ streamyfin-4fec1-firebase-adminsdk.json
|
|||||||
|
|
||||||
# Version and Backup Files
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
|
modules/background-downloader/android/build/*
|
||||||
|
|||||||
232
GLOBAL_MODAL_GUIDE.md
Normal file
232
GLOBAL_MODAL_GUIDE.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# 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
|
||||||
|
<BottomSheetModalProvider>
|
||||||
|
<GlobalModalProvider>
|
||||||
|
{/* Your app content */}
|
||||||
|
<GlobalModal />
|
||||||
|
</GlobalModalProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
import { View, Text } from "react-native";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-white text-2xl'>Hello from Modal!</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onPress={handleOpenModal} title="Open Modal" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Custom Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<YourCustomComponent />,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"], // Custom snap points
|
||||||
|
enablePanDownToClose: true, // Allow swipe to close
|
||||||
|
backgroundStyle: { // Custom background
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic Control
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Open modal
|
||||||
|
showModal(<Content />);
|
||||||
|
|
||||||
|
// Close modal from within the modal content
|
||||||
|
function ModalContent() {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Button onPress={hideModal} title="Close" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<SuccessMessage data={result} />
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Show error modal
|
||||||
|
showModal(
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## Using with PlatformDropdown
|
||||||
|
|
||||||
|
When using `PlatformDropdown` with option groups, avoid setting a `title` on the `OptionGroup` if you're already passing a `title` prop to `PlatformDropdown`. This prevents nested menu behavior on iOS where users have to click through an extra layer.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good - No title in option group (title is on PlatformDropdown)
|
||||||
|
const optionGroups: OptionGroup[] = [
|
||||||
|
{
|
||||||
|
options: items.map((item) => ({
|
||||||
|
type: "radio",
|
||||||
|
label: item.name,
|
||||||
|
value: item,
|
||||||
|
selected: item.id === selected?.id,
|
||||||
|
onPress: () => onChange(item),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={optionGroups}
|
||||||
|
title="Select Item" // Title here
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Bad - Causes nested menu on iOS
|
||||||
|
const optionGroups: OptionGroup[] = [
|
||||||
|
{
|
||||||
|
title: "Items", // This creates a nested Picker on iOS
|
||||||
|
options: items.map((item) => ({
|
||||||
|
type: "radio",
|
||||||
|
label: item.name,
|
||||||
|
value: item,
|
||||||
|
selected: item.id === selected?.id,
|
||||||
|
onPress: () => onChange(item),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -6,9 +6,6 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add the background downloader plugin only for non-TV builds
|
|
||||||
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
// Only override googleServicesFile if env var is set
|
||||||
|
|||||||
21
app.json
21
app.json
@@ -2,12 +2,13 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.40.4",
|
"version": "0.46.2",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
|
"newArchEnabled": true,
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": ["**/*"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"requireFullScreen": true,
|
"requireFullScreen": true,
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 76,
|
"versionCode": 81,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
"useFrameworks": "static"
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||||
"compileSdkVersion": 35,
|
"compileSdkVersion": 35,
|
||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
@@ -115,10 +117,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
|
||||||
["./plugins/withAndroidManifest.js"],
|
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
|
||||||
["./plugins/withGradleProperties.js"],
|
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -137,8 +135,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./plugins/with-runtime-framework-headers.js",
|
"expo-web-browser",
|
||||||
"react-native-bottom-tabs"
|
["./plugins/with-runtime-framework-headers.js"],
|
||||||
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
|
["./plugins/withAndroidManifest.js"],
|
||||||
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
|
["./plugins/withGradleProperties.js"]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
@@ -157,7 +159,6 @@
|
|||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||||
},
|
}
|
||||||
"newArchEnabled": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: Platform.OS !== "ios",
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.custom_links"),
|
headerTitle: t("tabs.custom_links"),
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Favorites } from "@/components/home/Favorites";
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
@@ -28,7 +28,7 @@ export default function favorites() {
|
|||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='my-4'>
|
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||||
<Favorites />
|
<Favorites />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
|
||||||
|
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
|
||||||
@@ -37,7 +30,6 @@ export default function IndexLayout() {
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast background='transparent' />
|
<Chromecast.Chromecast background='transparent' />
|
||||||
<RefreshButton />
|
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
@@ -49,49 +41,119 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='downloads/index'
|
name='downloads/index'
|
||||||
options={{
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='downloads/[seriesId]'
|
name='downloads/[seriesId]'
|
||||||
options={{
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
title: t("home.downloads.tvseries"),
|
title: t("home.downloads.tvseries"),
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='sessions/index'
|
name='sessions/index'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.sessions.title"),
|
title: t("home.sessions.title"),
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings'
|
name='settings'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.settings_title"),
|
title: t("home.settings.settings_title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/marlin-search/page'
|
name='settings/marlin-search/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/jellyseerr/page'
|
name='settings/jellyseerr/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/hide-libraries/page'
|
name='settings/hide-libraries/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/logs/page'
|
name='settings/logs/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -99,6 +161,11 @@ export default function IndexLayout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "",
|
title: "",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
presentation: "modal",
|
presentation: "modal",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -109,6 +176,11 @@ export default function IndexLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
@@ -133,32 +205,6 @@ const SettingsButton = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RefreshButton = () => {
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
eventBus.emit("refreshHome");
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleRefresh}
|
|
||||||
className='mr-4'
|
|
||||||
disabled={isRefreshing}
|
|
||||||
>
|
|
||||||
{isRefreshing ? (
|
|
||||||
<ActivityIndicator size='small' color='white' />
|
|
||||||
) : (
|
|
||||||
<Ionicons name='refresh-outline' color='white' size={24} />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SessionsButton = () => {
|
const SessionsButton = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||||
import {
|
import {
|
||||||
@@ -23,21 +24,22 @@ export default function page() {
|
|||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const { getDownloadedItems, deleteItems } = useDownload();
|
const { downloadedItems, deleteItems } = useDownload();
|
||||||
|
|
||||||
const series = useMemo(() => {
|
const series = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
getDownloadedItems()
|
downloadedItems
|
||||||
?.filter((f) => f.item.SeriesId === seriesId)
|
?.filter((f) => f.item.SeriesId === seriesId)
|
||||||
?.sort(
|
?.sort(
|
||||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
(a, b) =>
|
||||||
|
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
|
||||||
) || []
|
) || []
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [getDownloadedItems]);
|
}, [downloadedItems, seriesId]);
|
||||||
|
|
||||||
// Group episodes by season in a single pass
|
// Group episodes by season in a single pass
|
||||||
const seasonGroups = useMemo(() => {
|
const seasonGroups = useMemo(() => {
|
||||||
@@ -91,7 +93,7 @@ export default function page() {
|
|||||||
title: series[0].item.SeriesName,
|
title: series[0].item.SeriesName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
storage.delete(seriesId);
|
storage.remove(seriesId);
|
||||||
router.back();
|
router.back();
|
||||||
}
|
}
|
||||||
}, [series]);
|
}, [series]);
|
||||||
@@ -107,17 +109,22 @@ export default function page() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Delete",
|
text: "Delete",
|
||||||
onPress: () => deleteItems(groupBySeason),
|
onPress: () =>
|
||||||
|
deleteItems(
|
||||||
|
groupBySeason
|
||||||
|
.map((item) => item.Id)
|
||||||
|
.filter((id) => id !== undefined),
|
||||||
|
),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}, [groupBySeason]);
|
}, [groupBySeason, deleteItems]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-1'>
|
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||||
{series.length > 0 && (
|
{series.length > 0 && (
|
||||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
<View className='flex flex-row items-center justify-start px-4 pb-2'>
|
||||||
<SeasonDropdown
|
<SeasonDropdown
|
||||||
item={series[0].item}
|
item={series[0].item}
|
||||||
seasons={uniqueSeasons}
|
seasons={uniqueSeasons}
|
||||||
@@ -140,11 +147,13 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<ScrollView key={seasonIndex} className='px-4'>
|
<FlashList
|
||||||
{groupBySeason.map((episode, index) => (
|
key={seasonIndex}
|
||||||
<EpisodeCard key={index} item={episode} />
|
data={groupBySeason}
|
||||||
))}
|
renderItem={({ item }) => <EpisodeCard item={item} />}
|
||||||
</ScrollView>
|
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||||
@@ -26,18 +26,15 @@ import { writeToLog } from "@/utils/log";
|
|||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [_queue, _setQueue] = useAtom(queueAtom);
|
||||||
const {
|
const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
|
||||||
removeProcess,
|
|
||||||
getDownloadedItems,
|
|
||||||
deleteFileByType,
|
|
||||||
deleteAllFiles,
|
|
||||||
} = useDownload();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const [showMigration, setShowMigration] = useState(false);
|
const [showMigration, setShowMigration] = useState(false);
|
||||||
|
|
||||||
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const migration_20241124 = () => {
|
const migration_20241124 = () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.downloads.new_app_version_requires_re_download"),
|
t("home.downloads.new_app_version_requires_re_download"),
|
||||||
@@ -62,7 +59,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadedFiles = getDownloadedItems();
|
const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
const movies = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -106,7 +103,10 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
<TouchableOpacity
|
||||||
|
onPress={bottomSheetModalRef.current?.present}
|
||||||
|
className='px-2'
|
||||||
|
>
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -119,7 +119,7 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [showMigration]);
|
}, [showMigration]);
|
||||||
|
|
||||||
const deleteMovies = () =>
|
const _deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -130,7 +130,7 @@ export default function page() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const deleteShows = () =>
|
const _deleteShows = () =>
|
||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -141,38 +141,39 @@ export default function page() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
});
|
});
|
||||||
const deleteOtherMedia = () =>
|
const _deleteOtherMedia = () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
otherMedia.map((item) =>
|
otherMedia
|
||||||
deleteFileByType(item.item.Type)
|
.filter((item) => item.item.Type)
|
||||||
.then(() =>
|
.map((item) =>
|
||||||
toast.success(
|
deleteFileByType(item.item.Type!)
|
||||||
t("home.downloads.toasts.deleted_media_successfully", {
|
.then(() =>
|
||||||
type: item.item.Type,
|
toast.success(
|
||||||
}),
|
t("home.downloads.toasts.deleted_media_successfully", {
|
||||||
),
|
type: item.item.Type,
|
||||||
)
|
}),
|
||||||
.catch((reason) => {
|
),
|
||||||
writeToLog("ERROR", reason);
|
)
|
||||||
toast.error(
|
.catch((reason) => {
|
||||||
t("home.downloads.toasts.failed_to_delete_media", {
|
writeToLog("ERROR", reason);
|
||||||
type: item.item.Type,
|
toast.error(
|
||||||
}),
|
t("home.downloads.toasts.failed_to_delete_media", {
|
||||||
);
|
type: item.item.Type,
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAllMedia = async () =>
|
|
||||||
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ScrollView
|
||||||
<View style={{ flex: 1 }}>
|
showsVerticalScrollIndicator={false}
|
||||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
<View className='py-4'>
|
>
|
||||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
||||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
|
{/* Queue card - hidden */}
|
||||||
|
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.downloads.queue")}
|
{t("home.downloads.queue")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -214,139 +215,96 @@ export default function page() {
|
|||||||
{t("home.downloads.no_items_in_queue")}
|
{t("home.downloads.no_items_in_queue")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View> */}
|
||||||
|
|
||||||
<ActiveDownloads />
|
<ActiveDownloads />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{movies.length > 0 && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
|
<Text className='text-lg font-bold'>
|
||||||
|
{t("home.downloads.movies")}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
|
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
{movies.length > 0 && (
|
<View className='px-4 flex flex-row'>
|
||||||
<View className='mb-4'>
|
{movies?.map((item) => (
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
<TouchableItemRouter
|
||||||
<Text className='text-lg font-bold'>
|
item={item.item}
|
||||||
{t("home.downloads.movies")}
|
isOffline
|
||||||
</Text>
|
key={item.item.Id}
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
>
|
||||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
<MovieCard item={item.item} />
|
||||||
</View>
|
</TouchableItemRouter>
|
||||||
</View>
|
))}
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{movies?.map((item) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item.item}
|
|
||||||
isOffline
|
|
||||||
key={item.item.Id}
|
|
||||||
>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
</ScrollView>
|
||||||
{groupedBySeries.length > 0 && (
|
</View>
|
||||||
<View className='mb-4'>
|
)}
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
{groupedBySeries.length > 0 && (
|
||||||
<Text className='text-lg font-bold'>
|
<View className='mb-4'>
|
||||||
{t("home.downloads.tvseries")}
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
</Text>
|
<Text className='text-lg font-bold'>
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
{t("home.downloads.tvseries")}
|
||||||
<Text className='text-xs font-bold'>
|
</Text>
|
||||||
{groupedBySeries?.length}
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
</Text>
|
<Text className='text-xs font-bold'>
|
||||||
</View>
|
{groupedBySeries?.length}
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{groupedBySeries?.map((items) => (
|
|
||||||
<View
|
|
||||||
className='mb-2 last:mb-0'
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
>
|
|
||||||
<SeriesCard
|
|
||||||
items={items.map((i) => i.item)}
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{otherMedia.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.other_media")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>
|
|
||||||
{otherMedia?.length}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{otherMedia?.map((item) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item.item}
|
|
||||||
isOffline
|
|
||||||
key={item.item.Id}
|
|
||||||
>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{downloadedFiles?.length === 0 && (
|
|
||||||
<View className='flex px-4'>
|
|
||||||
<Text className='opacity-50'>
|
|
||||||
{t("home.downloads.no_downloaded_items")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className='px-4 flex flex-row'>
|
||||||
|
{groupedBySeries?.map((items) => (
|
||||||
|
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}>
|
||||||
|
<SeriesCard
|
||||||
|
items={items.map((i) => i.item)}
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
enableDynamicSizing
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<BottomSheetView>
|
{otherMedia.length > 0 && (
|
||||||
<View className='p-4 space-y-4 mb-4'>
|
<View className='mb-4'>
|
||||||
<Button color='purple' onPress={deleteMovies}>
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
{t("home.downloads.delete_all_movies_button")}
|
<Text className='text-lg font-bold'>
|
||||||
</Button>
|
{t("home.downloads.other_media")}
|
||||||
<Button color='purple' onPress={deleteShows}>
|
</Text>
|
||||||
{t("home.downloads.delete_all_tvseries_button")}
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
</Button>
|
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
|
||||||
{otherMedia.length > 0 && (
|
</View>
|
||||||
<Button color='purple' onPress={deleteOtherMedia}>
|
</View>
|
||||||
{t("home.downloads.delete_all_other_media_button")}
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
</Button>
|
<View className='px-4 flex flex-row'>
|
||||||
)}
|
{otherMedia?.map((item) => (
|
||||||
<Button color='red' onPress={deleteAllMedia}>
|
<TouchableItemRouter
|
||||||
{t("home.downloads.delete_all_button")}
|
item={item.item}
|
||||||
</Button>
|
isOffline
|
||||||
|
key={item.item.Id}
|
||||||
|
>
|
||||||
|
<MovieCard item={item.item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
)}
|
||||||
</BottomSheetModal>
|
{downloadedFiles?.length === 0 && (
|
||||||
</>
|
<View className='flex px-4'>
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_downloaded_items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { HomeIndex } from "@/components/settings/HomeIndex";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Home } from "../../../../components/home/Home";
|
||||||
|
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
||||||
|
|
||||||
export default function page() {
|
const Index = () => {
|
||||||
return <HomeIndex />;
|
const { settings } = useSettings();
|
||||||
}
|
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
||||||
|
|
||||||
|
if (showLargeHomeCarousel) {
|
||||||
|
return <HomeWithCarousel />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Home />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import {
|
import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
|
||||||
HardwareAccelerationType,
|
|
||||||
type SessionInfoDto,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
import {
|
||||||
GeneralCommandType,
|
GeneralCommandType,
|
||||||
PlaystateCommand,
|
PlaystateCommand,
|
||||||
|
SessionInfoDto,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
@@ -13,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Badge } from "@/components/Badge";
|
import { Badge } from "@/components/Badge";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -49,14 +47,13 @@ export default function page() {
|
|||||||
<FlashList
|
<FlashList
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 17,
|
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||||
paddingHorizontal: 17,
|
paddingHorizontal: 17,
|
||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
}}
|
}}
|
||||||
data={sessions}
|
data={sessions}
|
||||||
renderItem={({ item }) => <SessionCard session={item} />}
|
renderItem={({ item }) => <SessionCard session={item} />}
|
||||||
keyExtractor={(item) => item.Id || ""}
|
keyExtractor={(item) => item.Id || ""}
|
||||||
estimatedItemSize={200}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function settings() {
|
|||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-red-600'>
|
<Text className='text-red-600 px-2'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -56,12 +56,16 @@ export default function settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='p-4 flex flex-col gap-y-4'>
|
<View
|
||||||
|
className='p-4 flex flex-col gap-y-4'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
@@ -115,6 +119,7 @@ export default function settings() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{!Platform.isTV && <StorageSettings />}
|
{!Platform.isTV && <StorageSettings />}
|
||||||
|
<View className='h-24' />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as Sharing from "expo-sharing";
|
import * as Sharing from "expo-sharing";
|
||||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type React from "react";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
@@ -204,154 +205,154 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className=''>
|
<FlatList
|
||||||
<FlatList
|
horizontal
|
||||||
horizontal
|
showsHorizontalScrollIndicator={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
contentContainerStyle={{
|
||||||
contentContainerStyle={{
|
display: "flex",
|
||||||
display: "flex",
|
paddingHorizontal: 15,
|
||||||
paddingHorizontal: 15,
|
paddingVertical: 16,
|
||||||
paddingVertical: 16,
|
flexDirection: "row",
|
||||||
flexDirection: "row",
|
}}
|
||||||
}}
|
extraData={[
|
||||||
extraData={[
|
selectedGenres,
|
||||||
selectedGenres,
|
selectedYears,
|
||||||
selectedYears,
|
selectedTags,
|
||||||
selectedTags,
|
sortBy,
|
||||||
sortBy,
|
sortOrder,
|
||||||
sortOrder,
|
]}
|
||||||
]}
|
data={[
|
||||||
data={[
|
{
|
||||||
{
|
key: "reset",
|
||||||
key: "reset",
|
component: <ResetFiltersButton />,
|
||||||
component: <ResetFiltersButton />,
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "genre",
|
||||||
key: "genre",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='genreFilter'
|
||||||
queryKey='genreFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: collectionId,
|
||||||
parentId: collectionId,
|
});
|
||||||
});
|
return response.data.Genres || [];
|
||||||
return response.data.Genres || [];
|
}}
|
||||||
}}
|
set={setSelectedGenres}
|
||||||
set={setSelectedGenres}
|
values={selectedGenres}
|
||||||
values={selectedGenres}
|
title={t("library.filters.genres")}
|
||||||
title={t("library.filters.genres")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "year",
|
||||||
key: "year",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='yearFilter'
|
||||||
queryKey='yearFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: collectionId,
|
||||||
parentId: collectionId,
|
});
|
||||||
});
|
return response.data.Years || [];
|
||||||
return response.data.Years || [];
|
}}
|
||||||
}}
|
set={setSelectedYears}
|
||||||
set={setSelectedYears}
|
values={selectedYears}
|
||||||
values={selectedYears}
|
title={t("library.filters.years")}
|
||||||
title={t("library.filters.years")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "tags",
|
||||||
key: "tags",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='tagsFilter'
|
||||||
queryKey='tagsFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: collectionId,
|
||||||
parentId: collectionId,
|
});
|
||||||
});
|
return response.data.Tags || [];
|
||||||
return response.data.Tags || [];
|
}}
|
||||||
}}
|
set={setSelectedTags}
|
||||||
set={setSelectedTags}
|
values={selectedTags}
|
||||||
values={selectedTags}
|
title={t("library.filters.tags")}
|
||||||
title={t("library.filters.tags")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortBy",
|
||||||
key: "sortBy",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='sortBy'
|
||||||
queryKey='sortBy'
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
set={setSortBy}
|
||||||
set={setSortBy}
|
values={sortBy}
|
||||||
values={sortBy}
|
title={t("library.filters.sort_by")}
|
||||||
title={t("library.filters.sort_by")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortOrder",
|
||||||
key: "sortOrder",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='sortOrder'
|
||||||
queryKey='sortOrder'
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
set={setSortOrder}
|
||||||
set={setSortOrder}
|
values={sortOrder}
|
||||||
values={sortOrder}
|
title={t("library.filters.sort_order")}
|
||||||
title={t("library.filters.sort_order")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
]}
|
||||||
]}
|
renderItem={({ item }) => item.component}
|
||||||
renderItem={({ item }) => item.component}
|
keyExtractor={(item) => item.key}
|
||||||
keyExtractor={(item) => item.key}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
collectionId,
|
collectionId,
|
||||||
@@ -393,7 +394,6 @@ const page: React.FC = () => {
|
|||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={255}
|
|
||||||
numColumns={
|
numColumns={
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,31 +19,29 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import Cast from "@/components/jellyseerr/Cast";
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
import {
|
import {
|
||||||
type IssueType,
|
type IssueType,
|
||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
|
||||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
|
||||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
@@ -65,6 +63,7 @@ const Page: React.FC = () => {
|
|||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||||
|
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
||||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
@@ -115,6 +114,10 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
|
const handleIssueModalDismiss = useCallback(() => {
|
||||||
|
setIssueTypeDropdownOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setRequestBody = useCallback(
|
const setRequestBody = useCallback(
|
||||||
(body: MediaRequestBody) => {
|
(body: MediaRequestBody) => {
|
||||||
_setRequestBody(body);
|
_setRequestBody(body);
|
||||||
@@ -156,11 +159,31 @@ const Page: React.FC = () => {
|
|||||||
[details],
|
[details],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const issueTypeOptionGroups = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: t("jellyseerr.types"),
|
||||||
|
options: Object.entries(IssueTypeName)
|
||||||
|
.reverse()
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: value,
|
||||||
|
value: key,
|
||||||
|
selected: key === String(issueType),
|
||||||
|
onPress: () => setIssueType(key as unknown as IssueType),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[issueType, t],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (details) {
|
if (details) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
<TouchableOpacity
|
||||||
|
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
|
||||||
|
>
|
||||||
<ItemActions item={details} />
|
<ItemActions item={details} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -355,6 +378,8 @@ const Page: React.FC = () => {
|
|||||||
backgroundColor: "#171717",
|
backgroundColor: "#171717",
|
||||||
}}
|
}}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
|
stackBehavior='push'
|
||||||
|
onDismiss={handleIssueModalDismiss}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -364,50 +389,25 @@ const Page: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col w-full'>
|
||||||
<DropdownMenu.Root>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
<DropdownMenu.Trigger>
|
{t("jellyseerr.issue_type")}
|
||||||
<View className='flex flex-col'>
|
</Text>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<PlatformDropdown
|
||||||
{t("jellyseerr.issue_type")}
|
groups={issueTypeOptionGroups}
|
||||||
|
trigger={
|
||||||
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{issueType
|
||||||
|
? IssueTypeName[issueType]
|
||||||
|
: t("jellyseerr.select_an_issue")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
|
||||||
{issueType
|
|
||||||
? IssueTypeName[issueType]
|
|
||||||
: t("jellyseerr.select_an_issue")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
}
|
||||||
<DropdownMenu.Content
|
title={t("jellyseerr.types")}
|
||||||
loop={false}
|
open={issueTypeDropdownOpen}
|
||||||
side='bottom'
|
onOpenChange={setIssueTypeDropdownOpen}
|
||||||
align='center'
|
/>
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>
|
|
||||||
{t("jellyseerr.types")}
|
|
||||||
</DropdownMenu.Label>
|
|
||||||
{Object.entries(IssueTypeName)
|
|
||||||
.reverse()
|
|
||||||
.map(([key, value], _idx) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={value}
|
|
||||||
onSelect={() =>
|
|
||||||
setIssueType(key as unknown as IssueType)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{value}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||||
|
|||||||
@@ -65,9 +65,11 @@ const page: React.FC = () => {
|
|||||||
const { data: allEpisodes, isLoading } = useQuery({
|
const { data: allEpisodes, isLoading } = useQuery({
|
||||||
queryKey: ["AllEpisodes", item?.Id],
|
queryKey: ["AllEpisodes", item?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getTvShowsApi(api!).getEpisodes({
|
if (!api || !user?.Id || !item?.Id) return [];
|
||||||
seriesId: item?.Id!,
|
|
||||||
userId: user?.Id!,
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.Id,
|
||||||
|
userId: user.Id,
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
// Note: Including trick play is necessary to enable trick play downloads
|
// Note: Including trick play is necessary to enable trick play downloads
|
||||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
|
|||||||
@@ -271,145 +271,143 @@ const Page = () => {
|
|||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className=''>
|
<FlatList
|
||||||
<FlatList
|
horizontal
|
||||||
horizontal
|
showsHorizontalScrollIndicator={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
contentContainerStyle={{
|
||||||
contentContainerStyle={{
|
display: "flex",
|
||||||
display: "flex",
|
paddingHorizontal: 15,
|
||||||
paddingHorizontal: 15,
|
paddingVertical: 16,
|
||||||
paddingVertical: 16,
|
flexDirection: "row",
|
||||||
flexDirection: "row",
|
}}
|
||||||
}}
|
data={[
|
||||||
data={[
|
{
|
||||||
{
|
key: "reset",
|
||||||
key: "reset",
|
component: <ResetFiltersButton />,
|
||||||
component: <ResetFiltersButton />,
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "genre",
|
||||||
key: "genre",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='genreFilter'
|
||||||
queryKey='genreFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: libraryId,
|
||||||
parentId: libraryId,
|
});
|
||||||
});
|
return response.data.Genres || [];
|
||||||
return response.data.Genres || [];
|
}}
|
||||||
}}
|
set={setSelectedGenres}
|
||||||
set={setSelectedGenres}
|
values={selectedGenres}
|
||||||
values={selectedGenres}
|
title={t("library.filters.genres")}
|
||||||
title={t("library.filters.genres")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "year",
|
||||||
key: "year",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='yearFilter'
|
||||||
queryKey='yearFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: libraryId,
|
||||||
parentId: libraryId,
|
});
|
||||||
});
|
return response.data.Years || [];
|
||||||
return response.data.Years || [];
|
}}
|
||||||
}}
|
set={setSelectedYears}
|
||||||
set={setSelectedYears}
|
values={selectedYears}
|
||||||
values={selectedYears}
|
title={t("library.filters.years")}
|
||||||
title={t("library.filters.years")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "tags",
|
||||||
key: "tags",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='tagsFilter'
|
||||||
queryKey='tagsFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: libraryId,
|
||||||
parentId: libraryId,
|
});
|
||||||
});
|
return response.data.Tags || [];
|
||||||
return response.data.Tags || [];
|
}}
|
||||||
}}
|
set={setSelectedTags}
|
||||||
set={setSelectedTags}
|
values={selectedTags}
|
||||||
values={selectedTags}
|
title={t("library.filters.tags")}
|
||||||
title={t("library.filters.tags")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortBy",
|
||||||
key: "sortBy",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='sortBy'
|
||||||
queryKey='sortBy'
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
set={setSortBy}
|
||||||
set={setSortBy}
|
values={sortBy}
|
||||||
values={sortBy}
|
title={t("library.filters.sort_by")}
|
||||||
title={t("library.filters.sort_by")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortOrder",
|
||||||
key: "sortOrder",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='sortOrder'
|
||||||
queryKey='sortOrder'
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
set={setSortOrder}
|
||||||
set={setSortOrder}
|
values={sortOrder}
|
||||||
values={sortOrder}
|
title={t("library.filters.sort_order")}
|
||||||
title={t("library.filters.sort_order")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
]}
|
||||||
]}
|
renderItem={({ item }) => item.component}
|
||||||
renderItem={({ item }) => item.component}
|
keyExtractor={(item) => item.key}
|
||||||
keyExtractor={(item) => item.key}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
libraryId,
|
libraryId,
|
||||||
@@ -453,7 +451,6 @@ const Page = () => {
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={[orientation, nrOfCols]}
|
extraData={[orientation, nrOfCols]}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={244}
|
|
||||||
numColumns={nrOfCols}
|
numColumns={nrOfCols}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
|
|||||||
@@ -1,85 +1,166 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!settings?.libraryOptions) return null;
|
if (!settings?.libraryOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack>
|
||||||
<Stack>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name='index'
|
||||||
name='index'
|
options={{
|
||||||
options={{
|
headerShown: !Platform.isTV,
|
||||||
headerShown: !Platform.isTV,
|
headerTitle: t("tabs.library"),
|
||||||
headerTitle: t("tabs.library"),
|
headerBlurEffect: "none",
|
||||||
headerBlurEffect: "none",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerShadowVisible: false,
|
||||||
headerShadowVisible: false,
|
headerRight: () =>
|
||||||
headerRight: () =>
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
!Platform.isTV && (
|
||||||
!Platform.isTV && (
|
<PlatformDropdown
|
||||||
<TouchableOpacity
|
trigger={
|
||||||
onPress={() => setOptionsSheetOpen(true)}
|
<View className='pl-1.5'>
|
||||||
className='flex flex-row items-center justify-center w-9 h-9'
|
<Ionicons
|
||||||
>
|
name='ellipsis-horizontal-outline'
|
||||||
<Ionicons
|
size={24}
|
||||||
name='ellipsis-horizontal-outline'
|
color='white'
|
||||||
size={24}
|
/>
|
||||||
color='white'
|
</View>
|
||||||
/>
|
}
|
||||||
</TouchableOpacity>
|
title={t("library.options.display")}
|
||||||
),
|
groups={[
|
||||||
}}
|
{
|
||||||
/>
|
title: t("library.options.display"),
|
||||||
<Stack.Screen
|
options: [
|
||||||
name='[libraryId]'
|
{
|
||||||
options={{
|
type: "radio",
|
||||||
title: "",
|
label: t("library.options.row"),
|
||||||
headerShown: !Platform.isTV,
|
value: "row",
|
||||||
headerBlurEffect: "none",
|
selected: settings.libraryOptions.display === "row",
|
||||||
headerTransparent: Platform.OS === "ios",
|
onPress: () =>
|
||||||
headerShadowVisible: false,
|
updateSettings({
|
||||||
}}
|
libraryOptions: {
|
||||||
/>
|
...settings.libraryOptions,
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
display: "row",
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
},
|
||||||
))}
|
}),
|
||||||
<Stack.Screen
|
},
|
||||||
name='collections/[collectionId]'
|
{
|
||||||
options={{
|
type: "radio",
|
||||||
title: "",
|
label: t("library.options.list"),
|
||||||
headerShown: !Platform.isTV,
|
value: "list",
|
||||||
headerBlurEffect: "none",
|
selected: settings.libraryOptions.display === "list",
|
||||||
headerTransparent: Platform.OS === "ios",
|
onPress: () =>
|
||||||
headerShadowVisible: false,
|
updateSettings({
|
||||||
}}
|
libraryOptions: {
|
||||||
/>
|
...settings.libraryOptions,
|
||||||
</Stack>
|
display: "list",
|
||||||
<LibraryOptionsSheet
|
},
|
||||||
open={optionsSheetOpen}
|
}),
|
||||||
setOpen={setOptionsSheetOpen}
|
},
|
||||||
settings={settings.libraryOptions}
|
],
|
||||||
updateSettings={(options) =>
|
},
|
||||||
updateSettings({
|
{
|
||||||
libraryOptions: {
|
title: t("library.options.image_style"),
|
||||||
...settings.libraryOptions,
|
options: [
|
||||||
...options,
|
{
|
||||||
},
|
type: "radio",
|
||||||
})
|
label: t("library.options.poster"),
|
||||||
}
|
value: "poster",
|
||||||
disabled={pluginSettings?.libraryOptions?.locked}
|
selected:
|
||||||
|
settings.libraryOptions.imageStyle === "poster",
|
||||||
|
onPress: () =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "poster",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "radio",
|
||||||
|
label: t("library.options.cover"),
|
||||||
|
value: "cover",
|
||||||
|
selected:
|
||||||
|
settings.libraryOptions.imageStyle === "cover",
|
||||||
|
onPress: () =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "cover",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Options",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: "toggle",
|
||||||
|
label: t("library.options.show_titles"),
|
||||||
|
value: settings.libraryOptions.showTitles,
|
||||||
|
onToggle: () =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showTitles: !settings.libraryOptions.showTitles,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
disabled:
|
||||||
|
settings.libraryOptions.imageStyle === "poster",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "toggle",
|
||||||
|
label: t("library.options.show_stats"),
|
||||||
|
value: settings.libraryOptions.showStats,
|
||||||
|
onToggle: () =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showStats: !settings.libraryOptions.showStats,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
<Stack.Screen
|
||||||
|
name='[libraryId]'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
<Stack.Screen
|
||||||
|
name='collections/[collectionId]'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { Platform, StyleSheet, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -84,11 +84,11 @@ export default function index() {
|
|||||||
extraData={settings}
|
extraData={settings}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 17,
|
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left + 17,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right + 17,
|
||||||
}}
|
}}
|
||||||
data={libraries}
|
data={libraries}
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
@@ -105,7 +105,6 @@ export default function index() {
|
|||||||
<View className='h-4' />
|
<View className='h-4' />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
estimatedItemSize={200}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function SearchLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: !Platform.isTV,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { Tag } from "@/components/GenreTags";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import {
|
import {
|
||||||
JellyseerrSearchSort,
|
JellyseerrSearchSort,
|
||||||
@@ -33,8 +31,10 @@ import {
|
|||||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
|
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||||
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -284,67 +284,30 @@ export default function search() {
|
|||||||
)}
|
)}
|
||||||
<View
|
<View
|
||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{jellyseerrApi && (
|
{jellyseerrApi && (
|
||||||
<ScrollView
|
<View className='pl-4 pr-4 flex flex-row'>
|
||||||
horizontal
|
<SearchTabButtons
|
||||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
searchType={searchType}
|
||||||
>
|
setSearchType={setSearchType}
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
t={t}
|
||||||
<Tag
|
/>
|
||||||
text={t("search.library")}
|
|
||||||
textClass='p-1'
|
|
||||||
className={
|
|
||||||
searchType === "Library" ? "bg-purple-600" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
|
||||||
<Tag
|
|
||||||
text={t("search.discover")}
|
|
||||||
textClass='p-1'
|
|
||||||
className={
|
|
||||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{searchType === "Discover" &&
|
{searchType === "Discover" &&
|
||||||
!loading &&
|
!loading &&
|
||||||
noResults &&
|
noResults &&
|
||||||
debouncedSearch.length > 0 && (
|
debouncedSearch.length > 0 && (
|
||||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
<DiscoverFilters
|
||||||
<FilterButton
|
searchFilterId={searchFilterId}
|
||||||
id={searchFilterId}
|
orderFilterId={orderFilterId}
|
||||||
queryKey='jellyseerr_search'
|
jellyseerrOrderBy={jellyseerrOrderBy}
|
||||||
queryFn={async () =>
|
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
||||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
jellyseerrSortOrder={jellyseerrSortOrder}
|
||||||
Number.isNaN(Number(v)),
|
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
||||||
)
|
t={t}
|
||||||
}
|
/>
|
||||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
|
||||||
values={[jellyseerrOrderBy]}
|
|
||||||
title={t("library.filters.sort_by")}
|
|
||||||
renderItemLabel={(item) =>
|
|
||||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
|
||||||
}
|
|
||||||
disableSearch={true}
|
|
||||||
/>
|
|
||||||
<FilterButton
|
|
||||||
id={orderFilterId}
|
|
||||||
queryKey='jellysearr_search'
|
|
||||||
queryFn={async () => ["asc", "desc"]}
|
|
||||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
|
||||||
values={[jellyseerrSortOrder]}
|
|
||||||
title={t("library.filters.sort_order")}
|
|
||||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
|
||||||
disableSearch={true}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className='mt-2'>
|
<View className='mt-2'>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export default function TabLayout() {
|
|||||||
backgroundColor: "#121212",
|
backgroundColor: "#121212",
|
||||||
}}
|
}}
|
||||||
tabBarActiveTintColor={Colors.primary}
|
tabBarActiveTintColor={Colors.primary}
|
||||||
|
activeIndicatorColor={"#392c3b"}
|
||||||
scrollEdgeAppearance='default'
|
scrollEdgeAppearance='default'
|
||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name='index' />
|
<NativeTabs.Screen redirect name='index' />
|
||||||
@@ -70,10 +71,7 @@ export default function TabLayout() {
|
|||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/house.fill.png")
|
? (_e) => require("@/assets/icons/house.fill.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "house.fill" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "house.fill" }
|
|
||||||
: { sfSymbol: "house" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -84,14 +82,12 @@ export default function TabLayout() {
|
|||||||
})}
|
})}
|
||||||
name='(search)'
|
name='(search)'
|
||||||
options={{
|
options={{
|
||||||
|
role: "search",
|
||||||
title: t("tabs.search"),
|
title: t("tabs.search"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "magnifyingglass" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "magnifyingglass" }
|
|
||||||
: { sfSymbol: "magnifyingglass" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -100,14 +96,8 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.favorites"),
|
title: t("tabs.favorites"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) =>
|
? (_e) => require("@/assets/icons/heart.fill.png")
|
||||||
focused
|
: (_e) => ({ sfSymbol: "heart.fill" }),
|
||||||
? require("@/assets/icons/heart.fill.png")
|
|
||||||
: require("@/assets/icons/heart.png")
|
|
||||||
: ({ focused }) =>
|
|
||||||
focused
|
|
||||||
? { sfSymbol: "heart.fill" }
|
|
||||||
: { sfSymbol: "heart" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -117,10 +107,7 @@ export default function TabLayout() {
|
|||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/server.rack.png")
|
? (_e) => require("@/assets/icons/server.rack.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "rectangle.stack.fill" }
|
|
||||||
: { sfSymbol: "rectangle.stack" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -131,10 +118,7 @@ export default function TabLayout() {
|
|||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "list.dash.fill" }
|
|
||||||
: { sfSymbol: "list.dash" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
VLCColor,
|
VLCColor,
|
||||||
} from "@/constants/SubtitleConstants";
|
} from "@/constants/SubtitleConstants";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
@@ -76,7 +77,10 @@ export default function page() {
|
|||||||
: require("react-native-volume-manager");
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
const downloadUtils = useDownload();
|
const downloadUtils = useDownload();
|
||||||
const downloadedFiles = downloadUtils.getDownloadedItems();
|
const downloadedFiles = useMemo(
|
||||||
|
() => downloadUtils.getDownloadedItems(),
|
||||||
|
[downloadUtils.getDownloadedItems],
|
||||||
|
);
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
@@ -106,6 +110,7 @@ export default function page() {
|
|||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
const playbackManager = usePlaybackManager();
|
const playbackManager = usePlaybackManager();
|
||||||
@@ -168,6 +173,16 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [itemId, offline, api, user?.Id]);
|
}, [itemId, offline, api, user?.Id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlockOrientation();
|
||||||
|
};
|
||||||
|
}, [settings?.defaultVideoOrientation]);
|
||||||
|
|
||||||
interface Stream {
|
interface Stream {
|
||||||
mediaSource: MediaSourceInfo;
|
mediaSource: MediaSourceInfo;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -283,12 +298,14 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
|
if (!item?.Id || !stream?.sessionId) return;
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.get());
|
const currentTimeInTicks = msToTicks(progress.get());
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
itemId: item?.Id!,
|
itemId: item.Id,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
positionTicks: currentTimeInTicks,
|
positionTicks: currentTimeInTicks,
|
||||||
playSessionId: stream?.sessionId!,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
api,
|
api,
|
||||||
@@ -319,9 +336,9 @@ export default function page() {
|
|||||||
}, [navigation, stop]);
|
}, [navigation, stop]);
|
||||||
|
|
||||||
const currentPlayStateInfo = useCallback(() => {
|
const currentPlayStateInfo = useCallback(() => {
|
||||||
if (!stream) return;
|
if (!stream || !item?.Id) return;
|
||||||
return {
|
return {
|
||||||
itemId: item?.Id!,
|
itemId: item.Id,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
@@ -765,6 +782,7 @@ export default function page() {
|
|||||||
setAspectRatio={setAspectRatio}
|
setAspectRatio={setAspectRatio}
|
||||||
setScaleFactor={setScaleFactor}
|
setScaleFactor={setScaleFactor}
|
||||||
isVlc
|
isVlc
|
||||||
|
api={api}
|
||||||
downloadedFiles={downloadedFiles}
|
downloadedFiles={downloadedFiles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
248
app/_layout.tsx
248
app/_layout.tsx
@@ -1,18 +1,23 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import * as BackgroundTask from "expo-background-task";
|
||||||
|
import * as Device from "expo-device";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
|
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||||
import {
|
import {
|
||||||
apiAtom,
|
apiAtom,
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
getTokenFromStorage,
|
|
||||||
JellyfinProvider,
|
JellyfinProvider,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
BACKGROUND_FETCH_TASK_SESSIONS,
|
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||||
@@ -26,44 +31,30 @@ import {
|
|||||||
} from "@/utils/log";
|
} from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
const BackGroundDownloader = !Platform.isTV
|
|
||||||
? require("@kesha-antonov/react-native-background-downloader")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import * as BackgroundTask from "expo-background-task";
|
|
||||||
|
|
||||||
import * as Device from "expo-device";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
|
|
||||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
|
|
||||||
import { getLocales } from "expo-localization";
|
|
||||||
import { router, Stack, useSegments } from "expo-router";
|
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
|
||||||
|
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { I18nextProvider } from "react-i18next";
|
|
||||||
import { Appearance, AppState } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import "react-native-reanimated";
|
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import { getLocales } from "expo-localization";
|
||||||
import type { EventSubscription } from "expo-modules-core";
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
|
import { getDevicePushTokenAsync } from "expo-notifications";
|
||||||
import type {
|
import type {
|
||||||
Notification,
|
Notification,
|
||||||
NotificationResponse,
|
NotificationResponse,
|
||||||
} from "expo-notifications/build/Notifications.types";
|
} from "expo-notifications/build/Notifications.types";
|
||||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
import type { DevicePushToken } from "expo-notifications/build/Tokens.types";
|
||||||
import { useAtom } from "jotai";
|
import { router, Stack, useSegments } from "expo-router";
|
||||||
import { Toaster } from "sonner-native";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import * as TaskManager from "expo-task-manager";
|
||||||
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
import { Appearance } from "react-native";
|
||||||
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
|
import "react-native-reanimated";
|
||||||
|
import { Toaster } from "sonner-native";
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
@@ -131,24 +122,7 @@ if (!Platform.isTV) {
|
|||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
console.log("TaskManager ~ trigger");
|
console.log("TaskManager ~ trigger");
|
||||||
|
// Background fetch task placeholder - currently unused
|
||||||
const settingsData = storage.getString("settings");
|
|
||||||
|
|
||||||
if (!settingsData) return BackgroundTask.BackgroundTaskResult.Failed;
|
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
|
||||||
|
|
||||||
if (!settings?.autoDownload)
|
|
||||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
|
||||||
const deviceId = getOrSetDeviceId();
|
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
|
||||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
|
||||||
return BackgroundTask.BackgroundTaskResult.Success;
|
return BackgroundTask.BackgroundTaskResult.Success;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -213,11 +187,7 @@ export default function RootLayout() {
|
|||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 0,
|
staleTime: 30000,
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
retryOnMount: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -226,8 +196,7 @@ function Layout() {
|
|||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const appState = useRef(AppState.currentState);
|
const _segments = useSegments();
|
||||||
const segments = useSegments();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
@@ -237,26 +206,26 @@ function Layout() {
|
|||||||
|
|
||||||
useNotificationObserver();
|
useNotificationObserver();
|
||||||
|
|
||||||
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
const [pushToken, setPushToken] = useState<DevicePushToken>();
|
||||||
const notificationListener = useRef<EventSubscription>(null);
|
const notificationListener = useRef<EventSubscription>(null);
|
||||||
const responseListener = useRef<EventSubscription>(null);
|
const responseListener = useRef<EventSubscription>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV && expoPushToken && api && user) {
|
if (!Platform.isTV && pushToken && api && user) {
|
||||||
api
|
api
|
||||||
?.post("/Streamyfin/device", {
|
?.post("/Streamyfin/device", {
|
||||||
token: expoPushToken.data,
|
token: pushToken.data,
|
||||||
deviceId: getOrSetDeviceId(),
|
deviceId: getOrSetDeviceId(),
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
})
|
})
|
||||||
.then((_) => console.log("Posted expo push token"))
|
.then((_) => console.log("Posted device push token"))
|
||||||
.catch((_) =>
|
.catch((_) =>
|
||||||
writeErrorLog("Failed to push expo push token to plugin"),
|
writeErrorLog("Failed to push device push token to plugin"),
|
||||||
);
|
);
|
||||||
} else console.log("No token available");
|
} else console.log("No token available");
|
||||||
}, [api, expoPushToken, user]);
|
}, [api, pushToken, user]);
|
||||||
|
|
||||||
async function registerNotifications() {
|
const registerNotifications = useCallback(async () => {
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
console.log("Setting android notification channel 'default'");
|
console.log("Setting android notification channel 'default'");
|
||||||
await Notifications?.setNotificationChannelAsync("default", {
|
await Notifications?.setNotificationChannelAsync("default", {
|
||||||
@@ -287,13 +256,11 @@ function Layout() {
|
|||||||
|
|
||||||
// only create push token for real devices (pointless for emulators)
|
// only create push token for real devices (pointless for emulators)
|
||||||
if (Device.isDevice) {
|
if (Device.isDevice) {
|
||||||
Notifications?.getExpoPushTokenAsync({
|
getDevicePushTokenAsync()
|
||||||
projectId: "streamyfin-4fec1",
|
.then((token: DevicePushToken) => token && setPushToken(token))
|
||||||
})
|
|
||||||
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
|
||||||
.catch((reason: any) => console.log("Failed to get token", reason));
|
.catch((reason: any) => console.log("Failed to get token", reason));
|
||||||
}
|
}
|
||||||
}
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
@@ -357,61 +324,7 @@ function Layout() {
|
|||||||
responseListener.current?.remove();
|
responseListener.current?.remove();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [user, api]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segments.includes("direct-player" as never)) {
|
|
||||||
if (
|
|
||||||
!settings.followDeviceOrientation &&
|
|
||||||
settings.defaultVideoOrientation
|
|
||||||
) {
|
|
||||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.followDeviceOrientation === true) {
|
|
||||||
ScreenOrientation.unlockAsync();
|
|
||||||
} else {
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
settings.followDeviceOrientation,
|
|
||||||
settings.defaultVideoOrientation,
|
|
||||||
segments,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
|
||||||
if (
|
|
||||||
appState.current.match(/inactive|background/) &&
|
|
||||||
nextAppState === "active"
|
|
||||||
) {
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -420,52 +333,55 @@ function Layout() {
|
|||||||
<LogProvider>
|
<LogProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<BottomSheetModalProvider>
|
<GlobalModalProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<BottomSheetModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<SystemBars style='light' hidden={false} />
|
||||||
<Stack.Screen
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
name='(auth)/(tabs)'
|
<Stack.Screen
|
||||||
options={{
|
name='(auth)/(tabs)'
|
||||||
headerShown: false,
|
options={{
|
||||||
title: "",
|
headerShown: false,
|
||||||
header: () => null,
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/player'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='login'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name='+not-found' />
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
|
closeButton
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<GlobalModal />
|
||||||
name='(auth)/player'
|
</ThemeProvider>
|
||||||
options={{
|
</BottomSheetModalProvider>
|
||||||
headerShown: false,
|
</GlobalModalProvider>
|
||||||
title: "",
|
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
</ThemeProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</DownloadProvider>
|
</DownloadProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</LogProvider>
|
</LogProvider>
|
||||||
|
|||||||
@@ -4,17 +4,16 @@ import { Image } from "expo-image";
|
|||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
@@ -63,12 +62,13 @@ const Login: React.FC = () => {
|
|||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for server setup and state updates to complete
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (_username && _password) {
|
if (_username && _password) {
|
||||||
setCredentials({ username: _username, password: _password });
|
setCredentials({ username: _username, password: _password });
|
||||||
login(_username, _password);
|
login(_username, _password);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 0);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
@@ -82,10 +82,10 @@ const Login: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
}}
|
}}
|
||||||
className='flex flex-row items-center'
|
className='flex flex-row items-center pr-2 pl-1'
|
||||||
>
|
>
|
||||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||||
<Text className='ml-2 text-purple-600'>
|
<Text className=' ml-1 text-purple-600'>
|
||||||
{t("login.change_server")}
|
{t("login.change_server")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -371,10 +371,11 @@ const Login: React.FC = () => {
|
|||||||
// Mobile layout
|
// Mobile layout
|
||||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
{api?.basePath ? (
|
{api?.basePath ? (
|
||||||
<View className='flex flex-col h-full relative items-center justify-center'>
|
<View className='flex flex-col flex-1 items-center justify-center'>
|
||||||
<View className='px-4 -mt-20 w-full'>
|
<View className='px-4 -mt-20 w-full'>
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<Text className='text-2xl font-bold -mb-2'>
|
<Text className='text-2xl font-bold -mb-2'>
|
||||||
@@ -443,7 +444,7 @@ const Login: React.FC = () => {
|
|||||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col h-full items-center justify-center w-full'>
|
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
||||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||||
<Image
|
<Image
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MMKV } from "react-native-mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
declare module "react-native-mmkv" {
|
declare module "react-native-mmkv" {
|
||||||
interface MMKV {
|
interface MMKV {
|
||||||
@@ -9,7 +9,7 @@ declare module "react-native-mmkv" {
|
|||||||
|
|
||||||
// Add the augmentation methods directly to the MMKV prototype
|
// Add the augmentation methods directly to the MMKV prototype
|
||||||
// This follows the recommended pattern while adding the helper methods your app uses
|
// This follows the recommended pattern while adding the helper methods your app uses
|
||||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
(storage as any).get = function <T>(key: string): T | undefined {
|
||||||
try {
|
try {
|
||||||
const serializedItem = this.getString(key);
|
const serializedItem = this.getString(key);
|
||||||
if (!serializedItem) return undefined;
|
if (!serializedItem) return undefined;
|
||||||
@@ -20,10 +20,10 @@ MMKV.prototype.get = function <T>(key: string): T | undefined {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
(storage as any).setAny = function (key: string, value: any | undefined): void {
|
||||||
try {
|
try {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
this.delete(key);
|
this.remove(key);
|
||||||
} else {
|
} else {
|
||||||
this.set(key, JSON.stringify(value));
|
this.set(key, JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ module.exports = (api) => {
|
|||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ["babel-preset-expo"],
|
presets: ["babel-preset-expo"],
|
||||||
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
plugins: ["nativewind/babel", "react-native-worklets/plugin"],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { RoundButton } from "@/components/RoundButton";
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
|
||||||
@@ -11,24 +11,11 @@ interface Props extends ViewProps {
|
|||||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton
|
|
||||||
size='large'
|
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
|
||||||
onPress={toggleFavorite}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size='large'
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
icon={isFavorite ? "heart" : "heart-outline"}
|
||||||
fillColor={isFavorite ? "primary" : undefined}
|
|
||||||
onPress={toggleFavorite}
|
onPress={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -20,6 +18,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
@@ -31,55 +31,58 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
[audioStreams, selected],
|
[audioStreams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
audioStreams?.map((audio, idx) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: audio.DisplayTitle || `Audio Stream ${idx + 1}`,
|
||||||
|
value: audio.Index ?? idx,
|
||||||
|
selected: audio.Index === selected,
|
||||||
|
onPress: () => {
|
||||||
|
if (audio.Index !== null && audio.Index !== undefined) {
|
||||||
|
onChange(audio.Index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[audioStreams, selected, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionSelect = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.audio")}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>{selectedAudioSteam?.DisplayTitle}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 50,
|
title={t("item_card.audio")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<View className='flex flex-col' {...props}>
|
/>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.audio")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
|
||||||
<Text className='' numberOfLines={1}>
|
|
||||||
{selectedAudioSteam?.DisplayTitle}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
|
|
||||||
{audioStreams?.map((audio, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
if (audio.Index !== null && audio.Index !== undefined)
|
|
||||||
onChange(audio.Index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{audio.DisplayTitle}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -61,6 +59,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
@@ -76,53 +76,59 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
}, [inverted]);
|
}, [inverted]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: sorted.map((bitrate) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
|
selected: bitrate.value === selected?.value,
|
||||||
|
onPress: () => onChange(bitrate),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[sorted, selected, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionSelect = (optionId: string) => {
|
||||||
|
const selectedBitrate = sorted.find((b) => b.key === optionId);
|
||||||
|
if (selectedBitrate) {
|
||||||
|
onChange(selectedBitrate);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.quality")}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 60,
|
title={t("item_card.quality")}
|
||||||
maxWidth: 200,
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<View className='flex flex-col' {...props}>
|
/>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.quality")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={false}
|
|
||||||
side='bottom'
|
|
||||||
align='center'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
|
||||||
{sorted.map((b) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={b.key}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(b);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,9 +64,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||||
|
|
||||||
const { processes, startBackgroundDownload, getDownloadedItems } =
|
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
|
||||||
useDownload();
|
const downloadedFiles = downloadedItems;
|
||||||
const downloadedFiles = getDownloadedItems();
|
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
@@ -90,11 +89,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
const handleSheetChanges = useCallback((_index: number) => {
|
||||||
// Ensure modal is fully dismissed when index is -1
|
// Modal state tracking handled by BottomSheetModal
|
||||||
if (index === -1) {
|
|
||||||
// Modal is fully closed
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
@@ -157,6 +153,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
|
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
|
||||||
);
|
);
|
||||||
}, [queue, itemsNotDownloaded]);
|
}, [queue, itemsNotDownloaded]);
|
||||||
|
|
||||||
|
const itemsInProgressOrQueued = useMemo(() => {
|
||||||
|
const inProgress = itemsProcesses.length;
|
||||||
|
const inQueue = queue.filter((q) => itemIds.includes(q.item.Id)).length;
|
||||||
|
return inProgress + inQueue;
|
||||||
|
}, [itemsProcesses, queue, itemIds]);
|
||||||
|
|
||||||
const navigateToDownloads = () => router.push("/downloads");
|
const navigateToDownloads = () => router.push("/downloads");
|
||||||
|
|
||||||
const onDownloadedPress = () => {
|
const onDownloadedPress = () => {
|
||||||
@@ -256,13 +259,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
throw new Error("No item id");
|
throw new Error("No item id");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure modal is dismissed before starting download
|
closeModal();
|
||||||
await closeModal();
|
|
||||||
|
|
||||||
// Small delay to ensure modal is fully dismissed
|
// Wait for modal dismiss animation to complete
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
initiateDownload(...itemsToDownload);
|
initiateDownload(...itemsToDownload);
|
||||||
}, 100);
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||||
@@ -282,7 +284,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderButtonContent = () => {
|
const renderButtonContent = () => {
|
||||||
if (processes.length > 0 && itemsProcesses.length > 0) {
|
// 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
|
||||||
|
const shouldShowProgress =
|
||||||
|
itemIds.length === 1
|
||||||
|
? itemsProcesses.length > 0
|
||||||
|
: itemsInProgressOrQueued > 1;
|
||||||
|
|
||||||
|
if (processes.length > 0 && shouldShowProgress) {
|
||||||
return progress === 0 ? (
|
return progress === 0 ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
@@ -336,9 +345,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
backgroundColor: "#171717",
|
backgroundColor: "#171717",
|
||||||
}}
|
}}
|
||||||
onChange={handleSheetChanges}
|
onChange={handleSheetChanges}
|
||||||
onDismiss={() => {
|
|
||||||
// Ensure any pending state is cleared when modal is dismissed
|
|
||||||
}}
|
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
enableDismissOnClose
|
enableDismissOnClose
|
||||||
@@ -359,16 +365,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 w-full items-start'>
|
<View className='flex flex-col space-y-2 w-full'>
|
||||||
<BitrateSelector
|
<View className='items-start'>
|
||||||
inverted
|
<BitrateSelector
|
||||||
onChange={(val) =>
|
inverted
|
||||||
setSelectedOptions(
|
onChange={(val) =>
|
||||||
(prev) => prev && { ...prev, bitrate: val },
|
setSelectedOptions(
|
||||||
)
|
(prev) => prev && { ...prev, bitrate: val },
|
||||||
}
|
)
|
||||||
selected={selectedOptions?.bitrate}
|
}
|
||||||
/>
|
selected={selectedOptions?.bitrate}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
{itemsNotDownloaded.length > 1 && (
|
{itemsNotDownloaded.length > 1 && (
|
||||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||||
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
||||||
@@ -380,21 +388,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
{itemsNotDownloaded.length === 1 && (
|
{itemsNotDownloaded.length === 1 && (
|
||||||
<View>
|
<View>
|
||||||
<MediaSourceSelector
|
<View className='items-start'>
|
||||||
item={items[0]}
|
<MediaSourceSelector
|
||||||
onChange={(val) =>
|
item={items[0]}
|
||||||
setSelectedOptions(
|
onChange={(val) =>
|
||||||
(prev) =>
|
setSelectedOptions(
|
||||||
prev && {
|
(prev) =>
|
||||||
...prev,
|
prev && {
|
||||||
mediaSource: val,
|
...prev,
|
||||||
},
|
mediaSource: val,
|
||||||
)
|
},
|
||||||
}
|
)
|
||||||
selected={selectedOptions?.mediaSource}
|
}
|
||||||
/>
|
selected={selectedOptions?.mediaSource}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
{selectedOptions?.mediaSource && (
|
{selectedOptions?.mediaSource && (
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
@@ -427,11 +437,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button onPress={acceptDownloadOptions} color='purple'>
|
||||||
className='mt-auto'
|
|
||||||
onPress={acceptDownloadOptions}
|
|
||||||
color='purple'
|
|
||||||
>
|
|
||||||
{t("item_card.download.download_button")}
|
{t("item_card.download.download_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
203
components/ExampleGlobalModalUsage.tsx
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
@@ -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(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This is a simple modal with just some text content.
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400'>
|
||||||
|
Swipe down or tap outside to close.
|
||||||
|
</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 2: Modal with Custom Snap Points
|
||||||
|
*/
|
||||||
|
export const CustomSnapPointsExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6' style={{ minHeight: 400 }}>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Custom Snap Points
|
||||||
|
</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This modal has custom snap points (25%, 50%, 90%).
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||||
|
<Text className='text-white'>
|
||||||
|
Try dragging the modal to different heights!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"],
|
||||||
|
enableDynamicSizing: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||||
|
|
||||||
|
{settings.map((setting, index) => (
|
||||||
|
<View
|
||||||
|
key={setting.id}
|
||||||
|
className={`flex-row items-center justify-between py-4 ${
|
||||||
|
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center gap-3'>
|
||||||
|
<Ionicons name={setting.icon} size={24} color='white' />
|
||||||
|
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${
|
||||||
|
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||||
|
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={hideModal}
|
||||||
|
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComplexModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(<SettingsModalContent />);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||||
|
*/
|
||||||
|
export const useShowSuccessModal = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (message: string) => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6 items-center'>
|
||||||
|
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||||
|
<Ionicons name='checkmark' size={48} color='white' />
|
||||||
|
</View>
|
||||||
|
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||||
|
<Text className='text-white text-center'>{message}</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Demo Component
|
||||||
|
*/
|
||||||
|
export const GlobalModalDemo = () => {
|
||||||
|
const showSuccess = useShowSuccessModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6 gap-4'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Global Modal Examples
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleModalExample />
|
||||||
|
<CustomSnapPointsExample />
|
||||||
|
<ComplexModalExample />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => showSuccess("Operation completed successfully!")}
|
||||||
|
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
73
components/GlobalModal.tsx
Normal file
73
components/GlobalModal.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
} 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) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
enableDynamicSizing: true,
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
backgroundStyle: {
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
},
|
||||||
|
handleIndicatorStyle: {
|
||||||
|
backgroundColor: "white",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge default options with provided options
|
||||||
|
const modalOptions = { ...defaultOptions, ...modalState.options };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={modalRef}
|
||||||
|
{...(modalOptions.snapPoints
|
||||||
|
? { 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}
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -204,7 +204,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-2' />
|
<ItemHeader item={item} className='mb-2' />
|
||||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
|
||||||
<BitrateSheet
|
<BitrateSheet
|
||||||
className='mr-1'
|
className='mr-1'
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -23,7 +21,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
@@ -46,50 +44,60 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
return getDisplayName(selected);
|
return getDisplayName(selected);
|
||||||
}, [selected, getDisplayName]);
|
}, [selected, getDisplayName]);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
item.MediaSources?.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selected?.Id,
|
||||||
|
onPress: () => onChange(source),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[item.MediaSources, selected, getDisplayName, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionSelect = (optionId: string) => {
|
||||||
|
const selectedSource = item.MediaSources?.find(
|
||||||
|
(source, idx) => `${source.Id || idx}` === optionId,
|
||||||
|
);
|
||||||
|
if (selectedSource) {
|
||||||
|
onChange(selectedSource);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 50,
|
title={t("item_card.video")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<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'>
|
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
|
||||||
{item.MediaSources?.map((source, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(source);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{getDisplayName(source)}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
style={{
|
style={{
|
||||||
top: -50,
|
top: -50,
|
||||||
}}
|
}}
|
||||||
className='relative flex-1 bg-transparent pb-24'
|
className='relative flex-1 bg-transparent pb-4'
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
// Background Linear Gradient
|
// Background Linear Gradient
|
||||||
|
|||||||
337
components/PlatformDropdown.tsx
Normal file
337
components/PlatformDropdown.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
// Option types
|
||||||
|
export type RadioOption<T = any> = {
|
||||||
|
type: "radio";
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
selected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToggleOption = {
|
||||||
|
type: "toggle";
|
||||||
|
label: string;
|
||||||
|
value: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Option = RadioOption | ToggleOption;
|
||||||
|
|
||||||
|
// Option group structure
|
||||||
|
export type OptionGroup = {
|
||||||
|
title?: string;
|
||||||
|
options: Option[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlatformDropdownProps {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
groups: OptionGroup[];
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
onOptionSelect?: (value?: any) => void;
|
||||||
|
expoUIConfig?: {
|
||||||
|
hostStyle?: any;
|
||||||
|
};
|
||||||
|
bottomSheetConfig?: {
|
||||||
|
enableDynamicSizing?: boolean;
|
||||||
|
enablePanDownToClose?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
|
||||||
|
value ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||||
|
option,
|
||||||
|
isLast,
|
||||||
|
}) => {
|
||||||
|
const isToggle = option.type === "toggle";
|
||||||
|
const handlePress = isToggle ? option.onToggle : option.onPress;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={option.disabled}
|
||||||
|
className={`px-4 py-3 flex flex-row items-center justify-between ${
|
||||||
|
option.disabled ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||||
|
{isToggle ? (
|
||||||
|
<ToggleSwitch value={option.value} />
|
||||||
|
) : option.selected ? (
|
||||||
|
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
{!isLast && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
}}
|
||||||
|
className='bg-neutral-700 mx-4'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
||||||
|
<View className='mb-6'>
|
||||||
|
{group.title && (
|
||||||
|
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
|
||||||
|
{group.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
className='bg-neutral-800 rounded-xl overflow-hidden'
|
||||||
|
>
|
||||||
|
{group.options.map((option, index) => (
|
||||||
|
<OptionItem
|
||||||
|
key={index}
|
||||||
|
option={option}
|
||||||
|
isLast={index === group.options.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BottomSheetContent: React.FC<{
|
||||||
|
title?: string;
|
||||||
|
groups: OptionGroup[];
|
||||||
|
onOptionSelect?: (value?: any) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}> = ({ title, groups, onOptionSelect, onClose }) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
// Wrap the groups to call onOptionSelect when an option is pressed
|
||||||
|
const wrappedGroups = groups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
options: group.options.map((option) => {
|
||||||
|
if (option.type === "radio") {
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
onPress: () => {
|
||||||
|
option.onPress();
|
||||||
|
onOptionSelect?.(option.value);
|
||||||
|
onClose?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (option.type === "toggle") {
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
onToggle: () => {
|
||||||
|
option.onToggle();
|
||||||
|
onOptionSelect?.(option.value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return option;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetScrollView
|
||||||
|
className='px-4 pb-8 pt-2'
|
||||||
|
style={{
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
|
||||||
|
{wrappedGroups.map((group, index) => (
|
||||||
|
<OptionGroupComponent key={index} group={group} />
|
||||||
|
))}
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlatformDropdownComponent = ({
|
||||||
|
trigger,
|
||||||
|
title,
|
||||||
|
groups,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange: controlledOnOpenChange,
|
||||||
|
onOptionSelect,
|
||||||
|
expoUIConfig,
|
||||||
|
bottomSheetConfig,
|
||||||
|
}: PlatformDropdownProps) => {
|
||||||
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
// Use internal state if not controlled externally
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const open = controlledOpen ?? internalOpen;
|
||||||
|
const onOpenChange = controlledOnOpenChange ?? setInternalOpen;
|
||||||
|
|
||||||
|
// Handle open/close state changes for Android
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android" && open === true) {
|
||||||
|
showModal(
|
||||||
|
<BottomSheetContent
|
||||||
|
title={title}
|
||||||
|
groups={groups}
|
||||||
|
onOptionSelect={onOptionSelect}
|
||||||
|
onClose={() => {
|
||||||
|
hideModal();
|
||||||
|
onOpenChange?.(false);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
snapPoints: ["90%"],
|
||||||
|
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
groups,
|
||||||
|
onOptionSelect,
|
||||||
|
onOpenChange,
|
||||||
|
bottomSheetConfig,
|
||||||
|
showModal,
|
||||||
|
hideModal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<Host style={expoUIConfig?.hostStyle}>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenu.Trigger>
|
||||||
|
<View className=''>
|
||||||
|
{trigger || <Button variant='bordered'>Show Menu</Button>}
|
||||||
|
</View>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Items>
|
||||||
|
{groups.flatMap((group, groupIndex) => {
|
||||||
|
// Check if this group has radio options
|
||||||
|
const radioOptions = group.options.filter(
|
||||||
|
(opt) => opt.type === "radio",
|
||||||
|
) as RadioOption[];
|
||||||
|
const toggleOptions = group.options.filter(
|
||||||
|
(opt) => opt.type === "toggle",
|
||||||
|
) as ToggleOption[];
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
// Add Picker for radio options ONLY if there's a group title
|
||||||
|
// Otherwise render as individual buttons
|
||||||
|
if (radioOptions.length > 0) {
|
||||||
|
if (group.title) {
|
||||||
|
// Use Picker for grouped options
|
||||||
|
items.push(
|
||||||
|
<Picker
|
||||||
|
key={`picker-${groupIndex}`}
|
||||||
|
label={group.title}
|
||||||
|
options={radioOptions.map((opt) => opt.label)}
|
||||||
|
variant='menu'
|
||||||
|
selectedIndex={radioOptions.findIndex(
|
||||||
|
(opt) => opt.selected,
|
||||||
|
)}
|
||||||
|
onOptionSelected={(event: any) => {
|
||||||
|
const index = event.nativeEvent.index;
|
||||||
|
const selectedOption = radioOptions[index];
|
||||||
|
selectedOption?.onPress();
|
||||||
|
onOptionSelect?.(selectedOption?.value);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Render radio options as direct buttons
|
||||||
|
radioOptions.forEach((option, optionIndex) => {
|
||||||
|
items.push(
|
||||||
|
<Button
|
||||||
|
key={`radio-${groupIndex}-${optionIndex}`}
|
||||||
|
systemImage={
|
||||||
|
option.selected ? "checkmark.circle.fill" : "circle"
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
option.onPress();
|
||||||
|
onOptionSelect?.(option.value);
|
||||||
|
}}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Buttons for toggle options
|
||||||
|
toggleOptions.forEach((option, optionIndex) => {
|
||||||
|
items.push(
|
||||||
|
<Button
|
||||||
|
key={`toggle-${groupIndex}-${optionIndex}`}
|
||||||
|
systemImage={
|
||||||
|
option.value ? "checkmark.circle.fill" : "circle"
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
option.onToggle();
|
||||||
|
onOptionSelect?.(option.value);
|
||||||
|
}}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
})}
|
||||||
|
</ContextMenu.Items>
|
||||||
|
</ContextMenu>
|
||||||
|
</Host>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android: Wrap trigger in TouchableOpacity to handle press events
|
||||||
|
// The useEffect above watches for open state changes and shows/hides the modal
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={() => onOpenChange(true)} activeOpacity={0.7}>
|
||||||
|
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoize to prevent unnecessary re-renders when parent re-renders
|
||||||
|
export const PlatformDropdown = React.memo(
|
||||||
|
PlatformDropdownComponent,
|
||||||
|
(prevProps, nextProps) => {
|
||||||
|
// Custom comparison - only re-render if these props actually change
|
||||||
|
return (
|
||||||
|
prevProps.title === nextProps.title &&
|
||||||
|
prevProps.open === nextProps.open &&
|
||||||
|
prevProps.groups === nextProps.groups && // Reference equality (works because we memoize groups in caller)
|
||||||
|
prevProps.trigger === nextProps.trigger // Reference equality
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
|
import { Button, Host } from "@expo/ui/swift-ui";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
@@ -33,10 +34,9 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|||||||
import { chromecast } from "@/utils/profiles/chromecast";
|
import { chromecast } from "@/utils/profiles/chromecast";
|
||||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
@@ -165,7 +165,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
@@ -364,6 +364,46 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
* *********************
|
* *********************
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
if (Platform.OS === "ios")
|
||||||
|
return (
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
height: 50,
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant='glassProminent'
|
||||||
|
onPress={onPress}
|
||||||
|
color={effectiveColors.primary}
|
||||||
|
>
|
||||||
|
<View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
|
||||||
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name='play-circle' size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{client && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Feather name='cast' size={22} />
|
||||||
|
<CastButton tintColor='transparent' />
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
|
{!client && settings?.openInVLC && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='vlc'
|
||||||
|
size={18}
|
||||||
|
color={animatedTextStyle.color}
|
||||||
|
/>
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</Host>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
disabled={!item}
|
||||||
@@ -371,7 +411,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative"}
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
@@ -14,25 +14,10 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
|||||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||||
const toggle = useMarkAsPlayed(items);
|
const toggle = useMarkAsPlayed(items);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton
|
|
||||||
color={allPlayed ? "purple" : "white"}
|
|
||||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
|
||||||
onPress={async () => {
|
|
||||||
await toggle(!allPlayed);
|
|
||||||
}}
|
|
||||||
size={props.size}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
fillColor={allPlayed ? "primary" : undefined}
|
color={allPlayed ? "purple" : "white"}
|
||||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await toggle(!allPlayed);
|
await toggle(!allPlayed);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||||
fillColor ? fillColorClass : "bg-neutral-800/80"
|
fillColor ? fillColorClass : "bg-transparent"
|
||||||
}`}
|
}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -21,6 +19,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
}, [source]);
|
}, [source]);
|
||||||
@@ -30,64 +30,83 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
[subtitleStreams, selected],
|
[subtitleStreams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("item_card.none"),
|
||||||
|
value: -1,
|
||||||
|
selected: selected === -1,
|
||||||
|
onPress: () => onChange(-1),
|
||||||
|
},
|
||||||
|
...(subtitleStreams?.map((subtitle, idx) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`,
|
||||||
|
value: subtitle.Index,
|
||||||
|
selected: subtitle.Index === selected,
|
||||||
|
onPress: () => onChange(subtitle.Index ?? -1),
|
||||||
|
})) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [subtitleStreams, selected, t, onChange]);
|
||||||
|
|
||||||
|
const handleOptionSelect = (optionId: string) => {
|
||||||
|
if (optionId === "none") {
|
||||||
|
onChange(-1);
|
||||||
|
} else {
|
||||||
|
const selectedStream = subtitleStreams?.find(
|
||||||
|
(subtitle, idx) => `${subtitle.Index || idx}` === optionId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
selectedStream &&
|
||||||
|
selectedStream.Index !== undefined &&
|
||||||
|
selectedStream.Index !== null
|
||||||
|
) {
|
||||||
|
onChange(selectedStream.Index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
|
||||||
|
{t("item_card.subtitles")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
{selectedSubtitleSteam
|
||||||
|
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||||
|
: t("item_card.none")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex col shrink justify-start place-self-start items-start'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 60,
|
title={t("item_card.subtitles")}
|
||||||
maxWidth: 200,
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<View className='flex flex-col ' {...props}>
|
/>
|
||||||
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.subtitles")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
|
||||||
<Text className=' '>
|
|
||||||
{selectedSubtitleSteam
|
|
||||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
|
||||||
: t("item_card.none")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"-1"}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(-1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
if (subtitle.Index !== undefined && subtitle.Index !== null)
|
|
||||||
onChange(subtitle.Index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{subtitle.DisplayTitle}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,15 +28,16 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { ItemImage } from "./common/ItemImage";
|
import { ItemImage } from "../common/ItemImage";
|
||||||
import { getItemNavigation } from "./common/TouchableItemRouter";
|
import { getItemNavigation } from "../common/TouchableItemRouter";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "../ItemContent";
|
||||||
import { PlayButton } from "./PlayButton";
|
import { PlayButton } from "../PlayButton";
|
||||||
import { PlayedStatus } from "./PlayedStatus";
|
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
||||||
|
|
||||||
interface AppleTVCarouselProps {
|
interface AppleTVCarouselProps {
|
||||||
initialIndex?: number;
|
initialIndex?: number;
|
||||||
@@ -50,10 +51,11 @@ const GRADIENT_HEIGHT_BOTTOM = 150;
|
|||||||
const LOGO_HEIGHT = 80;
|
const LOGO_HEIGHT = 80;
|
||||||
|
|
||||||
// Position Constants
|
// Position Constants
|
||||||
const LOGO_BOTTOM_POSITION = 210;
|
const LOGO_BOTTOM_POSITION = 260;
|
||||||
const GENRES_BOTTOM_POSITION = 170;
|
const GENRES_BOTTOM_POSITION = 220;
|
||||||
const CONTROLS_BOTTOM_POSITION = 100;
|
const OVERVIEW_BOTTOM_POSITION = 165;
|
||||||
const DOTS_BOTTOM_POSITION = 60;
|
const CONTROLS_BOTTOM_POSITION = 80;
|
||||||
|
const DOTS_BOTTOM_POSITION = 40;
|
||||||
|
|
||||||
// Size Constants
|
// Size Constants
|
||||||
const DOT_HEIGHT = 6;
|
const DOT_HEIGHT = 6;
|
||||||
@@ -63,13 +65,15 @@ const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
|||||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
||||||
const TEXT_SKELETON_HEIGHT = 20;
|
const TEXT_SKELETON_HEIGHT = 20;
|
||||||
const TEXT_SKELETON_WIDTH = 250;
|
const TEXT_SKELETON_WIDTH = 250;
|
||||||
|
const OVERVIEW_SKELETON_HEIGHT = 16;
|
||||||
|
const OVERVIEW_SKELETON_WIDTH = 400;
|
||||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
const _EMPTY_STATE_ICON_SIZE = 64;
|
||||||
|
|
||||||
// Spacing Constants
|
// Spacing Constants
|
||||||
const HORIZONTAL_PADDING = 40;
|
const HORIZONTAL_PADDING = 40;
|
||||||
const DOT_PADDING = 2;
|
const DOT_PADDING = 2;
|
||||||
const DOT_GAP = 4;
|
const DOT_GAP = 4;
|
||||||
const CONTROLS_GAP = 20;
|
const CONTROLS_GAP = 10;
|
||||||
const _TEXT_MARGIN_TOP = 16;
|
const _TEXT_MARGIN_TOP = 16;
|
||||||
|
|
||||||
// Border Radius Constants
|
// Border Radius Constants
|
||||||
@@ -88,13 +92,16 @@ const VELOCITY_THRESHOLD = 400;
|
|||||||
|
|
||||||
// Text Constants
|
// Text Constants
|
||||||
const GENRES_FONT_SIZE = 16;
|
const GENRES_FONT_SIZE = 16;
|
||||||
|
const OVERVIEW_FONT_SIZE = 14;
|
||||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
const _EMPTY_STATE_FONT_SIZE = 18;
|
||||||
const TEXT_SHADOW_RADIUS = 2;
|
const TEXT_SHADOW_RADIUS = 2;
|
||||||
const MAX_GENRES_COUNT = 2;
|
const MAX_GENRES_COUNT = 2;
|
||||||
const MAX_BUTTON_WIDTH = 300;
|
const MAX_BUTTON_WIDTH = 300;
|
||||||
|
const OVERVIEW_MAX_LINES = 2;
|
||||||
|
const OVERVIEW_MAX_WIDTH = "80%";
|
||||||
|
|
||||||
// Opacity Constants
|
// Opacity Constants
|
||||||
const OVERLAY_OPACITY = 0.4;
|
const OVERLAY_OPACITY = 0.3;
|
||||||
const DOT_INACTIVE_OPACITY = 0.6;
|
const DOT_INACTIVE_OPACITY = 0.6;
|
||||||
const TEXT_OPACITY = 0.9;
|
const TEXT_OPACITY = 0.9;
|
||||||
|
|
||||||
@@ -180,7 +187,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
fields: ["Genres", "Overview"],
|
||||||
limit: 2,
|
limit: 2,
|
||||||
});
|
});
|
||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
@@ -195,7 +202,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres", "Overview"],
|
||||||
limit: 2,
|
limit: 2,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
@@ -214,7 +221,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
limit: 2,
|
limit: 2,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
});
|
});
|
||||||
@@ -374,6 +381,8 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const togglePlayedStatus = useMarkAsPlayed(items);
|
||||||
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||||
if (!scrollOffset) return {};
|
if (!scrollOffset) return {};
|
||||||
return {
|
return {
|
||||||
@@ -521,6 +530,36 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Overview Skeleton */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: OVERVIEW_BOTTOM_POSITION,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: OVERVIEW_SKELETON_HEIGHT,
|
||||||
|
width: OVERVIEW_SKELETON_WIDTH,
|
||||||
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||||
|
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: OVERVIEW_SKELETON_HEIGHT,
|
||||||
|
width: OVERVIEW_SKELETON_WIDTH * 0.7,
|
||||||
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||||
|
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Controls Skeleton */}
|
{/* Controls Skeleton */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -747,6 +786,39 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Overview Section - for Episodes and Movies */}
|
||||||
|
{(item.Type === "Episode" || item.Type === "Movie") &&
|
||||||
|
item.Overview && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: OVERVIEW_BOTTOM_POSITION,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
||||||
|
<Animated.Text
|
||||||
|
numberOfLines={OVERVIEW_MAX_LINES}
|
||||||
|
style={{
|
||||||
|
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
|
||||||
|
fontSize: OVERVIEW_FONT_SIZE,
|
||||||
|
fontWeight: "400",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: OVERVIEW_MAX_WIDTH,
|
||||||
|
textShadowColor: TEXT_SHADOW_COLOR,
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: TEXT_SHADOW_RADIUS,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.Overview}
|
||||||
|
</Animated.Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Controls Section */}
|
{/* Controls Section */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -777,7 +849,10 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Mark as Played */}
|
{/* Mark as Played */}
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<MarkAsPlayedLargeButton
|
||||||
|
isPlayed={item.UserData?.Played ?? false}
|
||||||
|
onToggle={togglePlayedStatus}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
51
components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
Normal file
51
components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Button, Host } from "@expo/ui/swift-ui";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { RoundButton } from "../RoundButton";
|
||||||
|
|
||||||
|
interface MarkAsPlayedLargeButtonProps {
|
||||||
|
isPlayed: boolean;
|
||||||
|
onToggle: (isPlayed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkAsPlayedLargeButton: React.FC<
|
||||||
|
MarkAsPlayedLargeButtonProps
|
||||||
|
> = ({ isPlayed, onToggle }) => {
|
||||||
|
if (Platform.OS === "ios")
|
||||||
|
return (
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
flex: 0,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
|
||||||
|
<View>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
marginLeft: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</Host>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<RoundButton
|
||||||
|
size='large'
|
||||||
|
icon={isPlayed ? "checkmark" : "checkmark"}
|
||||||
|
onPress={() => onToggle(isPlayed)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import {
|
|
||||||
type PropsWithChildren,
|
|
||||||
type ReactNode,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
|
|
||||||
interface Props<T> {
|
|
||||||
data: T[];
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholderText?: string;
|
|
||||||
keyExtractor: (item: T) => string;
|
|
||||||
titleExtractor: (item: T) => string | undefined;
|
|
||||||
title: string | ReactNode;
|
|
||||||
label: string;
|
|
||||||
onSelected: (...item: T[]) => void;
|
|
||||||
multiple?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Dropdown = <T,>({
|
|
||||||
data,
|
|
||||||
disabled,
|
|
||||||
placeholderText,
|
|
||||||
keyExtractor,
|
|
||||||
titleExtractor,
|
|
||||||
title,
|
|
||||||
label,
|
|
||||||
onSelected,
|
|
||||||
multiple = false,
|
|
||||||
...props
|
|
||||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
|
||||||
const isTv = Platform.isTV;
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState<T[]>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selected !== undefined) {
|
|
||||||
onSelected(...selected);
|
|
||||||
}
|
|
||||||
}, [selected, onSelected]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
{typeof title === "string" ? (
|
|
||||||
<View className='flex flex-col'>
|
|
||||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
|
||||||
{selected?.length !== undefined
|
|
||||||
? selected.map(titleExtractor).join(",")
|
|
||||||
: placeholderText}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
title
|
|
||||||
)}
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={false}
|
|
||||||
side='bottom'
|
|
||||||
align='center'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
|
||||||
{data.map((item, _idx) =>
|
|
||||||
multiple ? (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
value={
|
|
||||||
selected?.some((s) => keyExtractor(s) === keyExtractor(item))
|
|
||||||
? "on"
|
|
||||||
: "off"
|
|
||||||
}
|
|
||||||
key={keyExtractor(item)}
|
|
||||||
onValueChange={(
|
|
||||||
next: "on" | "off",
|
|
||||||
_previous: "on" | "off",
|
|
||||||
) => {
|
|
||||||
setSelected((p) => {
|
|
||||||
const prev = p || [];
|
|
||||||
if (next === "on") {
|
|
||||||
return [...prev, item];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
...prev.filter(
|
|
||||||
(p) => keyExtractor(p) !== keyExtractor(item),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{titleExtractor(item)}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
) : (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={keyExtractor(item)}
|
|
||||||
onSelect={() => setSelected([item])}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{titleExtractor(item)}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Dropdown;
|
|
||||||
@@ -55,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
className=' bg-neutral-800/80 rounded-full p-2'
|
className=' rounded-full p-2'
|
||||||
{...touchableOpacityProps}
|
{...touchableOpacityProps}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -3,17 +3,12 @@ import React, { useImperativeHandle, useRef } from "react";
|
|||||||
import { View, type ViewStyle } from "react-native";
|
import { View, type ViewStyle } from "react-native";
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
|
||||||
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
|
||||||
|
|
||||||
export interface HorizontalScrollRef {
|
export interface HorizontalScrollRef {
|
||||||
scrollToIndex: (index: number, viewOffset: number) => void;
|
scrollToIndex: (index: number, viewOffset: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HorizontalScrollProps<T>
|
interface HorizontalScrollProps<T>
|
||||||
extends PartialExcept<
|
extends Omit<FlashListProps<T>, "renderItem" | "estimatedItemSize" | "data"> {
|
||||||
Omit<FlashListProps<T>, "renderItem">,
|
|
||||||
"estimatedItemSize"
|
|
||||||
> {
|
|
||||||
data?: T[] | null;
|
data?: T[] | null;
|
||||||
renderItem: (item: T, index: number) => React.ReactNode;
|
renderItem: (item: T, index: number) => React.ReactNode;
|
||||||
keyExtractor?: (item: T, index: number) => string;
|
keyExtractor?: (item: T, index: number) => string;
|
||||||
@@ -44,7 +39,7 @@ export const HorizontalScroll = <T,>(
|
|||||||
...restProps
|
...restProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const flashListRef = useRef<FlashList<T>>(null);
|
const flashListRef = useRef<React.ComponentRef<typeof FlashList<T>>>(null);
|
||||||
|
|
||||||
useImperativeHandle(ref!, () => ({
|
useImperativeHandle(ref!, () => ({
|
||||||
scrollToIndex: (index: number, viewOffset: number) => {
|
scrollToIndex: (index: number, viewOffset: number) => {
|
||||||
@@ -78,7 +73,6 @@ export const HorizontalScroll = <T,>(
|
|||||||
extraData={extraData}
|
extraData={extraData}
|
||||||
renderItem={renderFlashListItem}
|
renderItem={renderFlashListItem}
|
||||||
horizontal
|
horizontal
|
||||||
estimatedItemSize={200}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type PropsWithChildren, useCallback, useMemo } from "react";
|
import { type PropsWithChildren } from "react";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import {
|
|
||||||
hasPermission,
|
|
||||||
Permission,
|
|
||||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
import type {
|
import type {
|
||||||
@@ -38,90 +32,33 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
|
||||||
|
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
const autoApprove = useMemo(() => {
|
|
||||||
return (
|
|
||||||
jellyseerrUser &&
|
|
||||||
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
|
|
||||||
type: "or",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [jellyseerrApi, jellyseerrUser]);
|
|
||||||
|
|
||||||
const request = useCallback(() => {
|
|
||||||
if (!result) return;
|
|
||||||
requestMedia(mediaTitle, {
|
|
||||||
mediaId: result.id,
|
|
||||||
mediaType,
|
|
||||||
});
|
|
||||||
}, [jellyseerrApi, result]);
|
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<TouchableOpacity
|
||||||
<ContextMenu.Trigger>
|
onPress={() => {
|
||||||
<TouchableOpacity
|
if (!result) return;
|
||||||
onPress={() => {
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
params: {
|
params: {
|
||||||
...result,
|
...result,
|
||||||
mediaTitle,
|
mediaTitle,
|
||||||
releaseYear,
|
releaseYear,
|
||||||
canRequest: canRequest.toString(),
|
canRequest: canRequest.toString(),
|
||||||
posterSrc,
|
posterSrc,
|
||||||
mediaType,
|
mediaType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content
|
|
||||||
avoidCollisions
|
|
||||||
alignOffset={0}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={false}
|
|
||||||
key={"content"}
|
|
||||||
>
|
|
||||||
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
|
|
||||||
{canRequest && mediaType === MediaType.MOVIE && (
|
|
||||||
<ContextMenu.Item
|
|
||||||
key='item-1'
|
|
||||||
onSelect={() => {
|
|
||||||
if (autoApprove) {
|
|
||||||
request();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
shouldDismissMenuOnSelect
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle key='item-1-title'>
|
|
||||||
Request
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
<ContextMenu.ItemIcon
|
|
||||||
ios={{
|
|
||||||
name: "arrow.down.to.line",
|
|
||||||
pointSize: 18,
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "purple",
|
|
||||||
light: "purple",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
androidIconName='download'
|
|
||||||
/>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
)}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
|||||||
<Text className='text-lg font-bold mb-2'>
|
<Text className='text-lg font-bold mb-2'>
|
||||||
{t("home.downloads.active_downloads")}
|
{t("home.downloads.active_downloads")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='space-y-2'>
|
<View className='gap-y-2'>
|
||||||
{processes?.map((p: JobStatus) => (
|
{processes?.map((p: JobStatus) => (
|
||||||
<DownloadCard key={p.item.Id} process={p} />
|
<DownloadCard key={p.item.Id} process={p} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { t } from "i18next";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
type TouchableOpacityProps,
|
type TouchableOpacityProps,
|
||||||
View,
|
View,
|
||||||
@@ -14,49 +13,36 @@ import {
|
|||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
||||||
import { JobStatus } from "@/providers/Downloads/types";
|
import { JobStatus } from "@/providers/Downloads/types";
|
||||||
|
import { estimateDownloadSize } from "@/utils/download";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
import { Button } from "../Button";
|
|
||||||
|
|
||||||
const bytesToMB = (bytes: number) => {
|
const bytesToMB = (bytes: number) => {
|
||||||
return bytes / 1024 / 1024;
|
return bytes / 1024 / 1024;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes >= 1024 * 1024 * 1024) {
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
interface DownloadCardProps extends TouchableOpacityProps {
|
interface DownloadCardProps extends TouchableOpacityProps {
|
||||||
process: JobStatus;
|
process: JobStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
|
const { cancelDownload } = useDownload();
|
||||||
useDownload();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const handlePause = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await pauseDownload(id);
|
|
||||||
toast.success(t("home.downloads.toasts.download_paused"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error pausing download:", error);
|
|
||||||
toast.error(t("home.downloads.toasts.could_not_pause_download"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResume = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await resumeDownload(id);
|
|
||||||
toast.success(t("home.downloads.toasts.download_resumed"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error resuming download:", error);
|
|
||||||
toast.error(t("home.downloads.toasts.could_not_resume_download"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await removeProcess(id);
|
await cancelDownload(id);
|
||||||
toast.success(t("home.downloads.toasts.download_deleted"));
|
// cancelDownload already shows a toast, so don't show another one
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting download:", error);
|
console.error("Error deleting download:", error);
|
||||||
@@ -64,16 +50,48 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const eta = (p: JobStatus) => {
|
const eta = useMemo(() => {
|
||||||
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
|
if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
const secondsRemaining = calculateSmoothedETA(
|
||||||
if (bytesRemaining <= 0) return null;
|
process.id,
|
||||||
|
process.bytesDownloaded,
|
||||||
|
process.estimatedTotalSizeBytes,
|
||||||
|
);
|
||||||
|
|
||||||
const secondsRemaining = bytesRemaining / p.speed;
|
if (!secondsRemaining || secondsRemaining <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return formatTimeString(secondsRemaining, "s");
|
return formatTimeString(secondsRemaining, "s");
|
||||||
};
|
}, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
|
||||||
|
|
||||||
|
const estimatedSize = useMemo(() => {
|
||||||
|
if (process.estimatedTotalSizeBytes) return process.estimatedTotalSizeBytes;
|
||||||
|
|
||||||
|
// Calculate from bitrate + duration (only if bitrate value is defined)
|
||||||
|
if (process.maxBitrate.value) {
|
||||||
|
return estimateDownloadSize(
|
||||||
|
process.maxBitrate.value,
|
||||||
|
process.item.RunTimeTicks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [
|
||||||
|
process.maxBitrate.value,
|
||||||
|
process.item.RunTimeTicks,
|
||||||
|
process.estimatedTotalSizeBytes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isTranscoding = process.isTranscoding || false;
|
||||||
|
|
||||||
|
const downloadedAmount = useMemo(() => {
|
||||||
|
if (!process.bytesDownloaded) return null;
|
||||||
|
return formatBytes(process.bytesDownloaded);
|
||||||
|
}, [process.bytesDownloaded]);
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(process.item.Id!);
|
return storage.getString(process.item.Id!);
|
||||||
@@ -98,9 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
>
|
>
|
||||||
{process.status === "downloading" && (
|
{process.status === "downloading" && (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`bg-purple-600 h-1 absolute bottom-0 left-0 ${isTranscoding ? "animate-pulse" : ""}`}
|
||||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
|
||||||
`}
|
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
sanitizedProgress > 0
|
sanitizedProgress > 0
|
||||||
@@ -111,26 +127,10 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons in bottom right corner */}
|
{/* Action buttons in bottom right corner */}
|
||||||
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
|
<View className='absolute bottom-2 right-2 flex flex-row items-center z-10'>
|
||||||
{process.status === "downloading" && Platform.OS !== "ios" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handlePause(process.id)}
|
|
||||||
className='p-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='pause' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{process.status === "paused" && Platform.OS !== "ios" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleResume(process.id)}
|
|
||||||
className='p-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='play' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleDelete(process.id)}
|
onPress={() => handleDelete(process.id)}
|
||||||
className='p-1'
|
className='p-2 bg-neutral-800 rounded-full'
|
||||||
>
|
>
|
||||||
<Ionicons name='close' size={20} color='red' />
|
<Ionicons name='close' size={20} color='red' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -152,47 +152,53 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className='shrink mb-1 flex-1'>
|
<View className='shrink mb-1 flex-1 pr-12'>
|
||||||
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
|
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
|
||||||
<Text className='font-semibold shrink'>{process.item.Name}</Text>
|
<Text className='font-semibold shrink'>{process.item.Name}</Text>
|
||||||
<Text className='text-xs opacity-50'>
|
<Text className='text-xs opacity-50'>
|
||||||
{process.item.ProductionYear}
|
{process.item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
|
||||||
|
{isTranscoding && (
|
||||||
|
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||||
|
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 1: Progress + Downloaded/Total */}
|
||||||
|
<View className='flex flex-row items-center gap-x-2 mt-1.5'>
|
||||||
{sanitizedProgress === 0 ? (
|
{sanitizedProgress === 0 ? (
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
) : (
|
) : (
|
||||||
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
|
<Text className='text-xs font-semibold'>
|
||||||
)}
|
{sanitizedProgress.toFixed(0)}%
|
||||||
{process.speed && process.speed > 0 && (
|
|
||||||
<Text className='text-xs'>
|
|
||||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{eta(process) && (
|
{downloadedAmount && (
|
||||||
<Text className='text-xs'>
|
<Text className='text-xs opacity-75'>
|
||||||
{t("home.downloads.eta", { eta: eta(process) })}
|
{downloadedAmount}
|
||||||
|
{estimatedSize
|
||||||
|
? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}`
|
||||||
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
{/* Row 2: Speed + ETA */}
|
||||||
<Text className='text-xs capitalize'>{process.status}</Text>
|
<View className='flex flex-row items-center gap-x-2 mt-0.5'>
|
||||||
|
{process.speed && process.speed > 0 && (
|
||||||
|
<Text className='text-xs text-purple-400'>
|
||||||
|
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{eta && (
|
||||||
|
<Text className='text-xs text-green-400'>
|
||||||
|
{t("home.downloads.eta", { eta: eta })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{process.status === "completed" && (
|
|
||||||
<View className='flex flex-row mt-4 space-x-4'>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
startDownload(process);
|
|
||||||
}}
|
|
||||||
className='w-full'
|
|
||||||
>
|
|
||||||
Download now
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
|||||||
items,
|
items,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { getDownloadedItemSize, getDownloadedItems } = useDownload();
|
const { getDownloadedItemSize, downloadedItems } = useDownload();
|
||||||
const downloadedFiles = getDownloadedItems();
|
|
||||||
const [size, setSize] = useState<string | undefined>();
|
const [size, setSize] = useState<string | undefined>();
|
||||||
|
|
||||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!downloadedFiles) return;
|
if (!downloadedItems) return;
|
||||||
|
|
||||||
let s = 0;
|
let s = 0;
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSize(s.bytesToReadable());
|
setSize(s.bytesToReadable());
|
||||||
}, [itemIds]);
|
}, [itemIds, downloadedItems, getDownloadedItemSize]);
|
||||||
|
|
||||||
const sizeText = useMemo(() => {
|
const sizeText = useMemo(() => {
|
||||||
if (!size) return "...";
|
if (!size) return "...";
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
*/
|
*/
|
||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id, "Episode");
|
deleteFile(item.Id);
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
return storage.getString(items[0].SeriesId!);
|
return storage.getString(items[0].SeriesId!);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteSeries = useCallback(async () => deleteItems(items), [items]);
|
const deleteSeries = useCallback(
|
||||||
|
async () =>
|
||||||
|
deleteItems(
|
||||||
|
items.map((item) => item.Id).filter((id) => id !== undefined),
|
||||||
|
),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const showActionSheet = useCallback(() => {
|
||||||
const options = ["Delete", "Cancel"];
|
const options = ["Delete", "Cancel"];
|
||||||
|
|||||||
515
components/home/Home.tsx
Normal file
515
components/home/Home.tsx
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemDtoQueryResult,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
getItemsApi,
|
||||||
|
getSuggestionsApi,
|
||||||
|
getTvShowsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
|
type ScrollingCollectionListSection = {
|
||||||
|
type: "ScrollingCollectionList";
|
||||||
|
title?: string;
|
||||||
|
queryKey: (string | undefined | null)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaListSectionType = {
|
||||||
|
type: "MediaListSection";
|
||||||
|
queryKey: (string | undefined)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Section = ScrollingCollectionListSection | MediaListSectionType;
|
||||||
|
|
||||||
|
export const Home = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
||||||
|
const prevIsConnected = useRef<boolean | null>(false);
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
serverConnected,
|
||||||
|
loading: retryLoading,
|
||||||
|
retryCheck,
|
||||||
|
} = useNetworkStatus();
|
||||||
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isConnected && !prevIsConnected.current) {
|
||||||
|
invalidateCache();
|
||||||
|
}
|
||||||
|
prevIsConnected.current = isConnected;
|
||||||
|
}, [isConnected, invalidateCache]);
|
||||||
|
|
||||||
|
const hasDownloads = useMemo(() => {
|
||||||
|
if (Platform.isTV) return false;
|
||||||
|
return downloadedItems.length > 0;
|
||||||
|
}, [downloadedItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerLeft: () => null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigation.setOptions({
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/downloads");
|
||||||
|
}}
|
||||||
|
className='ml-1.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather
|
||||||
|
name='download'
|
||||||
|
color={hasDownloads ? Colors.primary : "white"}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, router, hasDownloads]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cleanCacheDirectory().catch((_e) =>
|
||||||
|
console.error("Something went wrong cleaning cache directory"),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const segments = useSegments();
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||||
|
if ((segments as string[])[2] === "(home)")
|
||||||
|
scrollRef.current?.scrollTo({
|
||||||
|
y: Platform.isTV ? -152 : -100,
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [segments]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isError: e1,
|
||||||
|
isLoading: l1,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["home", "userViews", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userViews = useMemo(
|
||||||
|
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||||
|
[data, settings?.hiddenLibraries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const collections = useMemo(() => {
|
||||||
|
const allow = ["movies", "tvshows"];
|
||||||
|
return (
|
||||||
|
userViews?.filter(
|
||||||
|
(c) => c.CollectionType && allow.includes(c.CollectionType),
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}, [userViews]);
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await refreshStreamyfinPluginSettings();
|
||||||
|
await invalidateCache();
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCollectionConfig = useCallback(
|
||||||
|
(
|
||||||
|
title: string,
|
||||||
|
queryKey: string[],
|
||||||
|
includeItemTypes: BaseItemKind[],
|
||||||
|
parentId: string | undefined,
|
||||||
|
): ScrollingCollectionListSection => ({
|
||||||
|
title,
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 20,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
includeItemTypes,
|
||||||
|
parentId,
|
||||||
|
})
|
||||||
|
).data || []
|
||||||
|
);
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
}),
|
||||||
|
[api, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultSections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const latestMediaViews = collections.map((c) => {
|
||||||
|
const includeItemTypes: BaseItemKind[] =
|
||||||
|
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||||
|
? []
|
||||||
|
: ["Movie"];
|
||||||
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
|
const queryKey: string[] = [
|
||||||
|
"home",
|
||||||
|
`recentlyAddedIn${c.CollectionType}`,
|
||||||
|
user.Id!,
|
||||||
|
c.Id!,
|
||||||
|
];
|
||||||
|
return createCollectionConfig(
|
||||||
|
title || "",
|
||||||
|
queryKey,
|
||||||
|
includeItemTypes,
|
||||||
|
c.Id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ss: Section[] = [
|
||||||
|
{
|
||||||
|
title: t("home.continue_watching"),
|
||||||
|
queryKey: ["home", "resumeItems"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
fields: ["Genres"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.next_up"),
|
||||||
|
queryKey: ["home", "nextUp-all"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
|
limit: 20,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
enableResumable: false,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
...latestMediaViews,
|
||||||
|
{
|
||||||
|
title: t("home.suggested_movies"),
|
||||||
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Video"],
|
||||||
|
type: ["Movie"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.suggested_episodes"),
|
||||||
|
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const suggestions = await getSuggestions(api, user.Id);
|
||||||
|
const nextUpPromises = suggestions.map((series) =>
|
||||||
|
getNextUp(api, user.Id, series.Id),
|
||||||
|
);
|
||||||
|
const nextUpResults = await Promise.all(nextUpPromises);
|
||||||
|
|
||||||
|
return nextUpResults.filter((item) => item !== null) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
||||||
|
|
||||||
|
const customSections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||||
|
const ss: Section[] = [];
|
||||||
|
settings.home.sections.forEach((section, index) => {
|
||||||
|
const id = section.title || `section-${index}`;
|
||||||
|
ss.push({
|
||||||
|
title: t(`${id}`),
|
||||||
|
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (section.items) {
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: section.items?.limit || 25,
|
||||||
|
recursive: true,
|
||||||
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
|
sortBy: section.items?.sortBy,
|
||||||
|
sortOrder: section.items?.sortOrder,
|
||||||
|
filters: section.items?.filters,
|
||||||
|
parentId: section.items?.parentId,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
if (section.nextUp) {
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
|
limit: section.nextUp?.limit || 25,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
if (section.latest) {
|
||||||
|
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
|
limit: section.latest?.limit || 25,
|
||||||
|
isPlayed: section.latest?.isPlayed,
|
||||||
|
groupItems: section.latest?.groupItems,
|
||||||
|
});
|
||||||
|
return response.data || [];
|
||||||
|
}
|
||||||
|
if (section.custom) {
|
||||||
|
const response = await api.get<BaseItemDtoQueryResult>(
|
||||||
|
section.custom.endpoint,
|
||||||
|
{
|
||||||
|
params: { ...(section.custom.query || {}), userId: user?.Id },
|
||||||
|
headers: section.custom.headers || {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: section?.orientation || "vertical",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, settings?.home?.sections]);
|
||||||
|
|
||||||
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
|
if (!isConnected || serverConnected !== true) {
|
||||||
|
let title = "";
|
||||||
|
let subtitle = "";
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
title = t("home.no_internet");
|
||||||
|
subtitle = t("home.no_internet_message");
|
||||||
|
} else if (serverConnected === null) {
|
||||||
|
title = t("home.checking_server_connection");
|
||||||
|
subtitle = t("home.checking_server_connection_message");
|
||||||
|
} else if (!serverConnected) {
|
||||||
|
title = t("home.server_unreachable");
|
||||||
|
subtitle = t("home.server_unreachable_message");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
||||||
|
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
||||||
|
<Text className='text-center opacity-70'>{subtitle}</Text>
|
||||||
|
|
||||||
|
<View className='mt-4'>
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<Button
|
||||||
|
color='purple'
|
||||||
|
onPress={() => router.push("/(auth)/downloads")}
|
||||||
|
justify='center'
|
||||||
|
iconRight={
|
||||||
|
<Ionicons name='arrow-forward' size={20} color='white' />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("home.go_to_downloads")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color='black'
|
||||||
|
onPress={retryCheck}
|
||||||
|
justify='center'
|
||||||
|
className='mt-2'
|
||||||
|
iconRight={
|
||||||
|
retryLoading ? null : (
|
||||||
|
<Ionicons name='refresh' size={20} color='white' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{retryLoading ? (
|
||||||
|
<ActivityIndicator size='small' color='white' />
|
||||||
|
) : (
|
||||||
|
t("home.retry")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e1)
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col items-center justify-center h-full -mt-6'>
|
||||||
|
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
|
||||||
|
<Text className='text-center opacity-70'>
|
||||||
|
{t("home.error_message")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (l1)
|
||||||
|
return (
|
||||||
|
<View className='justify-center items-center h-full'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={loading}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='white'
|
||||||
|
colors={["white"]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='flex flex-col space-y-4'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
{sections.map((section, index) => {
|
||||||
|
if (section.type === "ScrollingCollectionList") {
|
||||||
|
return (
|
||||||
|
<ScrollingCollectionList
|
||||||
|
key={index}
|
||||||
|
title={section.title}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
orientation={section.orientation}
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.type === "MediaListSection") {
|
||||||
|
return (
|
||||||
|
<MediaListSection
|
||||||
|
key={index}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getSuggestions(api: Api, userId: string | undefined) {
|
||||||
|
if (!userId) return [];
|
||||||
|
const response = await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Unknown"],
|
||||||
|
type: ["Series"],
|
||||||
|
});
|
||||||
|
return response.data.Items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNextUp(
|
||||||
|
api: Api,
|
||||||
|
userId: string | undefined,
|
||||||
|
seriesId: string | undefined,
|
||||||
|
) {
|
||||||
|
if (!userId || !seriesId) return null;
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId,
|
||||||
|
seriesId,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return response.data.Items?.[0] ?? null;
|
||||||
|
}
|
||||||
@@ -12,11 +12,7 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import {
|
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
type QueryFunction,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -24,7 +20,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
RefreshControl,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -45,7 +40,7 @@ import { useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import { AppleTVCarousel } from "../AppleTVCarousel";
|
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
type ScrollingCollectionListSection = {
|
||||||
type: "ScrollingCollectionList";
|
type: "ScrollingCollectionList";
|
||||||
@@ -63,32 +58,19 @@ type MediaListSectionType = {
|
|||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSectionType;
|
type Section = ScrollingCollectionListSection | MediaListSectionType;
|
||||||
|
|
||||||
export const HomeIndex = () => {
|
export const HomeWithCarousel = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [_loading, setLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? true;
|
const headerOverlayOffset = Platform.isTV ? 0 : 60;
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const headerOverlayOffset = Platform.isTV
|
|
||||||
? 0
|
|
||||||
: showLargeHomeCarousel
|
|
||||||
? 60
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||||
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
||||||
|
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
||||||
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
|
|
||||||
const prevIsConnected = useRef<boolean | null>(false);
|
const prevIsConnected = useRef<boolean | null>(false);
|
||||||
const {
|
const {
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -97,15 +79,19 @@ export const HomeIndex = () => {
|
|||||||
retryCheck,
|
retryCheck,
|
||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only invalidate cache when transitioning from offline to online
|
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
invalidateCache();
|
invalidateCache();
|
||||||
}
|
}
|
||||||
// Update the ref to the current state for the next render
|
|
||||||
prevIsConnected.current = isConnected;
|
prevIsConnected.current = isConnected;
|
||||||
}, [isConnected, invalidateCache]);
|
}, [isConnected, invalidateCache]);
|
||||||
|
|
||||||
|
const hasDownloads = useMemo(() => {
|
||||||
|
if (Platform.isTV) return false;
|
||||||
|
return downloadedItems.length > 0;
|
||||||
|
}, [downloadedItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
@@ -113,7 +99,6 @@ export const HomeIndex = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasDownloads = getDownloadedItems().length > 0;
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -121,6 +106,7 @@ export const HomeIndex = () => {
|
|||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
className='ml-1.5'
|
className='ml-1.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather
|
||||||
name='download'
|
name='download'
|
||||||
@@ -130,7 +116,7 @@ export const HomeIndex = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [navigation, router]);
|
}, [navigation, router, hasDownloads]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cleanCacheDirectory().catch((_e) =>
|
cleanCacheDirectory().catch((_e) =>
|
||||||
@@ -188,24 +174,13 @@ export const HomeIndex = () => {
|
|||||||
);
|
);
|
||||||
}, [userViews]);
|
}, [userViews]);
|
||||||
|
|
||||||
const refetch = async () => {
|
const _refetch = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await refreshStreamyfinPluginSettings();
|
await refreshStreamyfinPluginSettings();
|
||||||
await queryClient.clear();
|
|
||||||
await invalidateCache();
|
await invalidateCache();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = eventBus.on("refreshHome", () => {
|
|
||||||
refetch();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [refetch]);
|
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
const createCollectionConfig = useCallback(
|
||||||
(
|
(
|
||||||
title: string,
|
title: string,
|
||||||
@@ -236,7 +211,6 @@ export const HomeIndex = () => {
|
|||||||
[api, user?.Id],
|
[api, user?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Always call useMemo() at the top-level, using computed dependencies for both "default"/custom sections
|
|
||||||
const defaultSections = useMemo(() => {
|
const defaultSections = useMemo(() => {
|
||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
@@ -246,10 +220,10 @@ export const HomeIndex = () => {
|
|||||||
? []
|
? []
|
||||||
: ["Movie"];
|
: ["Movie"];
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
const queryKey = [
|
const queryKey: string[] = [
|
||||||
"home",
|
"home",
|
||||||
`recentlyAddedIn${c.CollectionType}`,
|
`recentlyAddedIn${c.CollectionType}`,
|
||||||
user?.Id!,
|
user.Id!,
|
||||||
c.Id!,
|
c.Id!,
|
||||||
];
|
];
|
||||||
return createCollectionConfig(
|
return createCollectionConfig(
|
||||||
@@ -293,16 +267,6 @@ export const HomeIndex = () => {
|
|||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
},
|
},
|
||||||
...latestMediaViews,
|
...latestMediaViews,
|
||||||
// ...(mediaListCollections?.map(
|
|
||||||
// (ml) =>
|
|
||||||
// ({
|
|
||||||
// title: ml.Name,
|
|
||||||
// queryKey: ["home", "mediaList", ml.Id!],
|
|
||||||
// queryFn: async () => ml,
|
|
||||||
// type: "MediaListSection",
|
|
||||||
// orientation: "vertical",
|
|
||||||
// } as Section)
|
|
||||||
// ) || []),
|
|
||||||
{
|
{
|
||||||
title: t("home.suggested_movies"),
|
title: t("home.suggested_movies"),
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
@@ -411,15 +375,12 @@ export const HomeIndex = () => {
|
|||||||
let subtitle = "";
|
let subtitle = "";
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
// No network connection
|
|
||||||
title = t("home.no_internet");
|
title = t("home.no_internet");
|
||||||
subtitle = t("home.no_internet_message");
|
subtitle = t("home.no_internet_message");
|
||||||
} else if (serverConnected === null) {
|
} else if (serverConnected === null) {
|
||||||
// Network is up, but server is being checked
|
|
||||||
title = t("home.checking_server_connection");
|
title = t("home.checking_server_connection");
|
||||||
subtitle = t("home.checking_server_connection_message");
|
subtitle = t("home.checking_server_connection_message");
|
||||||
} else if (!serverConnected) {
|
} else if (!serverConnected) {
|
||||||
// Network is up, but server is unreachable
|
|
||||||
title = t("home.server_unreachable");
|
title = t("home.server_unreachable");
|
||||||
subtitle = t("home.server_unreachable_message");
|
subtitle = t("home.server_unreachable_message");
|
||||||
}
|
}
|
||||||
@@ -488,35 +449,18 @@ export const HomeIndex = () => {
|
|||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior='never'
|
contentInsetAdjustmentBehavior='never'
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
bounces={!showLargeHomeCarousel}
|
bounces={false}
|
||||||
overScrollMode={showLargeHomeCarousel ? "never" : "auto"}
|
overScrollMode='never'
|
||||||
refreshControl={
|
|
||||||
showLargeHomeCarousel ? undefined : (
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={loading}
|
|
||||||
onRefresh={refetch}
|
|
||||||
tintColor='white'
|
|
||||||
colors={["white"]}
|
|
||||||
progressViewOffset={100}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
style={{ marginTop: -headerOverlayOffset }}
|
style={{ marginTop: -headerOverlayOffset }}
|
||||||
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
||||||
>
|
>
|
||||||
{showLargeHomeCarousel && (
|
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
||||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
|
||||||
)}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
paddingTop: Platform.isTV
|
paddingTop: 0,
|
||||||
? 0
|
|
||||||
: showLargeHomeCarousel
|
|
||||||
? 0
|
|
||||||
: insets.top + 60,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col space-y-4'>
|
<View className='flex flex-col space-y-4'>
|
||||||
@@ -551,7 +495,6 @@ export const HomeIndex = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to get suggestions
|
|
||||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
async function getSuggestions(api: Api, userId: string | undefined) {
|
||||||
if (!userId) return [];
|
if (!userId) return [];
|
||||||
const response = await getSuggestionsApi(api).getSuggestions({
|
const response = await getSuggestionsApi(api).getSuggestions({
|
||||||
@@ -563,7 +506,6 @@ async function getSuggestions(api: Api, userId: string | undefined) {
|
|||||||
return response.data.Items ?? [];
|
return response.data.Items ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to get the next up TV show for a series
|
|
||||||
async function getNextUp(
|
async function getNextUp(
|
||||||
api: Api,
|
api: Api,
|
||||||
userId: string | undefined,
|
userId: string | undefined,
|
||||||
@@ -23,7 +23,6 @@ const CastSlide: React.FC<
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
data={details?.credits.cast}
|
data={details?.credits.cast}
|
||||||
ItemSeparatorComponent={() => <View className='w-2' />}
|
ItemSeparatorComponent={() => <View className='w-2' />}
|
||||||
estimatedItemSize={15}
|
|
||||||
keyExtractor={(item) => item?.id?.toString()}
|
keyExtractor={(item) => item?.id?.toString()}
|
||||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { forwardRef, useCallback, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import Dropdown from "@/components/common/Dropdown";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type {
|
import type {
|
||||||
QualityProfile,
|
QualityProfile,
|
||||||
@@ -48,8 +48,22 @@ const RequestModal = forwardRef<
|
|||||||
userId: jellyseerrUser?.id,
|
userId: jellyseerrUser?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
|
||||||
|
const [rootFolderOpen, setRootFolderOpen] = useState(false);
|
||||||
|
const [tagsOpen, setTagsOpen] = useState(false);
|
||||||
|
const [usersOpen, setUsersOpen] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Reset all dropdown states when modal closes
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setQualityProfileOpen(false);
|
||||||
|
setRootFolderOpen(false);
|
||||||
|
setTagsOpen(false);
|
||||||
|
setUsersOpen(false);
|
||||||
|
onDismiss?.();
|
||||||
|
}, [onDismiss]);
|
||||||
|
|
||||||
const { data: serviceSettings } = useQuery({
|
const { data: serviceSettings } = useQuery({
|
||||||
queryKey: ["jellyseerr", "request", type, "service"],
|
queryKey: ["jellyseerr", "request", type, "service"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
@@ -138,6 +152,109 @@ const RequestModal = forwardRef<
|
|||||||
});
|
});
|
||||||
}, [requestBody?.seasons]);
|
}, [requestBody?.seasons]);
|
||||||
|
|
||||||
|
const pathTitleExtractor = (item: RootFolder) =>
|
||||||
|
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||||
|
|
||||||
|
const qualityProfileOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
defaultServiceDetails?.profiles.map((profile) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: profile.name,
|
||||||
|
value: profile.id.toString(),
|
||||||
|
selected:
|
||||||
|
(requestOverrides.profileId || defaultProfile?.id) ===
|
||||||
|
profile.id,
|
||||||
|
onPress: () =>
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
profileId: profile.id,
|
||||||
|
})),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.profiles,
|
||||||
|
defaultProfile,
|
||||||
|
requestOverrides.profileId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootFolderOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: pathTitleExtractor(folder),
|
||||||
|
value: folder.id.toString(),
|
||||||
|
selected:
|
||||||
|
(requestOverrides.rootFolder || defaultFolder?.path) ===
|
||||||
|
folder.path,
|
||||||
|
onPress: () =>
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rootFolder: folder.path,
|
||||||
|
})),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.rootFolders,
|
||||||
|
defaultFolder,
|
||||||
|
requestOverrides.rootFolder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagsOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
defaultServiceDetails?.tags.map((tag) => ({
|
||||||
|
type: "toggle" as const,
|
||||||
|
label: tag.label,
|
||||||
|
value:
|
||||||
|
requestOverrides.tags?.includes(tag.id) ||
|
||||||
|
defaultTags.some((dt) => dt.id === tag.id),
|
||||||
|
onToggle: () =>
|
||||||
|
setRequestOverrides((prev) => {
|
||||||
|
const currentTags = prev.tags || defaultTags.map((t) => t.id);
|
||||||
|
const hasTag = currentTags.includes(tag.id);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
tags: hasTag
|
||||||
|
? currentTags.filter((id) => id !== tag.id)
|
||||||
|
: [...currentTags, tag.id],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags],
|
||||||
|
);
|
||||||
|
|
||||||
|
const usersOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
users?.map((user) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: user.displayName,
|
||||||
|
value: user.id.toString(),
|
||||||
|
selected:
|
||||||
|
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||||
|
onPress: () =>
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
userId: user.id,
|
||||||
|
})),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[users, jellyseerrUser, requestOverrides.userId],
|
||||||
|
);
|
||||||
|
|
||||||
const request = useCallback(() => {
|
const request = useCallback(() => {
|
||||||
const body = {
|
const body = {
|
||||||
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||||
@@ -163,15 +280,12 @@ const RequestModal = forwardRef<
|
|||||||
defaultTags,
|
defaultTags,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pathTitleExtractor = (item: RootFolder) =>
|
|
||||||
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={ref}
|
ref={ref}
|
||||||
enableDynamicSizing
|
enableDynamicSizing
|
||||||
enableDismissOnClose
|
enableDismissOnClose
|
||||||
onDismiss={onDismiss}
|
onDismiss={handleDismiss}
|
||||||
handleIndicatorStyle={{
|
handleIndicatorStyle={{
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
}}
|
}}
|
||||||
@@ -185,6 +299,7 @@ const RequestModal = forwardRef<
|
|||||||
appearsOnIndex={0}
|
appearsOnIndex={0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
stackBehavior='push'
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -199,70 +314,112 @@ const RequestModal = forwardRef<
|
|||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
{defaultService && defaultServiceDetails && users && (
|
{defaultService && defaultServiceDetails && users && (
|
||||||
<>
|
<>
|
||||||
<Dropdown
|
<View className='flex flex-col'>
|
||||||
data={defaultServiceDetails.profiles}
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
titleExtractor={(item) => item.name}
|
{t("jellyseerr.quality_profile")}
|
||||||
placeholderText={
|
</Text>
|
||||||
requestOverrides.profileName || defaultProfile.name
|
<PlatformDropdown
|
||||||
}
|
groups={qualityProfileOptions}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
trigger={
|
||||||
label={t("jellyseerr.quality_profile")}
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
onSelected={(item) =>
|
<Text numberOfLines={1}>
|
||||||
item &&
|
{defaultServiceDetails.profiles.find(
|
||||||
setRequestOverrides((prev) => ({
|
(p) =>
|
||||||
...prev,
|
p.id ===
|
||||||
profileId: item?.id,
|
(requestOverrides.profileId ||
|
||||||
}))
|
defaultProfile?.id),
|
||||||
}
|
)?.name || defaultProfile?.name}
|
||||||
title={t("jellyseerr.quality_profile")}
|
</Text>
|
||||||
/>
|
</View>
|
||||||
<Dropdown
|
}
|
||||||
data={defaultServiceDetails.rootFolders}
|
title={t("jellyseerr.quality_profile")}
|
||||||
titleExtractor={pathTitleExtractor}
|
open={qualityProfileOpen}
|
||||||
placeholderText={
|
onOpenChange={setQualityProfileOpen}
|
||||||
defaultFolder ? pathTitleExtractor(defaultFolder) : ""
|
/>
|
||||||
}
|
</View>
|
||||||
keyExtractor={(item) => item.id.toString()}
|
|
||||||
label={t("jellyseerr.root_folder")}
|
<View className='flex flex-col'>
|
||||||
onSelected={(item) =>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
item &&
|
{t("jellyseerr.root_folder")}
|
||||||
setRequestOverrides((prev) => ({
|
</Text>
|
||||||
...prev,
|
<PlatformDropdown
|
||||||
rootFolder: item.path,
|
groups={rootFolderOptions}
|
||||||
}))
|
trigger={
|
||||||
}
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
title={t("jellyseerr.root_folder")}
|
<Text numberOfLines={1}>
|
||||||
/>
|
{defaultServiceDetails.rootFolders.find(
|
||||||
<Dropdown
|
(f) =>
|
||||||
multiple
|
f.path ===
|
||||||
data={defaultServiceDetails.tags}
|
(requestOverrides.rootFolder ||
|
||||||
titleExtractor={(item) => item.label}
|
defaultFolder?.path),
|
||||||
placeholderText={defaultTags.map((t) => t.label).join(",")}
|
)
|
||||||
keyExtractor={(item) => item.id.toString()}
|
? pathTitleExtractor(
|
||||||
label={t("jellyseerr.tags")}
|
defaultServiceDetails.rootFolders.find(
|
||||||
onSelected={(...selected) =>
|
(f) =>
|
||||||
setRequestOverrides((prev) => ({
|
f.path ===
|
||||||
...prev,
|
(requestOverrides.rootFolder ||
|
||||||
tags: selected.map((i) => i.id),
|
defaultFolder?.path),
|
||||||
}))
|
)!,
|
||||||
}
|
)
|
||||||
title={t("jellyseerr.tags")}
|
: pathTitleExtractor(defaultFolder!)}
|
||||||
/>
|
</Text>
|
||||||
<Dropdown
|
</View>
|
||||||
data={users}
|
}
|
||||||
titleExtractor={(item) => item.displayName}
|
title={t("jellyseerr.root_folder")}
|
||||||
placeholderText={jellyseerrUser!.displayName}
|
open={rootFolderOpen}
|
||||||
keyExtractor={(item) => item.id.toString() || ""}
|
onOpenChange={setRootFolderOpen}
|
||||||
label={t("jellyseerr.request_as")}
|
/>
|
||||||
onSelected={(item) =>
|
</View>
|
||||||
item &&
|
|
||||||
setRequestOverrides((prev) => ({
|
<View className='flex flex-col'>
|
||||||
...prev,
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
userId: item?.id,
|
{t("jellyseerr.tags")}
|
||||||
}))
|
</Text>
|
||||||
}
|
<PlatformDropdown
|
||||||
title={t("jellyseerr.request_as")}
|
groups={tagsOptions}
|
||||||
/>
|
trigger={
|
||||||
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{requestOverrides.tags
|
||||||
|
? defaultServiceDetails.tags
|
||||||
|
.filter((t) =>
|
||||||
|
requestOverrides.tags!.includes(t.id),
|
||||||
|
)
|
||||||
|
.map((t) => t.label)
|
||||||
|
.join(", ") ||
|
||||||
|
defaultTags.map((t) => t.label).join(", ")
|
||||||
|
: defaultTags.map((t) => t.label).join(", ")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("jellyseerr.tags")}
|
||||||
|
open={tagsOpen}
|
||||||
|
onOpenChange={setTagsOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex flex-col'>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
|
{t("jellyseerr.request_as")}
|
||||||
|
</Text>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={usersOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{users.find(
|
||||||
|
(u) =>
|
||||||
|
u.id ===
|
||||||
|
(requestOverrides.userId || jellyseerrUser?.id),
|
||||||
|
)?.displayName || jellyseerrUser!.displayName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("jellyseerr.request_as")}
|
||||||
|
open={usersOpen}
|
||||||
|
onOpenChange={setUsersOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
115
components/search/DiscoverFilters.tsx
Normal file
115
components/search/DiscoverFilters.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
|
|
||||||
|
interface DiscoverFiltersProps {
|
||||||
|
searchFilterId: string;
|
||||||
|
orderFilterId: string;
|
||||||
|
jellyseerrOrderBy: JellyseerrSearchSort;
|
||||||
|
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
|
||||||
|
jellyseerrSortOrder: "asc" | "desc";
|
||||||
|
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||||
|
Number.isNaN(Number(v)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderOptions = ["asc", "desc"] as const;
|
||||||
|
|
||||||
|
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||||
|
searchFilterId,
|
||||||
|
orderFilterId,
|
||||||
|
jellyseerrOrderBy,
|
||||||
|
setJellyseerrOrderBy,
|
||||||
|
jellyseerrSortOrder,
|
||||||
|
setJellyseerrSortOrder,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
overflow: "visible",
|
||||||
|
height: 40,
|
||||||
|
width: 50,
|
||||||
|
marginLeft: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenu.Trigger>
|
||||||
|
<Button
|
||||||
|
variant='glass'
|
||||||
|
modifiers={[]}
|
||||||
|
systemImage='line.3.horizontal.decrease.circle'
|
||||||
|
></Button>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Items>
|
||||||
|
<Picker
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
options={sortOptions.map((item) =>
|
||||||
|
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
|
||||||
|
)}
|
||||||
|
variant='menu'
|
||||||
|
selectedIndex={sortOptions.indexOf(
|
||||||
|
jellyseerrOrderBy as unknown as string,
|
||||||
|
)}
|
||||||
|
onOptionSelected={(event: any) => {
|
||||||
|
const index = event.nativeEvent.index;
|
||||||
|
setJellyseerrOrderBy(
|
||||||
|
sortOptions[index] as unknown as JellyseerrSearchSort,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Picker
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
options={orderOptions.map((item) => t(`library.filters.${item}`))}
|
||||||
|
variant='menu'
|
||||||
|
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
|
||||||
|
onOptionSelected={(event: any) => {
|
||||||
|
const index = event.nativeEvent.index;
|
||||||
|
setJellyseerrSortOrder(orderOptions[index]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ContextMenu.Items>
|
||||||
|
</ContextMenu>
|
||||||
|
</Host>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android UI
|
||||||
|
return (
|
||||||
|
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||||
|
<FilterButton
|
||||||
|
id={searchFilterId}
|
||||||
|
queryKey='jellyseerr_search'
|
||||||
|
queryFn={async () =>
|
||||||
|
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||||
|
Number.isNaN(Number(v)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||||
|
values={[jellyseerrOrderBy]}
|
||||||
|
title={t("library.filters.sort_by")}
|
||||||
|
renderItemLabel={(item) =>
|
||||||
|
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||||
|
}
|
||||||
|
disableSearch={true}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
id={orderFilterId}
|
||||||
|
queryKey='jellysearr_search'
|
||||||
|
queryFn={async () => ["asc", "desc"]}
|
||||||
|
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||||
|
values={[jellyseerrSortOrder]}
|
||||||
|
title={t("library.filters.sort_order")}
|
||||||
|
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||||
|
disableSearch={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
components/search/SearchTabButtons.tsx
Normal file
76
components/search/SearchTabButtons.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Button, Host } from "@expo/ui/swift-ui";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Tag } from "@/components/GenreTags";
|
||||||
|
|
||||||
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
|
interface SearchTabButtonsProps {
|
||||||
|
searchType: SearchType;
|
||||||
|
setSearchType: (type: SearchType) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
||||||
|
searchType,
|
||||||
|
setSearchType,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
width: 80,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant={searchType === "Library" ? "glassProminent" : "glass"}
|
||||||
|
onPress={() => setSearchType("Library")}
|
||||||
|
>
|
||||||
|
{t("search.library")}
|
||||||
|
</Button>
|
||||||
|
</Host>
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
width: 100,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant={searchType === "Discover" ? "glassProminent" : "glass"}
|
||||||
|
onPress={() => setSearchType("Discover")}
|
||||||
|
>
|
||||||
|
{t("search.discover")}
|
||||||
|
</Button>
|
||||||
|
</Host>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android UI
|
||||||
|
return (
|
||||||
|
<View className='flex flex-row gap-1 mr-1'>
|
||||||
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
|
<Tag
|
||||||
|
text={t("search.library")}
|
||||||
|
textClass='p-1'
|
||||||
|
className={searchType === "Library" ? "bg-purple-600" : undefined}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||||
|
<Tag
|
||||||
|
text={t("search.discover")}
|
||||||
|
textClass='p-1'
|
||||||
|
className={searchType === "Discover" ? "bg-purple-600" : undefined}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
import { PlatformDropdown } from "../PlatformDropdown";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -33,6 +31,7 @@ export const SeasonDropdown: React.FC<Props> = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const keys = useMemo<SeasonKeys>(
|
const keys = useMemo<SeasonKeys>(
|
||||||
() =>
|
() =>
|
||||||
@@ -55,6 +54,31 @@ export const SeasonDropdown: React.FC<Props> = ({
|
|||||||
[state, item, keys],
|
[state, item, keys],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||||
|
Number(a[keys.index]) - Number(b[keys.index]);
|
||||||
|
|
||||||
|
const optionGroups = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
seasons?.sort(sortByIndex).map((season: any) => {
|
||||||
|
const title =
|
||||||
|
season[keys.title] ||
|
||||||
|
season.Name ||
|
||||||
|
`Season ${season.IndexNumber}`;
|
||||||
|
return {
|
||||||
|
type: "radio" as const,
|
||||||
|
label: title,
|
||||||
|
value: season.Id || season.IndexNumber,
|
||||||
|
selected: Number(season[keys.index]) === Number(seasonIndex),
|
||||||
|
onPress: () => onSelect(season),
|
||||||
|
};
|
||||||
|
}) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[seasons, keys, seasonIndex, onSelect],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTv) return;
|
if (isTv) return;
|
||||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||||
@@ -96,45 +120,23 @@ export const SeasonDropdown: React.FC<Props> = ({
|
|||||||
keys,
|
keys,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
|
||||||
Number(a[keys.index]) - Number(b[keys.index]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Root>
|
<PlatformDropdown
|
||||||
<DropdownMenu.Trigger>
|
groups={optionGroups}
|
||||||
<View className='flex flex-row'>
|
open={open}
|
||||||
<TouchableOpacity className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
onOpenChange={setOpen}
|
||||||
|
trigger={
|
||||||
|
<TouchableOpacity onPress={() => setOpen(true)}>
|
||||||
|
<View className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
<Text>
|
<Text>
|
||||||
{t("item_card.season")} {seasonIndex}
|
{t("item_card.season")} {seasonIndex}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
}
|
||||||
<DropdownMenu.Content
|
title={t("item_card.seasons")}
|
||||||
loop={true}
|
/>
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>{t("item_card.seasons")}</DropdownMenu.Label>
|
|
||||||
{seasons?.sort(sortByIndex).map((season: any) => {
|
|
||||||
const title =
|
|
||||||
season[keys.title] || season.Name || `Season ${season.IndexNumber}`;
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={season.Id || season.IndexNumber}
|
|
||||||
onSelect={() => onSelect(season)}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{title}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
const downloadedFiles = getDownloadedItems();
|
const downloadedFiles = useMemo(
|
||||||
|
() => getDownloadedItems(),
|
||||||
|
[getDownloadedItems],
|
||||||
|
);
|
||||||
|
|
||||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
seasonId: selectedSeasonId,
|
seasonId: selectedSeasonId,
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
// Note: Including trick play is necessary to enable trick play downloads
|
// Note: Including trick play is necessary to enable trick play downloads
|
||||||
fields: ["Overview", "Trickplay"],
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.data.TotalRecordCount === 0)
|
if (res.data.TotalRecordCount === 0)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { APP_LANGUAGES } from "@/i18n";
|
import { APP_LANGUAGES } from "@/i18n";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { PlatformDropdown } from "../PlatformDropdown";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -15,6 +15,31 @@ export const AppLanguageSelector: React.FC<Props> = () => {
|
|||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const optionGroups = useMemo(() => {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("home.settings.languages.system"),
|
||||||
|
value: "system",
|
||||||
|
selected: !settings?.preferedLanguage,
|
||||||
|
onPress: () => updateSettings({ preferedLanguage: undefined }),
|
||||||
|
},
|
||||||
|
...APP_LANGUAGES.map((lang) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: lang.label,
|
||||||
|
value: lang.value,
|
||||||
|
selected: lang.value === settings?.preferedLanguage,
|
||||||
|
onPress: () => updateSettings({ preferedLanguage: lang.value }),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [settings?.preferedLanguage, t, updateSettings]);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -22,54 +47,19 @@ export const AppLanguageSelector: React.FC<Props> = () => {
|
|||||||
<View>
|
<View>
|
||||||
<ListGroup title={t("home.settings.languages.title")}>
|
<ListGroup title={t("home.settings.languages.title")}>
|
||||||
<ListItem title={t("home.settings.languages.app_language")}>
|
<ListItem title={t("home.settings.languages.app_language")}>
|
||||||
<DropdownMenu.Root>
|
<PlatformDropdown
|
||||||
<DropdownMenu.Trigger>
|
groups={optionGroups}
|
||||||
<TouchableOpacity className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
trigger={
|
||||||
|
<View className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
<Text>
|
<Text>
|
||||||
{APP_LANGUAGES.find(
|
{APP_LANGUAGES.find(
|
||||||
(l) => l.value === settings?.preferedLanguage,
|
(l) => l.value === settings?.preferedLanguage,
|
||||||
)?.label || t("home.settings.languages.system")}
|
)?.label || t("home.settings.languages.system")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
}
|
||||||
<DropdownMenu.Content
|
title={t("home.settings.languages.title")}
|
||||||
loop={true}
|
/>
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>
|
|
||||||
{t("home.settings.languages.title")}
|
|
||||||
</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"unknown"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
preferedLanguage: undefined,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{t("home.settings.languages.system")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{APP_LANGUAGES?.map((l) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={l?.value ?? "unknown"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
preferedLanguage: l.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { PlatformDropdown } from "../PlatformDropdown";
|
||||||
import { useMedia } from "./MediaContext";
|
import { useMedia } from "./MediaContext";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
@@ -22,6 +21,39 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
const cultures = media.cultures;
|
const cultures = media.cultures;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const optionGroups = useMemo(() => {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("home.settings.audio.none"),
|
||||||
|
value: "none",
|
||||||
|
selected: !settings?.defaultAudioLanguage,
|
||||||
|
onPress: () => updateSettings({ defaultAudioLanguage: null }),
|
||||||
|
},
|
||||||
|
...(cultures?.map((culture) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label:
|
||||||
|
culture.DisplayName ||
|
||||||
|
culture.ThreeLetterISOLanguageName ||
|
||||||
|
"Unknown",
|
||||||
|
value:
|
||||||
|
culture.ThreeLetterISOLanguageName ||
|
||||||
|
culture.DisplayName ||
|
||||||
|
"unknown",
|
||||||
|
selected:
|
||||||
|
culture.ThreeLetterISOLanguageName ===
|
||||||
|
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName,
|
||||||
|
onPress: () => updateSettings({ defaultAudioLanguage: culture }),
|
||||||
|
})) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [cultures, settings?.defaultAudioLanguage, t, updateSettings]);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -48,9 +80,10 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.audio.audio_language")}>
|
<ListItem title={t("home.settings.audio.audio_language")}>
|
||||||
<DropdownMenu.Root>
|
<PlatformDropdown
|
||||||
<DropdownMenu.Trigger>
|
groups={optionGroups}
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3 '>
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
{settings?.defaultAudioLanguage?.DisplayName ||
|
{settings?.defaultAudioLanguage?.DisplayName ||
|
||||||
t("home.settings.audio.none")}
|
t("home.settings.audio.none")}
|
||||||
@@ -60,48 +93,10 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
}
|
||||||
<DropdownMenu.Content
|
title={t("home.settings.audio.language")}
|
||||||
loop={true}
|
/>
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>
|
|
||||||
{t("home.settings.audio.language")}
|
|
||||||
</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"none-audio"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultAudioLanguage: null,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{t("home.settings.audio.none")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{cultures?.map((l) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultAudioLanguage: l,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{l.DisplayName}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,44 +1,3 @@
|
|||||||
import { useMemo } from "react";
|
export default function DownloadSettings() {
|
||||||
import { useTranslation } from "react-i18next";
|
return null;
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
export default function DownloadSettings({ ...props }) {
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const allDisabled = useMemo(
|
|
||||||
() =>
|
|
||||||
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
|
||||||
pluginSettings?.autoDownload?.locked === true,
|
|
||||||
[pluginSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting disabled={allDisabled} {...props} className='mb-4'>
|
|
||||||
<ListGroup title={t("home.settings.downloads.downloads_title")}>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.downloads.remux_max_download")}
|
|
||||||
disabled={pluginSettings?.remuxConcurrentLimit?.locked}
|
|
||||||
>
|
|
||||||
<Stepper
|
|
||||||
value={settings.remuxConcurrentLimit}
|
|
||||||
step={1}
|
|
||||||
min={1}
|
|
||||||
max={4}
|
|
||||||
onUpdate={(value) =>
|
|
||||||
updateSettings({
|
|
||||||
remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, Platform, Switch, TouchableOpacity } from "react-native";
|
import { Linking, Switch, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import Dropdown from "@/components/common/Dropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
|
||||||
BACKGROUND_FETCH_TASK,
|
|
||||||
registerBackgroundFetchAsync,
|
|
||||||
unregisterBackgroundFetchAsync,
|
|
||||||
} from "@/utils/background-tasks";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
@@ -27,39 +20,8 @@ export const OtherSettings: React.FC = () => {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
/********************
|
|
||||||
* Background task
|
|
||||||
*******************/
|
|
||||||
const checkStatusAsync = async () => {
|
|
||||||
if (Platform.isTV) return false;
|
|
||||||
return TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const registered = await checkStatusAsync();
|
|
||||||
|
|
||||||
if (settings?.autoDownload === true && !registered) {
|
|
||||||
registerBackgroundFetchAsync();
|
|
||||||
toast.success(t("home.settings.toasts.background_downloads_enabled"));
|
|
||||||
} else if (settings?.autoDownload === false && registered) {
|
|
||||||
unregisterBackgroundFetchAsync();
|
|
||||||
toast.info(t("home.settings.toasts.background_downloads_disabled"));
|
|
||||||
} else if (settings?.autoDownload === true && registered) {
|
|
||||||
// Don't to anything
|
|
||||||
} else if (settings?.autoDownload === false && !registered) {
|
|
||||||
// Don't to anything
|
|
||||||
} else {
|
|
||||||
updateSettings({ autoDownload: false });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [settings?.autoDownload]);
|
|
||||||
/**********************
|
|
||||||
*********************/
|
|
||||||
|
|
||||||
const disabled = useMemo(
|
const disabled = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pluginSettings?.followDeviceOrientation?.locked === true &&
|
|
||||||
pluginSettings?.defaultVideoOrientation?.locked === true &&
|
pluginSettings?.defaultVideoOrientation?.locked === true &&
|
||||||
pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
|
pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
|
||||||
pluginSettings?.showCustomMenuLinks?.locked === true &&
|
pluginSettings?.showCustomMenuLinks?.locked === true &&
|
||||||
@@ -89,41 +51,65 @@ export const OtherSettings: React.FC = () => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const orientationOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: orientations.map((orientation) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t(ScreenOrientationEnum[orientation]),
|
||||||
|
value: String(orientation),
|
||||||
|
selected: orientation === settings?.defaultVideoOrientation,
|
||||||
|
onPress: () =>
|
||||||
|
updateSettings({ defaultVideoOrientation: orientation }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[orientations, settings?.defaultVideoOrientation, t, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bitrateOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: BITRATES.map((bitrate) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate.key,
|
||||||
|
selected: bitrate.key === settings?.defaultBitrate?.key,
|
||||||
|
onPress: () => updateSettings({ defaultBitrate: bitrate }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[settings?.defaultBitrate?.key, t, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoPlayEpisodeOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: AUTOPLAY_EPISODES_COUNT(t).map((item) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: item.key,
|
||||||
|
value: item.key,
|
||||||
|
selected: item.key === settings?.maxAutoPlayEpisodeCount?.key,
|
||||||
|
onPress: () => updateSettings({ maxAutoPlayEpisodeCount: item }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled}>
|
<DisabledSetting disabled={disabled}>
|
||||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.follow_device_orientation")}
|
|
||||||
disabled={pluginSettings?.followDeviceOrientation?.locked}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.followDeviceOrientation}
|
|
||||||
disabled={pluginSettings?.followDeviceOrientation?.locked}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ followDeviceOrientation: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.video_orientation")}
|
title={t("home.settings.other.video_orientation")}
|
||||||
disabled={
|
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||||
pluginSettings?.defaultVideoOrientation?.locked ||
|
|
||||||
settings.followDeviceOrientation
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Dropdown
|
<PlatformDropdown
|
||||||
data={orientations}
|
groups={orientationOptions}
|
||||||
disabled={
|
trigger={
|
||||||
pluginSettings?.defaultVideoOrientation?.locked ||
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
settings.followDeviceOrientation
|
|
||||||
}
|
|
||||||
keyExtractor={String}
|
|
||||||
titleExtractor={(item) => t(ScreenOrientationEnum[item])}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
{t(
|
{t(
|
||||||
orientationTranslations[
|
orientationTranslations[
|
||||||
@@ -136,12 +122,9 @@ export const OtherSettings: React.FC = () => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
|
||||||
label={t("home.settings.other.orientation")}
|
|
||||||
onSelected={(defaultVideoOrientation) =>
|
|
||||||
updateSettings({ defaultVideoOrientation })
|
|
||||||
}
|
}
|
||||||
|
title={t("home.settings.other.orientation")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@@ -222,13 +205,10 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.default_quality")}
|
title={t("home.settings.other.default_quality")}
|
||||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||||
>
|
>
|
||||||
<Dropdown
|
<PlatformDropdown
|
||||||
data={BITRATES}
|
groups={bitrateOptions}
|
||||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
trigger={
|
||||||
keyExtractor={(item) => item.key}
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
titleExtractor={(item) => item.key}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
{settings.defaultBitrate?.key}
|
{settings.defaultBitrate?.key}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -237,10 +217,9 @@ export const OtherSettings: React.FC = () => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
}
|
||||||
label={t("home.settings.other.default_quality")}
|
title={t("home.settings.other.default_quality")}
|
||||||
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -256,12 +235,10 @@ export const OtherSettings: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||||
<Dropdown
|
<PlatformDropdown
|
||||||
data={AUTOPLAY_EPISODES_COUNT(t)}
|
groups={autoPlayEpisodeOptions}
|
||||||
keyExtractor={(item) => item.key}
|
trigger={
|
||||||
titleExtractor={(item) => item.key}
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -270,12 +247,9 @@ export const OtherSettings: React.FC = () => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
|
||||||
label={t("home.settings.other.max_auto_play_episode_count")}
|
|
||||||
onSelected={(maxAutoPlayEpisodeCount) =>
|
|
||||||
updateSettings({ maxAutoPlayEpisodeCount })
|
|
||||||
}
|
}
|
||||||
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const PluginSettings = () => {
|
|||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
return (
|
return (
|
||||||
<View>
|
<View className='mt-4'>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
title={t("home.settings.plugins.plugins_title")}
|
title={t("home.settings.plugins.plugins_title")}
|
||||||
className='mb-4'
|
className='mb-4'
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
|
||||||
|
|
||||||
const _DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import Dropdown from "@/components/common/Dropdown";
|
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
|
import {
|
||||||
|
OUTLINE_THICKNESS,
|
||||||
|
type OutlineThickness,
|
||||||
|
VLC_COLORS,
|
||||||
|
type VLCColor,
|
||||||
|
} from "@/constants/SubtitleConstants";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { PlatformDropdown } from "../PlatformDropdown";
|
||||||
import { useMedia } from "./MediaContext";
|
import { useMedia } from "./MediaContext";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
import { OUTLINE_THICKNESS, VLC_COLORS } from "@/constants/SubtitleConstants";
|
|
||||||
|
|
||||||
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
@@ -27,18 +29,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
const cultures = media.cultures;
|
const cultures = media.cultures;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Get VLC subtitle settings from the settings system
|
|
||||||
const textColor = settings?.vlcTextColor ?? "White";
|
|
||||||
const backgroundColor = settings?.vlcBackgroundColor ?? "Black";
|
|
||||||
const outlineColor = settings?.vlcOutlineColor ?? "Black";
|
|
||||||
const outlineThickness = settings?.vlcOutlineThickness ?? "Normal";
|
|
||||||
const backgroundOpacity = settings?.vlcBackgroundOpacity ?? 128;
|
|
||||||
const outlineOpacity = settings?.vlcOutlineOpacity ?? 255;
|
|
||||||
const isBold = settings?.vlcIsBold ?? false;
|
|
||||||
|
|
||||||
if (isTv) return null;
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
const subtitleModes = [
|
const subtitleModes = [
|
||||||
SubtitlePlaybackMode.Default,
|
SubtitlePlaybackMode.Default,
|
||||||
SubtitlePlaybackMode.Smart,
|
SubtitlePlaybackMode.Smart,
|
||||||
@@ -56,6 +46,133 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
|
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const subtitleLanguageOptionGroups = useMemo(() => {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("home.settings.subtitles.none"),
|
||||||
|
value: "none",
|
||||||
|
selected: !settings?.defaultSubtitleLanguage,
|
||||||
|
onPress: () => updateSettings({ defaultSubtitleLanguage: null }),
|
||||||
|
},
|
||||||
|
...(cultures?.map((culture) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: culture.DisplayName || "Unknown",
|
||||||
|
value:
|
||||||
|
culture.ThreeLetterISOLanguageName ||
|
||||||
|
culture.DisplayName ||
|
||||||
|
"unknown",
|
||||||
|
selected:
|
||||||
|
culture.ThreeLetterISOLanguageName ===
|
||||||
|
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName,
|
||||||
|
onPress: () => updateSettings({ defaultSubtitleLanguage: culture }),
|
||||||
|
})) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [cultures, settings?.defaultSubtitleLanguage, t, updateSettings]);
|
||||||
|
|
||||||
|
const subtitleModeOptionGroups = useMemo(() => {
|
||||||
|
const options = subtitleModes.map((mode) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t(subtitleModeKeys[mode]) || String(mode),
|
||||||
|
value: String(mode),
|
||||||
|
selected: mode === settings?.subtitleMode,
|
||||||
|
onPress: () => updateSettings({ subtitleMode: mode }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [settings?.subtitleMode, t, updateSettings]);
|
||||||
|
|
||||||
|
const textColorOptionGroups = useMemo(() => {
|
||||||
|
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||||
|
const options = colors.map((color) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t(`home.settings.subtitles.colors.${color}`),
|
||||||
|
value: color,
|
||||||
|
selected: (settings?.vlcTextColor || "White") === color,
|
||||||
|
onPress: () => updateSettings({ vlcTextColor: color }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [{ options }];
|
||||||
|
}, [settings?.vlcTextColor, t, updateSettings]);
|
||||||
|
|
||||||
|
const backgroundColorOptionGroups = useMemo(() => {
|
||||||
|
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||||
|
const options = colors.map((color) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t(`home.settings.subtitles.colors.${color}`),
|
||||||
|
value: color,
|
||||||
|
selected: (settings?.vlcBackgroundColor || "Black") === color,
|
||||||
|
onPress: () => updateSettings({ vlcBackgroundColor: color }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [{ options }];
|
||||||
|
}, [settings?.vlcBackgroundColor, t, updateSettings]);
|
||||||
|
|
||||||
|
const outlineColorOptionGroups = useMemo(() => {
|
||||||
|
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||||
|
const options = colors.map((color) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t(`home.settings.subtitles.colors.${color}`),
|
||||||
|
value: color,
|
||||||
|
selected: (settings?.vlcOutlineColor || "Black") === color,
|
||||||
|
onPress: () => updateSettings({ vlcOutlineColor: color }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [{ options }];
|
||||||
|
}, [settings?.vlcOutlineColor, t, updateSettings]);
|
||||||
|
|
||||||
|
const outlineThicknessOptionGroups = useMemo(() => {
|
||||||
|
const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
|
||||||
|
const options = thicknesses.map((thickness) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t(`home.settings.subtitles.thickness.${thickness}`),
|
||||||
|
value: thickness,
|
||||||
|
selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
|
||||||
|
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [{ options }];
|
||||||
|
}, [settings?.vlcOutlineThickness, t, updateSettings]);
|
||||||
|
|
||||||
|
const backgroundOpacityOptionGroups = useMemo(() => {
|
||||||
|
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||||
|
const options = opacities.map((opacity) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: `${Math.round((opacity / 255) * 100)}%`,
|
||||||
|
value: opacity,
|
||||||
|
selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
|
||||||
|
onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [{ options }];
|
||||||
|
}, [settings?.vlcBackgroundOpacity, updateSettings]);
|
||||||
|
|
||||||
|
const outlineOpacityOptionGroups = useMemo(() => {
|
||||||
|
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||||
|
const options = opacities.map((opacity) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: `${Math.round((opacity / 255) * 100)}%`,
|
||||||
|
value: opacity,
|
||||||
|
selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
|
||||||
|
onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [{ options }];
|
||||||
|
}, [settings?.vlcOutlineOpacity, updateSettings]);
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
@@ -67,20 +184,10 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
||||||
<Dropdown
|
<PlatformDropdown
|
||||||
data={[
|
groups={subtitleLanguageOptionGroups}
|
||||||
{
|
trigger={
|
||||||
DisplayName: t("home.settings.subtitles.none"),
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
ThreeLetterISOLanguageName: "none-subs",
|
|
||||||
},
|
|
||||||
...(cultures ?? []),
|
|
||||||
]}
|
|
||||||
keyExtractor={(item) =>
|
|
||||||
item?.ThreeLetterISOLanguageName ?? "unknown"
|
|
||||||
}
|
|
||||||
titleExtractor={(item) => item?.DisplayName}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
{settings?.defaultSubtitleLanguage?.DisplayName ||
|
{settings?.defaultSubtitleLanguage?.DisplayName ||
|
||||||
t("home.settings.subtitles.none")}
|
t("home.settings.subtitles.none")}
|
||||||
@@ -90,18 +197,9 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
|
||||||
label={t("home.settings.subtitles.language")}
|
|
||||||
onSelected={(defaultSubtitleLanguage) =>
|
|
||||||
updateSettings({
|
|
||||||
defaultSubtitleLanguage:
|
|
||||||
defaultSubtitleLanguage.DisplayName ===
|
|
||||||
t("home.settings.subtitles.none")
|
|
||||||
? null
|
|
||||||
: defaultSubtitleLanguage,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
title={t("home.settings.subtitles.language")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@@ -109,13 +207,10 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.subtitles.subtitle_mode")}
|
title={t("home.settings.subtitles.subtitle_mode")}
|
||||||
disabled={pluginSettings?.subtitleMode?.locked}
|
disabled={pluginSettings?.subtitleMode?.locked}
|
||||||
>
|
>
|
||||||
<Dropdown
|
<PlatformDropdown
|
||||||
data={subtitleModes}
|
groups={subtitleModeOptionGroups}
|
||||||
disabled={pluginSettings?.subtitleMode?.locked}
|
trigger={
|
||||||
keyExtractor={String}
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
titleExtractor={(item) => t(subtitleModeKeys[item]) || String(item)}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
{t(subtitleModeKeys[settings?.subtitleMode]) ||
|
{t(subtitleModeKeys[settings?.subtitleMode]) ||
|
||||||
t("home.settings.subtitles.loading")}
|
t("home.settings.subtitles.loading")}
|
||||||
@@ -125,10 +220,9 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
}
|
||||||
label={t("home.settings.subtitles.subtitle_mode")}
|
title={t("home.settings.subtitles.subtitle_mode")}
|
||||||
onSelected={(subtitleMode) => updateSettings({ subtitleMode })}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@@ -159,144 +253,120 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.subtitles.text_color")}>
|
<ListItem title={t("home.settings.subtitles.text_color")}>
|
||||||
<Dropdown
|
<PlatformDropdown
|
||||||
data={Object.keys(VLC_COLORS)}
|
groups={textColorOptionGroups}
|
||||||
keyExtractor={(item) => item}
|
trigger={
|
||||||
titleExtractor={(item) =>
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
t(`home.settings.subtitles.colors.${item}`)
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
{t(`home.settings.subtitles.colors.${textColor}`)}
|
{t(
|
||||||
|
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-expand-sharp'
|
name='chevron-expand-sharp'
|
||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
}
|
||||||
label={t("home.settings.subtitles.text_color")}
|
title={t("home.settings.subtitles.text_color")}
|
||||||
onSelected={(value) => updateSettings({ vlcTextColor: value })}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.subtitles.background_color")}>
|
<ListItem title={t("home.settings.subtitles.background_color")}>
|
||||||
<Dropdown
|
<PlatformDropdown
|
||||||
data={Object.keys(VLC_COLORS)}
|
groups={backgroundColorOptionGroups}
|
||||||
keyExtractor={(item) => item}
|
trigger={
|
||||||
titleExtractor={(item) =>
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
t(`home.settings.subtitles.colors.${item}`)
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
{t(`home.settings.subtitles.colors.${backgroundColor}`)}
|
{t(
|
||||||
|
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-expand-sharp'
|
name='chevron-expand-sharp'
|
||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
|
||||||
label={t("home.settings.subtitles.background_color")}
|
|
||||||
onSelected={(value) =>
|
|
||||||
updateSettings({ vlcBackgroundColor: value })
|
|
||||||
}
|
}
|
||||||
|
title={t("home.settings.subtitles.background_color")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.subtitles.outline_color")}>
|
<ListItem title={t("home.settings.subtitles.outline_color")}>
|
||||||
<Dropdown
|
<PlatformDropdown
|
||||||
data={Object.keys(VLC_COLORS)}
|
groups={outlineColorOptionGroups}
|
||||||
keyExtractor={(item) => item}
|
trigger={
|
||||||
titleExtractor={(item) =>
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
t(`home.settings.subtitles.colors.${item}`)
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
{t(`home.settings.subtitles.colors.${outlineColor}`)}
|
{t(
|
||||||
|
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-expand-sharp'
|
name='chevron-expand-sharp'
|
||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
}
|
||||||
label={t("home.settings.subtitles.outline_color")}
|
title={t("home.settings.subtitles.outline_color")}
|
||||||
onSelected={(value) => updateSettings({ vlcOutlineColor: value })}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
|
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
|
||||||
<Dropdown
|
<PlatformDropdown
|
||||||
data={Object.keys(OUTLINE_THICKNESS)}
|
groups={outlineThicknessOptionGroups}
|
||||||
keyExtractor={(item) => item}
|
trigger={
|
||||||
titleExtractor={(item) =>
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
t(`home.settings.subtitles.thickness.${item}`)
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
{t(`home.settings.subtitles.thickness.${outlineThickness}`)}
|
{t(
|
||||||
|
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-expand-sharp'
|
name='chevron-expand-sharp'
|
||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
|
||||||
label={t("home.settings.subtitles.outline_thickness")}
|
|
||||||
onSelected={(value) =>
|
|
||||||
updateSettings({ vlcOutlineThickness: value })
|
|
||||||
}
|
}
|
||||||
|
title={t("home.settings.subtitles.outline_thickness")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||||
<Dropdown
|
<PlatformDropdown
|
||||||
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
|
groups={backgroundOpacityOptionGroups}
|
||||||
keyExtractor={String}
|
trigger={
|
||||||
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
title={
|
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((backgroundOpacity / 255) * 100)}%`}</Text>
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-expand-sharp'
|
name='chevron-expand-sharp'
|
||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
|
||||||
label={t("home.settings.subtitles.background_opacity")}
|
|
||||||
onSelected={(value) =>
|
|
||||||
updateSettings({ vlcBackgroundOpacity: value })
|
|
||||||
}
|
}
|
||||||
|
title={t("home.settings.subtitles.background_opacity")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
|
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
|
||||||
<Dropdown
|
<PlatformDropdown
|
||||||
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
|
groups={outlineOpacityOptionGroups}
|
||||||
keyExtractor={String}
|
trigger={
|
||||||
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
|
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||||
title={
|
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((outlineOpacity / 255) * 100)}%`}</Text>
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-expand-sharp'
|
name='chevron-expand-sharp'
|
||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
}
|
||||||
label={t("home.settings.subtitles.outline_opacity")}
|
title={t("home.settings.subtitles.outline_opacity")}
|
||||||
onSelected={(value) => updateSettings({ vlcOutlineOpacity: value })}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.subtitles.bold_text")}>
|
<ListItem title={t("home.settings.subtitles.bold_text")}>
|
||||||
<Switch
|
<Switch
|
||||||
value={isBold}
|
value={settings?.vlcIsBold ?? false}
|
||||||
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|||||||
@@ -114,10 +114,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
className='flex flex-col items-start shrink'
|
||||||
flexDirection: "column",
|
|
||||||
alignSelf: "flex-end",
|
|
||||||
}}
|
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
>
|
>
|
||||||
{item?.Type === "Episode" && (
|
{item?.Type === "Episode" && (
|
||||||
@@ -133,7 +130,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row space-x-2'>
|
<View className='flex flex-row space-x-2 shrink-0'>
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export const Controls: FC<Props> = ({
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
item?.Id!,
|
item.Id!,
|
||||||
currentTime,
|
currentTime,
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
@@ -332,7 +332,7 @@ export const Controls: FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||||
item?.Id!,
|
item.Id!,
|
||||||
currentTime,
|
currentTime,
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
const downloadedFiles = getDownloadedItems();
|
const downloadedFiles = useMemo(
|
||||||
|
() => getDownloadedItems(),
|
||||||
|
[getDownloadedItems],
|
||||||
|
);
|
||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||||
|
|
||||||
@@ -68,13 +71,13 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
const seriesEpisodes = downloadedFiles?.filter(
|
const seriesEpisodes = downloadedFiles?.filter(
|
||||||
(f: DownloadedItem) => f.item.SeriesId === item.SeriesId,
|
(f: DownloadedItem) => f.item.SeriesId === item.SeriesId,
|
||||||
);
|
);
|
||||||
const seasonNumbers = [
|
const seasonNumbers = Array.from(
|
||||||
...new Set(
|
new Set(
|
||||||
seriesEpisodes
|
seriesEpisodes
|
||||||
?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
|
?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
),
|
),
|
||||||
];
|
);
|
||||||
// Create fake season objects
|
// Create fake season objects
|
||||||
return seasonNumbers.map((seasonNumber) => ({
|
return seasonNumbers.map((seasonNumber) => ({
|
||||||
Id: seasonNumber?.toString(),
|
Id: seasonNumber?.toString(),
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
className={"flex flex-row w-full pt-2"}
|
className={"flex flex-row w-full pt-2"}
|
||||||
>
|
>
|
||||||
<View className='mr-auto'>
|
<View className='mr-auto' pointerEvents='box-none'>
|
||||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||||
<VideoProvider
|
<VideoProvider
|
||||||
getAudioTracks={getAudioTracks}
|
getAudioTracks={getAudioTracks}
|
||||||
@@ -120,7 +120,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
setSubtitleTrack={setSubtitleTrack}
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
setSubtitleURL={setSubtitleURL}
|
setSubtitleURL={setSubtitleURL}
|
||||||
>
|
>
|
||||||
<DropdownView />
|
<View pointerEvents='auto'>
|
||||||
|
<DropdownView />
|
||||||
|
</View>
|
||||||
</VideoProvider>
|
</VideoProvider>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React, { useState } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import {
|
||||||
import { FilterSheet } from "@/components/filters/FilterSheet";
|
type OptionGroup,
|
||||||
|
PlatformDropdown,
|
||||||
|
} from "@/components/PlatformDropdown";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
export type ScaleFactor =
|
export type ScaleFactor =
|
||||||
@@ -94,56 +96,51 @@ export const ScaleFactorSelector: React.FC<ScaleFactorSelectorProps> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
// Hide on TV platforms
|
|
||||||
if (Platform.isTV) return null;
|
|
||||||
|
|
||||||
const handleScaleSelect = (scale: ScaleFactor) => {
|
const handleScaleSelect = (scale: ScaleFactor) => {
|
||||||
onScaleChange(scale);
|
onScaleChange(scale);
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentOption = SCALE_FACTOR_OPTIONS.find(
|
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||||
(option) => option.id === currentScale,
|
return [
|
||||||
);
|
{
|
||||||
|
options: SCALE_FACTOR_OPTIONS.map((option) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: option.label,
|
||||||
|
value: option.id,
|
||||||
|
selected: option.id === currentScale,
|
||||||
|
onPress: () => handleScaleSelect(option.id),
|
||||||
|
disabled,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentScale, disabled]);
|
||||||
|
|
||||||
return (
|
const trigger = useMemo(
|
||||||
<>
|
() => (
|
||||||
<TouchableOpacity
|
<View
|
||||||
disabled={disabled}
|
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||||
onPress={() => setOpen(true)}
|
|
||||||
>
|
>
|
||||||
<Ionicons name='search-outline' size={24} color='white' />
|
<Ionicons name='search-outline' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
),
|
||||||
|
[disabled],
|
||||||
|
);
|
||||||
|
|
||||||
<FilterSheet
|
// Hide on TV platforms
|
||||||
open={open}
|
if (Platform.isTV) return null;
|
||||||
setOpen={setOpen}
|
|
||||||
title='Scale Factor'
|
return (
|
||||||
data={SCALE_FACTOR_OPTIONS}
|
<PlatformDropdown
|
||||||
values={currentOption ? [currentOption] : []}
|
title='Scale Factor'
|
||||||
multiple={false}
|
groups={optionGroups}
|
||||||
searchFilter={(item, query) => {
|
trigger={trigger}
|
||||||
const option = item as ScaleFactorOption;
|
bottomSheetConfig={{
|
||||||
return (
|
enablePanDownToClose: true,
|
||||||
option.label.toLowerCase().includes(query.toLowerCase()) ||
|
}}
|
||||||
option.description.toLowerCase().includes(query.toLowerCase())
|
/>
|
||||||
);
|
|
||||||
}}
|
|
||||||
renderItemLabel={(item) => {
|
|
||||||
const option = item as ScaleFactorOption;
|
|
||||||
return <Text>{option.label}</Text>;
|
|
||||||
}}
|
|
||||||
set={(vals) => {
|
|
||||||
const chosen = vals[0] as ScaleFactorOption | undefined;
|
|
||||||
if (chosen) {
|
|
||||||
handleScaleSelect(chosen.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ const SkipButton: React.FC<SkipButtonProps> = ({
|
|||||||
buttonText,
|
buttonText,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
console.log(`[SKIP_BUTTON] Render:`, {
|
||||||
|
buttonText,
|
||||||
|
showButton,
|
||||||
|
className: showButton ? "flex" : "hidden",
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={showButton ? "flex" : "hidden"} {...props}>
|
<View className={showButton ? "flex" : "hidden"} {...props}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React, { useState } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import {
|
||||||
import { FilterSheet } from "@/components/filters/FilterSheet";
|
type OptionGroup,
|
||||||
|
PlatformDropdown,
|
||||||
|
} from "@/components/PlatformDropdown";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
|
export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
|
||||||
@@ -53,56 +55,51 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
// Hide on TV platforms
|
|
||||||
if (Platform.isTV) return null;
|
|
||||||
|
|
||||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||||
onRatioChange(ratio);
|
onRatioChange(ratio);
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentOption = ASPECT_RATIO_OPTIONS.find(
|
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||||
(option) => option.id === currentRatio,
|
return [
|
||||||
);
|
{
|
||||||
|
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: option.label,
|
||||||
|
value: option.id,
|
||||||
|
selected: option.id === currentRatio,
|
||||||
|
onPress: () => handleRatioSelect(option.id),
|
||||||
|
disabled,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentRatio, disabled]);
|
||||||
|
|
||||||
return (
|
const trigger = useMemo(
|
||||||
<>
|
() => (
|
||||||
<TouchableOpacity
|
<View
|
||||||
disabled={disabled}
|
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||||
onPress={() => setOpen(true)}
|
|
||||||
>
|
>
|
||||||
<Ionicons name='crop-outline' size={24} color='white' />
|
<Ionicons name='crop-outline' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
),
|
||||||
|
[disabled],
|
||||||
|
);
|
||||||
|
|
||||||
<FilterSheet
|
// Hide on TV platforms
|
||||||
open={open}
|
if (Platform.isTV) return null;
|
||||||
setOpen={setOpen}
|
|
||||||
title='Aspect Ratio'
|
return (
|
||||||
data={ASPECT_RATIO_OPTIONS}
|
<PlatformDropdown
|
||||||
values={currentOption ? [currentOption] : []}
|
title='Aspect Ratio'
|
||||||
multiple={false}
|
groups={optionGroups}
|
||||||
searchFilter={(item, query) => {
|
trigger={trigger}
|
||||||
const option = item as AspectRatioOption;
|
bottomSheetConfig={{
|
||||||
return (
|
enablePanDownToClose: true,
|
||||||
option.label.toLowerCase().includes(query.toLowerCase()) ||
|
}}
|
||||||
option.description.toLowerCase().includes(query.toLowerCase())
|
/>
|
||||||
);
|
|
||||||
}}
|
|
||||||
renderItemLabel={(item) => {
|
|
||||||
const option = item as AspectRatioOption;
|
|
||||||
return <Text>{option.label}</Text>;
|
|
||||||
}}
|
|
||||||
set={(vals) => {
|
|
||||||
const chosen = vals[0] as AspectRatioOption | undefined;
|
|
||||||
if (chosen) {
|
|
||||||
handleRatioSelect(chosen.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { Text } from "@/components/common/Text";
|
import {
|
||||||
|
type OptionGroup,
|
||||||
|
PlatformDropdown,
|
||||||
|
} from "@/components/PlatformDropdown";
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
import { useControlContext } from "../contexts/ControlContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
|
|
||||||
@@ -23,10 +19,6 @@ const DropdownView = () => {
|
|||||||
ControlContext?.mediaSource,
|
ControlContext?.mediaSource,
|
||||||
];
|
];
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
const snapPoints = useMemo(() => ["75%"], []);
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||||
useLocalSearchParams<{
|
useLocalSearchParams<{
|
||||||
@@ -39,248 +31,127 @@ const DropdownView = () => {
|
|||||||
offline: string;
|
offline: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// Use ref to track playbackPosition without causing re-renders
|
||||||
|
const playbackPositionRef = useRef(playbackPosition);
|
||||||
|
playbackPositionRef.current = playbackPosition;
|
||||||
|
|
||||||
const isOffline = offline === "true";
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
|
// Stabilize IDs to prevent unnecessary recalculations
|
||||||
|
const itemIdRef = useRef(item.Id);
|
||||||
|
const mediaSourceIdRef = useRef(mediaSource?.Id);
|
||||||
|
itemIdRef.current = item.Id;
|
||||||
|
mediaSourceIdRef.current = mediaSource?.Id;
|
||||||
|
|
||||||
const changeBitrate = useCallback(
|
const changeBitrate = useCallback(
|
||||||
(bitrate: string) => {
|
(bitrate: string) => {
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id ?? "",
|
itemId: itemIdRef.current ?? "",
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
subtitleIndex: subtitleIndex.toString() ?? "",
|
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: mediaSource?.Id ?? "",
|
mediaSourceId: mediaSourceIdRef.current ?? "",
|
||||||
bitrateValue: bitrate.toString(),
|
bitrateValue: bitrate.toString(),
|
||||||
playbackPosition: playbackPosition,
|
playbackPosition: playbackPositionRef.current,
|
||||||
}).toString();
|
}).toString();
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
},
|
},
|
||||||
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
|
[audioIndex, subtitleIndex, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
// Create stable identifiers for tracks
|
||||||
if (index === -1) {
|
const subtitleTracksKey = useMemo(
|
||||||
setOpen(false);
|
() => subtitleTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
|
||||||
}
|
[subtitleTracks],
|
||||||
}, []);
|
);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const audioTracksKey = useMemo(
|
||||||
(props: BottomSheetBackdropProps) => (
|
() => audioTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
|
||||||
<BottomSheetBackdrop
|
[audioTracks],
|
||||||
{...props}
|
);
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
// Transform sections into OptionGroup format
|
||||||
/>
|
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||||
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
|
// Quality Section
|
||||||
|
if (!isOffline) {
|
||||||
|
groups.push({
|
||||||
|
title: "Quality",
|
||||||
|
options:
|
||||||
|
BITRATES?.map((bitrate) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate.value?.toString() ?? "",
|
||||||
|
selected: bitrateValue === (bitrate.value?.toString() ?? ""),
|
||||||
|
onPress: () => changeBitrate(bitrate.value?.toString() ?? ""),
|
||||||
|
})) || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle Section
|
||||||
|
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
title: "Subtitles",
|
||||||
|
options: subtitleTracks.map((sub) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: sub.name,
|
||||||
|
value: sub.index.toString(),
|
||||||
|
selected: subtitleIndex === sub.index.toString(),
|
||||||
|
onPress: () => sub.setTrack(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio Section
|
||||||
|
if (audioTracks && audioTracks.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
title: "Audio",
|
||||||
|
options: audioTracks.map((track) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: track.name,
|
||||||
|
value: track.index.toString(),
|
||||||
|
selected: audioIndex === track.index.toString(),
|
||||||
|
onPress: () => track.setTrack(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
isOffline,
|
||||||
|
bitrateValue,
|
||||||
|
changeBitrate,
|
||||||
|
subtitleTracksKey,
|
||||||
|
audioTracksKey,
|
||||||
|
subtitleIndex,
|
||||||
|
audioIndex,
|
||||||
|
// Note: subtitleTracks and audioTracks are intentionally excluded
|
||||||
|
// because we use subtitleTracksKey and audioTracksKey for stability
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Memoize the trigger to prevent re-renders
|
||||||
|
const trigger = useMemo(
|
||||||
|
() => (
|
||||||
|
<View className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'>
|
||||||
|
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
|
||||||
|
</View>
|
||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpen = () => {
|
|
||||||
setOpen(true);
|
|
||||||
bottomSheetModalRef.current?.present();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setOpen(false);
|
|
||||||
bottomSheetModalRef.current?.dismiss();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) bottomSheetModalRef.current?.present();
|
|
||||||
else bottomSheetModalRef.current?.dismiss();
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
// Hide on TV platforms
|
// Hide on TV platforms
|
||||||
if (Platform.isTV) return null;
|
if (Platform.isTV) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<PlatformDropdown
|
||||||
<TouchableOpacity
|
title='Playback Options'
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
groups={optionGroups}
|
||||||
onPress={handleOpen}
|
trigger={trigger}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
|
enablePanDownToClose: true,
|
||||||
</TouchableOpacity>
|
}}
|
||||||
|
/>
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
index={0}
|
|
||||||
snapPoints={snapPoints}
|
|
||||||
onChange={handleSheetChanges}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BottomSheetScrollView
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='mt-2 mb-8'
|
|
||||||
style={{
|
|
||||||
paddingLeft: Math.max(16, insets.left),
|
|
||||||
paddingRight: Math.max(16, insets.right),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='font-bold text-2xl mb-6'>Playback Options</Text>
|
|
||||||
|
|
||||||
{/* Quality Section */}
|
|
||||||
{!isOffline && (
|
|
||||||
<View className='mb-6'>
|
|
||||||
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
|
|
||||||
Quality
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
borderRadius: 20,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
className='flex flex-col rounded-xl overflow-hidden'
|
|
||||||
>
|
|
||||||
{BITRATES?.map((bitrate, idx: number) => (
|
|
||||||
<View key={`quality-item-${idx}`}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
changeBitrate(bitrate.value?.toString() ?? "");
|
|
||||||
setTimeout(() => handleClose(), 250);
|
|
||||||
}}
|
|
||||||
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
|
||||||
>
|
|
||||||
<Text className='flex shrink'>{bitrate.key}</Text>
|
|
||||||
{bitrateValue === (bitrate.value?.toString() ?? "") ? (
|
|
||||||
<Ionicons
|
|
||||||
name='radio-button-on'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Ionicons
|
|
||||||
name='radio-button-off'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
{idx < BITRATES.length - 1 && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
}}
|
|
||||||
className='bg-neutral-700'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subtitle Section */}
|
|
||||||
<View className='mb-6'>
|
|
||||||
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
|
|
||||||
Subtitles
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
borderRadius: 20,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
className='flex flex-col rounded-xl overflow-hidden'
|
|
||||||
>
|
|
||||||
{subtitleTracks?.map((sub, idx: number) => (
|
|
||||||
<View key={`subtitle-item-${idx}`}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
sub.setTrack();
|
|
||||||
setTimeout(() => handleClose(), 250);
|
|
||||||
}}
|
|
||||||
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
|
||||||
>
|
|
||||||
<Text className='flex shrink'>{sub.name}</Text>
|
|
||||||
{subtitleIndex === sub.index.toString() ? (
|
|
||||||
<Ionicons
|
|
||||||
name='radio-button-on'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Ionicons
|
|
||||||
name='radio-button-off'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
{idx < (subtitleTracks?.length ?? 0) - 1 && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
}}
|
|
||||||
className='bg-neutral-700'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Audio Section */}
|
|
||||||
{(audioTracks?.length ?? 0) > 0 && (
|
|
||||||
<View className='mb-6'>
|
|
||||||
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
|
|
||||||
Audio
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
borderRadius: 20,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
className='flex flex-col rounded-xl overflow-hidden'
|
|
||||||
>
|
|
||||||
{audioTracks?.map((track, idx: number) => (
|
|
||||||
<View key={`audio-item-${idx}`}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
track.setTrack();
|
|
||||||
setTimeout(() => handleClose(), 250);
|
|
||||||
}}
|
|
||||||
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
|
||||||
>
|
|
||||||
<Text className='flex shrink'>{track.name}</Text>
|
|
||||||
{audioIndex === track.index.toString() ? (
|
|
||||||
<Ionicons
|
|
||||||
name='radio-button-on'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Ionicons
|
|
||||||
name='radio-button-off'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
{idx < (audioTracks?.length ?? 0) - 1 && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
}}
|
|
||||||
className='bg-neutral-700'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</BottomSheetScrollView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTVEventHandler } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import { CONTROLS_CONSTANTS } from "../constants";
|
import { CONTROLS_CONSTANTS } from "../constants";
|
||||||
|
|
||||||
|
// TV event handler with fallback for non-TV platforms
|
||||||
|
let useTVEventHandler: (callback: (evt: any) => void) => void;
|
||||||
|
if (Platform.isTV) {
|
||||||
|
try {
|
||||||
|
useTVEventHandler = require("react-native").useTVEventHandler;
|
||||||
|
} catch {
|
||||||
|
// Fallback for non-TV platforms
|
||||||
|
useTVEventHandler = () => {};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No-op hook for non-TV platforms
|
||||||
|
useTVEventHandler = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
interface UseRemoteControlProps {
|
interface UseRemoteControlProps {
|
||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
min: SharedValue<number>;
|
min: SharedValue<number>;
|
||||||
@@ -63,6 +77,7 @@ export function useRemoteControl({
|
|||||||
[isVlc],
|
[isVlc],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TV remote control handling (no-op on non-TV platforms)
|
||||||
useTVEventHandler((evt) => {
|
useTVEventHandler((evt) => {
|
||||||
if (!evt) return;
|
if (!evt) return;
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const useControlsTimeout = ({
|
|||||||
isSliding,
|
isSliding,
|
||||||
episodeView,
|
episodeView,
|
||||||
onHideControls,
|
onHideControls,
|
||||||
timeout = 4000,
|
timeout = 10000,
|
||||||
}: UseControlsTimeoutProps) => {
|
}: UseControlsTimeoutProps) => {
|
||||||
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
|||||||
58
docs/nested-modals.md
Normal file
58
docs/nested-modals.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Nested Modals with PlatformDropdown
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
PlatformDropdowns inside BottomSheetModals don't open on Android.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
1. **Add controlled state** for each PlatformDropdown:
|
||||||
|
```tsx
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
<PlatformDropdown
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use `View` for triggers, not `TouchableOpacity`**:
|
||||||
|
```tsx
|
||||||
|
// ✅ Correct
|
||||||
|
<PlatformDropdown
|
||||||
|
trigger={<View>...</View>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// ❌ Wrong - causes nested TouchableOpacity conflicts
|
||||||
|
<PlatformDropdown
|
||||||
|
trigger={<TouchableOpacity>...</TouchableOpacity>}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add `stackBehavior='push'` to parent BottomSheetModal**:
|
||||||
|
```tsx
|
||||||
|
<BottomSheetModal
|
||||||
|
stackBehavior='push'
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Reset dropdown states on modal dismiss**:
|
||||||
|
```tsx
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setDropdown1Open(false);
|
||||||
|
setDropdown2Open(false);
|
||||||
|
// reset all dropdown states
|
||||||
|
onDismiss?.();
|
||||||
|
}, [onDismiss]);
|
||||||
|
|
||||||
|
<BottomSheetModal
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why
|
||||||
|
- PlatformDropdown wraps triggers in TouchableOpacity on Android. Nested TouchableOpacity causes touch event conflicts.
|
||||||
|
- PlatformDropdown's useEffect should only call `showModal()` when `open === true`, not call `hideModal()` when `open === false` (interferes with parent modals).
|
||||||
|
- Dropdown states must be reset on modal dismiss to prevent them from reopening automatically when parent modal reopens.
|
||||||
|
|
||||||
6
eas.json
6
eas.json
@@ -45,14 +45,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.40.4",
|
"channel": "0.46.2",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.40.4",
|
"channel": "0.46.2",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.40.4",
|
"channel": "0.46.2",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -43,26 +43,60 @@ export const useIntroSkipper = (
|
|||||||
const introTimestamps = segments?.introSegments?.[0];
|
const introTimestamps = segments?.introSegments?.[0];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log(`[INTRO_SKIPPER] Hook state:`, {
|
||||||
|
itemId,
|
||||||
|
currentTime,
|
||||||
|
hasSegments: !!segments,
|
||||||
|
segments: segments,
|
||||||
|
introSegmentsCount: segments?.introSegments?.length || 0,
|
||||||
|
introSegments: segments?.introSegments,
|
||||||
|
hasIntroTimestamps: !!introTimestamps,
|
||||||
|
introTimestamps,
|
||||||
|
isVlc,
|
||||||
|
isOffline,
|
||||||
|
});
|
||||||
|
|
||||||
if (introTimestamps) {
|
if (introTimestamps) {
|
||||||
setShowSkipButton(
|
const shouldShow =
|
||||||
currentTime > introTimestamps.startTime &&
|
currentTime > introTimestamps.startTime &&
|
||||||
currentTime < introTimestamps.endTime,
|
currentTime < introTimestamps.endTime;
|
||||||
);
|
|
||||||
|
console.log(`[INTRO_SKIPPER] Button visibility check:`, {
|
||||||
|
currentTime,
|
||||||
|
introStart: introTimestamps.startTime,
|
||||||
|
introEnd: introTimestamps.endTime,
|
||||||
|
afterStart: currentTime > introTimestamps.startTime,
|
||||||
|
beforeEnd: currentTime < introTimestamps.endTime,
|
||||||
|
shouldShow,
|
||||||
|
});
|
||||||
|
|
||||||
|
setShowSkipButton(shouldShow);
|
||||||
|
} else {
|
||||||
|
if (showSkipButton) {
|
||||||
|
console.log(`[INTRO_SKIPPER] No intro timestamps, hiding button`);
|
||||||
|
setShowSkipButton(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [introTimestamps, currentTime]);
|
}, [introTimestamps, currentTime, showSkipButton]);
|
||||||
|
|
||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
|
console.log(
|
||||||
|
`[INTRO_SKIPPER] Skipping intro to:`,
|
||||||
|
introTimestamps.endTime,
|
||||||
|
);
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
wrappedSeek(introTimestamps.endTime);
|
wrappedSeek(introTimestamps.endTime);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
play();
|
play();
|
||||||
}, 200);
|
}, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error skipping intro", error);
|
console.error("[INTRO_SKIPPER] Error skipping intro", error);
|
||||||
}
|
}
|
||||||
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||||
|
|
||||||
|
console.log(`[INTRO_SKIPPER] Returning state:`, { showSkipButton });
|
||||||
|
|
||||||
return { showSkipButton, skipIntro };
|
return { showSkipButton, skipIntro };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ const JELLYSEERR_USER = "JELLYSEERR_USER";
|
|||||||
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
|
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
|
||||||
|
|
||||||
export const clearJellyseerrStorageData = () => {
|
export const clearJellyseerrStorageData = () => {
|
||||||
storage.delete(JELLYSEERR_USER);
|
storage.remove(JELLYSEERR_USER);
|
||||||
storage.delete(JELLYSEERR_COOKIES);
|
storage.remove(JELLYSEERR_COOKIES);
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum Endpoints {
|
export enum Endpoints {
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||||
|
import { Orientation } from "../packages/expo-screen-orientation.tv";
|
||||||
|
|
||||||
|
const orientationToOrientationLock = (
|
||||||
|
orientation: Orientation,
|
||||||
|
): OrientationLock => {
|
||||||
|
switch (orientation) {
|
||||||
|
case Orientation.LANDSCAPE_LEFT:
|
||||||
|
return OrientationLock.LANDSCAPE_LEFT;
|
||||||
|
case Orientation.LANDSCAPE_RIGHT:
|
||||||
|
return OrientationLock.LANDSCAPE_RIGHT;
|
||||||
|
case Orientation.PORTRAIT_UP:
|
||||||
|
return OrientationLock.PORTRAIT_UP;
|
||||||
|
default:
|
||||||
|
return OrientationLock.PORTRAIT_UP;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const useOrientation = () => {
|
export const useOrientation = () => {
|
||||||
const [orientation, setOrientation] = useState(
|
const [orientation, setOrientation] = useState(
|
||||||
@@ -29,5 +45,20 @@ export const useOrientation = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { orientation, setOrientation };
|
const lockOrientation = async (lock: OrientationLock) => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (lock === ScreenOrientation.OrientationLock.DEFAULT) {
|
||||||
|
await ScreenOrientation.unlockAsync();
|
||||||
|
} else {
|
||||||
|
await ScreenOrientation.lockAsync(lock);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlockOrientation = async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
await ScreenOrientation.unlockAsync();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { orientation, setOrientation, lockOrientation, unlockOrientation };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
|
||||||
/** @type {import('expo/metro-config').MetroConfig} */
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
// Add Hermes parser
|
// Add Hermes parser
|
||||||
config.transformer.hermesParser = true;
|
config.transformer.hermesParser = true;
|
||||||
|
|||||||
258
modules/background-downloader/README.md
Normal file
258
modules/background-downloader/README.md
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# Background Downloader Module
|
||||||
|
|
||||||
|
A native iOS and Android module for downloading large files in the background using `NSURLSession` (iOS) and `DownloadManager` (Android).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Background Downloads**: Downloads continue even when the app is backgrounded or suspended
|
||||||
|
- **Progress Tracking**: Real-time progress updates via events
|
||||||
|
- **Multiple Downloads**: Support for concurrent downloads
|
||||||
|
- **Cancellation**: Cancel individual or all downloads
|
||||||
|
- **Custom Destination**: Optionally specify custom file paths
|
||||||
|
- **Error Handling**: Comprehensive error reporting
|
||||||
|
- **Cross-Platform**: Works on both iOS and Android
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BackgroundDownloader } from '@/modules';
|
||||||
|
|
||||||
|
// Start a download
|
||||||
|
const taskId = await BackgroundDownloader.startDownload(
|
||||||
|
'https://example.com/largefile.mp4'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for progress updates
|
||||||
|
const progressSub = BackgroundDownloader.addProgressListener((event) => {
|
||||||
|
console.log(`Progress: ${Math.floor(event.progress * 100)}%`);
|
||||||
|
console.log(`Downloaded: ${event.bytesWritten} / ${event.totalBytes}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for completion
|
||||||
|
const completeSub = BackgroundDownloader.addCompleteListener((event) => {
|
||||||
|
console.log('Download complete!');
|
||||||
|
console.log('File saved to:', event.filePath);
|
||||||
|
console.log('Task ID:', event.taskId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for errors
|
||||||
|
const errorSub = BackgroundDownloader.addErrorListener((event) => {
|
||||||
|
console.error('Download failed:', event.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel a download
|
||||||
|
BackgroundDownloader.cancelDownload(taskId);
|
||||||
|
|
||||||
|
// Get all active downloads
|
||||||
|
const activeDownloads = await BackgroundDownloader.getActiveDownloads();
|
||||||
|
|
||||||
|
// Cleanup listeners when done
|
||||||
|
progressSub.remove();
|
||||||
|
completeSub.remove();
|
||||||
|
errorSub.remove();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Destination Path
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BackgroundDownloader } from '@/modules';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
|
||||||
|
const destinationPath = `${FileSystem.documentDirectory}myfile.mp4`;
|
||||||
|
const taskId = await BackgroundDownloader.startDownload(
|
||||||
|
'https://example.com/video.mp4',
|
||||||
|
destinationPath
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Multiple Downloads
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BackgroundDownloader } from '@/modules';
|
||||||
|
|
||||||
|
const downloads = new Map();
|
||||||
|
|
||||||
|
async function startMultipleDownloads(urls: string[]) {
|
||||||
|
for (const url of urls) {
|
||||||
|
const taskId = await BackgroundDownloader.startDownload(url);
|
||||||
|
downloads.set(taskId, { url, progress: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track progress for each download
|
||||||
|
const progressSub = BackgroundDownloader.addProgressListener((event) => {
|
||||||
|
const download = downloads.get(event.taskId);
|
||||||
|
if (download) {
|
||||||
|
download.progress = event.progress;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel all downloads
|
||||||
|
BackgroundDownloader.cancelAllDownloads();
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
#### `startDownload(url: string, destinationPath?: string): Promise<number>`
|
||||||
|
|
||||||
|
Starts a new background download.
|
||||||
|
|
||||||
|
- **Parameters:**
|
||||||
|
- `url`: The URL of the file to download
|
||||||
|
- `destinationPath`: (Optional) Custom file path for the downloaded file
|
||||||
|
- **Returns:** Promise that resolves to the task ID (number)
|
||||||
|
|
||||||
|
#### `cancelDownload(taskId: number): void`
|
||||||
|
|
||||||
|
Cancels a specific download by task ID.
|
||||||
|
|
||||||
|
- **Parameters:**
|
||||||
|
- `taskId`: The task ID returned by `startDownload`
|
||||||
|
|
||||||
|
#### `cancelAllDownloads(): void`
|
||||||
|
|
||||||
|
Cancels all active downloads.
|
||||||
|
|
||||||
|
#### `getActiveDownloads(): Promise<ActiveDownload[]>`
|
||||||
|
|
||||||
|
Gets information about all active downloads.
|
||||||
|
|
||||||
|
- **Returns:** Promise that resolves to an array of active downloads
|
||||||
|
|
||||||
|
### Event Listeners
|
||||||
|
|
||||||
|
#### `addProgressListener(listener: (event: DownloadProgressEvent) => void): Subscription`
|
||||||
|
|
||||||
|
Listens for download progress updates.
|
||||||
|
|
||||||
|
- **Event payload:**
|
||||||
|
- `taskId`: number
|
||||||
|
- `bytesWritten`: number
|
||||||
|
- `totalBytes`: number
|
||||||
|
- `progress`: number (0.0 to 1.0)
|
||||||
|
|
||||||
|
#### `addCompleteListener(listener: (event: DownloadCompleteEvent) => void): Subscription`
|
||||||
|
|
||||||
|
Listens for download completion.
|
||||||
|
|
||||||
|
- **Event payload:**
|
||||||
|
- `taskId`: number
|
||||||
|
- `filePath`: string
|
||||||
|
- `url`: string
|
||||||
|
|
||||||
|
#### `addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription`
|
||||||
|
|
||||||
|
Listens for download errors.
|
||||||
|
|
||||||
|
- **Event payload:**
|
||||||
|
- `taskId`: number
|
||||||
|
- `error`: string
|
||||||
|
|
||||||
|
#### `addStartedListener(listener: (event: DownloadStartedEvent) => void): Subscription`
|
||||||
|
|
||||||
|
Listens for download start confirmation.
|
||||||
|
|
||||||
|
- **Event payload:**
|
||||||
|
- `taskId`: number
|
||||||
|
- `url`: string
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DownloadProgressEvent {
|
||||||
|
taskId: number;
|
||||||
|
bytesWritten: number;
|
||||||
|
totalBytes: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadCompleteEvent {
|
||||||
|
taskId: number;
|
||||||
|
filePath: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadErrorEvent {
|
||||||
|
taskId: number;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadStartedEvent {
|
||||||
|
taskId: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveDownload {
|
||||||
|
taskId: number;
|
||||||
|
url: string;
|
||||||
|
state: 'running' | 'suspended' | 'canceling' | 'completed' | 'unknown';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### iOS Background Downloads
|
||||||
|
|
||||||
|
- Uses `NSURLSession` with background configuration
|
||||||
|
- Session identifier: `com.fredrikburmester.streamyfin.backgrounddownloader`
|
||||||
|
- Downloads continue when app is backgrounded or suspended
|
||||||
|
- System may terminate downloads if app is force-quit
|
||||||
|
|
||||||
|
### Android Background Downloads
|
||||||
|
|
||||||
|
- Uses Android's `DownloadManager` API
|
||||||
|
- Downloads are managed by the system and continue in the background
|
||||||
|
- Shows download notification in the notification tray
|
||||||
|
- Downloads continue even if the app is closed
|
||||||
|
- Requires `INTERNET` permission (automatically added by Expo)
|
||||||
|
|
||||||
|
### Background Modes
|
||||||
|
|
||||||
|
The app's `Info.plist` already includes the required background mode for iOS:
|
||||||
|
|
||||||
|
- `UIBackgroundModes`: `["audio", "fetch"]`
|
||||||
|
|
||||||
|
### File Storage
|
||||||
|
|
||||||
|
**iOS:** By default, downloaded files are saved to the app's Documents directory.
|
||||||
|
|
||||||
|
**Android:** By default, files are saved to the app's external files directory (accessible via `FileSystem.documentDirectory` in Expo).
|
||||||
|
|
||||||
|
You can specify a custom path using the `destinationPath` parameter on both platforms.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
After adding this module, rebuild the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# iOS
|
||||||
|
npx expo prebuild -p ios
|
||||||
|
npx expo run:ios
|
||||||
|
|
||||||
|
# Android
|
||||||
|
npx expo prebuild -p android
|
||||||
|
npx expo run:android
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# iOS
|
||||||
|
cd ios
|
||||||
|
pod install
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Android - prebuild handles everything
|
||||||
|
npx expo prebuild -p android
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Background downloads may be cancelled if the user force-quits the app (iOS)
|
||||||
|
- The OS manages download priority and may pause downloads to save battery
|
||||||
|
- Android shows a system notification for ongoing downloads
|
||||||
|
- Downloads over cellular are allowed by default on both platforms
|
||||||
46
modules/background-downloader/android/build.gradle
Normal file
46
modules/background-downloader/android/build.gradle
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.library'
|
||||||
|
id 'kotlin-android'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'expo.modules.backgrounddownloader'
|
||||||
|
version = '1.0.0'
|
||||||
|
|
||||||
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||||
|
|
||||||
|
apply from: expoModulesCorePlugin
|
||||||
|
|
||||||
|
applyKotlinExpoModulesCorePlugin()
|
||||||
|
useDefaultAndroidSdkVersions()
|
||||||
|
useCoreDependencies()
|
||||||
|
useExpoPublishing()
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "expo.modules.backgrounddownloader"
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||||
|
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<service
|
||||||
|
android:name=".DownloadService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package expo.modules.backgrounddownloader
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import expo.modules.kotlin.Promise
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
data class DownloadTaskInfo(
|
||||||
|
val url: String,
|
||||||
|
val destinationPath: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
class BackgroundDownloaderModule : Module() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BackgroundDownloader"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val context
|
||||||
|
get() = requireNotNull(appContext.reactContext)
|
||||||
|
|
||||||
|
private val downloadManager = OkHttpDownloadManager()
|
||||||
|
private val downloadTasks = mutableMapOf<Int, DownloadTaskInfo>()
|
||||||
|
private val downloadQueue = mutableListOf<Pair<String, String?>>()
|
||||||
|
private var taskIdCounter = 1
|
||||||
|
private var downloadService: DownloadService? = null
|
||||||
|
private var serviceBound = false
|
||||||
|
|
||||||
|
private val serviceConnection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
Log.d(TAG, "Service connected")
|
||||||
|
val binder = service as DownloadService.DownloadServiceBinder
|
||||||
|
downloadService = binder.getService()
|
||||||
|
serviceBound = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
Log.d(TAG, "Service disconnected")
|
||||||
|
downloadService = null
|
||||||
|
serviceBound = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("BackgroundDownloader")
|
||||||
|
|
||||||
|
Events(
|
||||||
|
"onDownloadProgress",
|
||||||
|
"onDownloadComplete",
|
||||||
|
"onDownloadError",
|
||||||
|
"onDownloadStarted"
|
||||||
|
)
|
||||||
|
|
||||||
|
OnCreate {
|
||||||
|
Log.d(TAG, "Module created")
|
||||||
|
}
|
||||||
|
|
||||||
|
OnDestroy {
|
||||||
|
Log.d(TAG, "Module destroyed")
|
||||||
|
downloadManager.cancelAllDownloads()
|
||||||
|
if (serviceBound) {
|
||||||
|
try {
|
||||||
|
context.unbindService(serviceConnection)
|
||||||
|
serviceBound = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error unbinding service: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("startDownload") { urlString: String, destinationPath: String?, promise: Promise ->
|
||||||
|
try {
|
||||||
|
val taskId = startDownloadInternal(urlString, destinationPath)
|
||||||
|
promise.resolve(taskId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("enqueueDownload") { urlString: String, destinationPath: String?, promise: Promise ->
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Enqueuing download: url=$urlString")
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
val wasEmpty = downloadQueue.isEmpty()
|
||||||
|
downloadQueue.add(Pair(urlString, destinationPath))
|
||||||
|
Log.d(TAG, "Queue size: ${downloadQueue.size}")
|
||||||
|
|
||||||
|
// If queue was empty and no active downloads, start processing immediately
|
||||||
|
if (wasEmpty && downloadTasks.isEmpty()) {
|
||||||
|
val taskId = processNextInQueue()
|
||||||
|
promise.resolve(taskId)
|
||||||
|
} else {
|
||||||
|
// Return placeholder taskId for queued items
|
||||||
|
promise.resolve(-1)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("DOWNLOAD_ERROR", "Failed to enqueue download: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("cancelDownload") { taskId: Int ->
|
||||||
|
Log.d(TAG, "Cancelling download: taskId=$taskId")
|
||||||
|
downloadManager.cancelDownload(taskId)
|
||||||
|
downloadTasks.remove(taskId)
|
||||||
|
downloadService?.stopDownload()
|
||||||
|
|
||||||
|
// Process next item in queue after cancellation
|
||||||
|
processNextInQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("cancelQueuedDownload") { url: String ->
|
||||||
|
// Remove from queue by URL
|
||||||
|
downloadQueue.removeAll { queuedItem ->
|
||||||
|
queuedItem.first == url
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Removed queued download: $url, queue size: ${downloadQueue.size}")
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("cancelAllDownloads") {
|
||||||
|
Log.d(TAG, "Cancelling all downloads")
|
||||||
|
downloadManager.cancelAllDownloads()
|
||||||
|
downloadTasks.clear()
|
||||||
|
downloadQueue.clear()
|
||||||
|
stopDownloadService()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getActiveDownloads") { promise: Promise ->
|
||||||
|
try {
|
||||||
|
val activeDownloads = downloadTasks.map { (taskId, taskInfo) ->
|
||||||
|
mapOf(
|
||||||
|
"taskId" to taskId,
|
||||||
|
"url" to taskInfo.url
|
||||||
|
)
|
||||||
|
}
|
||||||
|
promise.resolve(activeDownloads)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {
|
||||||
|
val taskId = taskIdCounter++
|
||||||
|
|
||||||
|
if (destinationPath == null) {
|
||||||
|
throw IllegalArgumentException("Destination path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadTasks[taskId] = DownloadTaskInfo(
|
||||||
|
url = urlString,
|
||||||
|
destinationPath = destinationPath
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start foreground service if not running
|
||||||
|
startDownloadService()
|
||||||
|
downloadService?.startDownload()
|
||||||
|
|
||||||
|
Log.d(TAG, "Starting download: taskId=$taskId, url=$urlString")
|
||||||
|
|
||||||
|
// Send started event
|
||||||
|
sendEvent("onDownloadStarted", mapOf(
|
||||||
|
"taskId" to taskId,
|
||||||
|
"url" to urlString
|
||||||
|
))
|
||||||
|
|
||||||
|
// Start the download with OkHttp
|
||||||
|
downloadManager.startDownload(
|
||||||
|
taskId = taskId,
|
||||||
|
url = urlString,
|
||||||
|
destinationPath = destinationPath,
|
||||||
|
onProgress = { bytesWritten, totalBytes ->
|
||||||
|
handleProgress(taskId, bytesWritten, totalBytes)
|
||||||
|
},
|
||||||
|
onComplete = { filePath ->
|
||||||
|
handleDownloadComplete(taskId, filePath)
|
||||||
|
},
|
||||||
|
onError = { error ->
|
||||||
|
handleError(taskId, error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return taskId
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processNextInQueue(): Int {
|
||||||
|
// Check if queue has items
|
||||||
|
if (downloadQueue.isEmpty()) {
|
||||||
|
Log.d(TAG, "Queue is empty")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are active downloads (one at a time)
|
||||||
|
if (downloadTasks.isNotEmpty()) {
|
||||||
|
Log.d(TAG, "Active downloads in progress (${downloadTasks.size}), waiting...")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next item from queue
|
||||||
|
val (url, destinationPath) = downloadQueue.removeAt(0)
|
||||||
|
Log.d(TAG, "Processing next in queue: $url")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
startDownloadInternal(url, destinationPath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error processing queue item: ${e.message}", e)
|
||||||
|
// Try to process next item
|
||||||
|
processNextInQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleProgress(taskId: Int, bytesWritten: Long, totalBytes: Long) {
|
||||||
|
val progress = if (totalBytes > 0) {
|
||||||
|
bytesWritten.toDouble() / totalBytes.toDouble()
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update notification
|
||||||
|
val taskInfo = downloadTasks[taskId]
|
||||||
|
if (taskInfo != null) {
|
||||||
|
val progressPercent = (progress * 100).toInt()
|
||||||
|
downloadService?.updateProgress("Downloading video", progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent("onDownloadProgress", mapOf(
|
||||||
|
"taskId" to taskId,
|
||||||
|
"bytesWritten" to bytesWritten,
|
||||||
|
"totalBytes" to totalBytes,
|
||||||
|
"progress" to progress
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownloadComplete(taskId: Int, filePath: String) {
|
||||||
|
val taskInfo = downloadTasks[taskId]
|
||||||
|
|
||||||
|
if (taskInfo == null) {
|
||||||
|
Log.e(TAG, "Download completed but task info not found: taskId=$taskId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Download completed: taskId=$taskId, filePath=$filePath")
|
||||||
|
|
||||||
|
sendEvent("onDownloadComplete", mapOf(
|
||||||
|
"taskId" to taskId,
|
||||||
|
"filePath" to filePath,
|
||||||
|
"url" to taskInfo.url
|
||||||
|
))
|
||||||
|
|
||||||
|
downloadTasks.remove(taskId)
|
||||||
|
downloadService?.stopDownload()
|
||||||
|
|
||||||
|
// Process next item in queue
|
||||||
|
processNextInQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleError(taskId: Int, error: String) {
|
||||||
|
val taskInfo = downloadTasks[taskId]
|
||||||
|
|
||||||
|
Log.e(TAG, "Download error: taskId=$taskId, error=$error")
|
||||||
|
|
||||||
|
sendEvent("onDownloadError", mapOf(
|
||||||
|
"taskId" to taskId,
|
||||||
|
"error" to error
|
||||||
|
))
|
||||||
|
|
||||||
|
downloadTasks.remove(taskId)
|
||||||
|
downloadService?.stopDownload()
|
||||||
|
|
||||||
|
// Process next item in queue even on error
|
||||||
|
processNextInQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDownloadService() {
|
||||||
|
if (!serviceBound) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java)
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopDownloadService() {
|
||||||
|
if (serviceBound && downloadTasks.isEmpty()) {
|
||||||
|
try {
|
||||||
|
context.unbindService(serviceConnection)
|
||||||
|
serviceBound = false
|
||||||
|
downloadService = null
|
||||||
|
|
||||||
|
val intent = Intent(context, DownloadService::class.java)
|
||||||
|
context.stopService(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error stopping service: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package expo.modules.backgrounddownloader
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
class DownloadService : Service() {
|
||||||
|
private val TAG = "DownloadService"
|
||||||
|
private val NOTIFICATION_ID = 1001
|
||||||
|
private val CHANNEL_ID = "download_channel"
|
||||||
|
|
||||||
|
private val binder = DownloadServiceBinder()
|
||||||
|
private var activeDownloadCount = 0
|
||||||
|
private var currentDownloadTitle = "Preparing download..."
|
||||||
|
private var currentProgress = 0
|
||||||
|
|
||||||
|
inner class DownloadServiceBinder : Binder() {
|
||||||
|
fun getService(): DownloadService = this@DownloadService
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.d(TAG, "DownloadService created")
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder {
|
||||||
|
Log.d(TAG, "DownloadService bound")
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
Log.d(TAG, "DownloadService started")
|
||||||
|
startForeground(NOTIFICATION_ID, createNotification())
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
Log.d(TAG, "DownloadService destroyed")
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Downloads",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Video download progress"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotification(): Notification {
|
||||||
|
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(currentDownloadTitle)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
|
||||||
|
if (currentProgress > 0) {
|
||||||
|
builder.setProgress(100, currentProgress, false)
|
||||||
|
.setContentText("$currentProgress% complete")
|
||||||
|
} else {
|
||||||
|
builder.setProgress(100, 0, true)
|
||||||
|
.setContentText("Starting...")
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startDownload() {
|
||||||
|
activeDownloadCount++
|
||||||
|
Log.d(TAG, "Download started, active count: $activeDownloadCount")
|
||||||
|
if (activeDownloadCount == 1) {
|
||||||
|
startForeground(NOTIFICATION_ID, createNotification())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopDownload() {
|
||||||
|
activeDownloadCount = maxOf(0, activeDownloadCount - 1)
|
||||||
|
Log.d(TAG, "Download stopped, active count: $activeDownloadCount")
|
||||||
|
if (activeDownloadCount == 0) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProgress(title: String, progress: Int) {
|
||||||
|
currentDownloadTitle = title
|
||||||
|
currentProgress = progress
|
||||||
|
|
||||||
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, createNotification())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package expo.modules.backgrounddownloader
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class OkHttpDownloadManager {
|
||||||
|
private val TAG = "OkHttpDownloadManager"
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(0, TimeUnit.SECONDS) // No timeout for long transcodes
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val activeDownloads = mutableMapOf<Int, Call>()
|
||||||
|
|
||||||
|
fun startDownload(
|
||||||
|
taskId: Int,
|
||||||
|
url: String,
|
||||||
|
destinationPath: String,
|
||||||
|
onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit,
|
||||||
|
onComplete: (filePath: String) -> Unit,
|
||||||
|
onError: (error: String) -> Unit
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "Starting download: taskId=$taskId, url=$url")
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val call = client.newCall(request)
|
||||||
|
activeDownloads[taskId] = call
|
||||||
|
|
||||||
|
call.enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.e(TAG, "Download failed: taskId=$taskId, error=${e.message}")
|
||||||
|
activeDownloads.remove(taskId)
|
||||||
|
if (call.isCanceled()) {
|
||||||
|
// Don't report cancellation as error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onError(e.message ?: "Download failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
Log.e(TAG, "Download failed with HTTP code: ${response.code}")
|
||||||
|
activeDownloads.remove(taskId)
|
||||||
|
onError("HTTP error: ${response.code} ${response.message}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val totalBytes = response.body?.contentLength() ?: -1L
|
||||||
|
val inputStream = response.body?.byteStream()
|
||||||
|
|
||||||
|
if (inputStream == null) {
|
||||||
|
activeDownloads.remove(taskId)
|
||||||
|
onError("Failed to get response body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destination directory if needed
|
||||||
|
val destFile = File(destinationPath)
|
||||||
|
val destDir = destFile.parentFile
|
||||||
|
if (destDir != null && !destDir.exists()) {
|
||||||
|
destDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val outputStream = destFile.outputStream()
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
var bytesWritten = 0L
|
||||||
|
var lastProgressUpdate = System.currentTimeMillis()
|
||||||
|
|
||||||
|
inputStream.use { input ->
|
||||||
|
outputStream.use { output ->
|
||||||
|
var bytes = input.read(buffer)
|
||||||
|
while (bytes >= 0) {
|
||||||
|
// Check if download was cancelled
|
||||||
|
if (call.isCanceled()) {
|
||||||
|
Log.d(TAG, "Download cancelled: taskId=$taskId")
|
||||||
|
destFile.delete()
|
||||||
|
activeDownloads.remove(taskId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
output.write(buffer, 0, bytes)
|
||||||
|
bytesWritten += bytes
|
||||||
|
|
||||||
|
// Throttle progress updates to every 500ms
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastProgressUpdate >= 500) {
|
||||||
|
onProgress(bytesWritten, totalBytes)
|
||||||
|
lastProgressUpdate = now
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes = input.read(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send final progress update
|
||||||
|
onProgress(bytesWritten, totalBytes)
|
||||||
|
|
||||||
|
Log.d(TAG, "Download completed: taskId=$taskId, bytes=$bytesWritten")
|
||||||
|
activeDownloads.remove(taskId)
|
||||||
|
onComplete(destinationPath)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error during download: taskId=$taskId, error=${e.message}", e)
|
||||||
|
activeDownloads.remove(taskId)
|
||||||
|
|
||||||
|
// Clean up partial file
|
||||||
|
try {
|
||||||
|
File(destinationPath).delete()
|
||||||
|
} catch (deleteError: Exception) {
|
||||||
|
Log.e(TAG, "Failed to delete partial file: ${deleteError.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!call.isCanceled()) {
|
||||||
|
onError(e.message ?: "Download failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelDownload(taskId: Int) {
|
||||||
|
Log.d(TAG, "Cancelling download: taskId=$taskId")
|
||||||
|
activeDownloads[taskId]?.cancel()
|
||||||
|
activeDownloads.remove(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAllDownloads() {
|
||||||
|
Log.d(TAG, "Cancelling all downloads")
|
||||||
|
activeDownloads.values.forEach { it.cancel() }
|
||||||
|
activeDownloads.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasActiveDownloads(): Boolean {
|
||||||
|
return activeDownloads.isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
98
modules/background-downloader/example.ts
Normal file
98
modules/background-downloader/example.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type {
|
||||||
|
DownloadCompleteEvent,
|
||||||
|
DownloadErrorEvent,
|
||||||
|
DownloadProgressEvent,
|
||||||
|
} from "@/modules";
|
||||||
|
import { BackgroundDownloader } from "@/modules";
|
||||||
|
|
||||||
|
export class DownloadManager {
|
||||||
|
private progressSubscription: any;
|
||||||
|
private completeSubscription: any;
|
||||||
|
private errorSubscription: any;
|
||||||
|
private activeDownloads = new Map<
|
||||||
|
number,
|
||||||
|
{ url: string; progress: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupListeners() {
|
||||||
|
this.progressSubscription = BackgroundDownloader.addProgressListener(
|
||||||
|
(event: DownloadProgressEvent) => {
|
||||||
|
const download = this.activeDownloads.get(event.taskId);
|
||||||
|
if (download) {
|
||||||
|
download.progress = event.progress;
|
||||||
|
console.log(
|
||||||
|
`Download ${event.taskId}: ${Math.floor(event.progress * 100)}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.completeSubscription = BackgroundDownloader.addCompleteListener(
|
||||||
|
(event: DownloadCompleteEvent) => {
|
||||||
|
console.log("Download complete:", event.filePath);
|
||||||
|
this.activeDownloads.delete(event.taskId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.errorSubscription = BackgroundDownloader.addErrorListener(
|
||||||
|
(event: DownloadErrorEvent) => {
|
||||||
|
console.error("Download error:", event.error);
|
||||||
|
this.activeDownloads.delete(event.taskId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startDownload(url: string, destinationPath?: string): Promise<number> {
|
||||||
|
const taskId = await BackgroundDownloader.startDownload(
|
||||||
|
url,
|
||||||
|
destinationPath,
|
||||||
|
);
|
||||||
|
this.activeDownloads.set(taskId, { url, progress: 0 });
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelDownload(taskId: number): void {
|
||||||
|
BackgroundDownloader.cancelDownload(taskId);
|
||||||
|
this.activeDownloads.delete(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAllDownloads(): void {
|
||||||
|
BackgroundDownloader.cancelAllDownloads();
|
||||||
|
this.activeDownloads.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveDownloads() {
|
||||||
|
return await BackgroundDownloader.getActiveDownloads();
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
this.progressSubscription?.remove();
|
||||||
|
this.completeSubscription?.remove();
|
||||||
|
this.errorSubscription?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadManager = new DownloadManager();
|
||||||
|
|
||||||
|
export async function downloadFile(
|
||||||
|
url: string,
|
||||||
|
destinationPath?: string,
|
||||||
|
): Promise<number> {
|
||||||
|
return await downloadManager.startDownload(url, destinationPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelDownload(taskId: number): void {
|
||||||
|
downloadManager.cancelDownload(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelAllDownloads(): void {
|
||||||
|
downloadManager.cancelAllDownloads();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveDownloads() {
|
||||||
|
return await downloadManager.getActiveDownloads();
|
||||||
|
}
|
||||||
12
modules/background-downloader/expo-module.config.json
Normal file
12
modules/background-downloader/expo-module.config.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "background-downloader",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"platforms": ["ios", "android"],
|
||||||
|
"ios": {
|
||||||
|
"modules": ["BackgroundDownloaderModule"],
|
||||||
|
"appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"]
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"modules": ["expo.modules.backgrounddownloader.BackgroundDownloaderModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
109
modules/background-downloader/index.ts
Normal file
109
modules/background-downloader/index.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
|
import type {
|
||||||
|
ActiveDownload,
|
||||||
|
DownloadCompleteEvent,
|
||||||
|
DownloadErrorEvent,
|
||||||
|
DownloadProgressEvent,
|
||||||
|
DownloadStartedEvent,
|
||||||
|
} from "./src/BackgroundDownloader.types";
|
||||||
|
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
|
||||||
|
|
||||||
|
export interface BackgroundDownloader {
|
||||||
|
startDownload(url: string, destinationPath?: string): Promise<number>;
|
||||||
|
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
|
||||||
|
cancelDownload(taskId: number): void;
|
||||||
|
cancelQueuedDownload(url: string): void;
|
||||||
|
cancelAllDownloads(): void;
|
||||||
|
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||||
|
|
||||||
|
addProgressListener(
|
||||||
|
listener: (event: DownloadProgressEvent) => void,
|
||||||
|
): EventSubscription;
|
||||||
|
|
||||||
|
addCompleteListener(
|
||||||
|
listener: (event: DownloadCompleteEvent) => void,
|
||||||
|
): EventSubscription;
|
||||||
|
|
||||||
|
addErrorListener(
|
||||||
|
listener: (event: DownloadErrorEvent) => void,
|
||||||
|
): EventSubscription;
|
||||||
|
|
||||||
|
addStartedListener(
|
||||||
|
listener: (event: DownloadStartedEvent) => void,
|
||||||
|
): EventSubscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BackgroundDownloader: BackgroundDownloader = {
|
||||||
|
async startDownload(url: string, destinationPath?: string): Promise<number> {
|
||||||
|
return await BackgroundDownloaderModule.startDownload(url, destinationPath);
|
||||||
|
},
|
||||||
|
|
||||||
|
async enqueueDownload(
|
||||||
|
url: string,
|
||||||
|
destinationPath?: string,
|
||||||
|
): Promise<number> {
|
||||||
|
return await BackgroundDownloaderModule.enqueueDownload(
|
||||||
|
url,
|
||||||
|
destinationPath,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelDownload(taskId: number): void {
|
||||||
|
BackgroundDownloaderModule.cancelDownload(taskId);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelQueuedDownload(url: string): void {
|
||||||
|
BackgroundDownloaderModule.cancelQueuedDownload(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelAllDownloads(): void {
|
||||||
|
BackgroundDownloaderModule.cancelAllDownloads();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActiveDownloads(): Promise<ActiveDownload[]> {
|
||||||
|
return await BackgroundDownloaderModule.getActiveDownloads();
|
||||||
|
},
|
||||||
|
|
||||||
|
addProgressListener(
|
||||||
|
listener: (event: DownloadProgressEvent) => void,
|
||||||
|
): EventSubscription {
|
||||||
|
return BackgroundDownloaderModule.addListener(
|
||||||
|
"onDownloadProgress",
|
||||||
|
listener,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
addCompleteListener(
|
||||||
|
listener: (event: DownloadCompleteEvent) => void,
|
||||||
|
): EventSubscription {
|
||||||
|
return BackgroundDownloaderModule.addListener(
|
||||||
|
"onDownloadComplete",
|
||||||
|
listener,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
addErrorListener(
|
||||||
|
listener: (event: DownloadErrorEvent) => void,
|
||||||
|
): EventSubscription {
|
||||||
|
return BackgroundDownloaderModule.addListener("onDownloadError", listener);
|
||||||
|
},
|
||||||
|
|
||||||
|
addStartedListener(
|
||||||
|
listener: (event: DownloadStartedEvent) => void,
|
||||||
|
): EventSubscription {
|
||||||
|
return BackgroundDownloaderModule.addListener(
|
||||||
|
"onDownloadStarted",
|
||||||
|
listener,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BackgroundDownloader;
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ActiveDownload,
|
||||||
|
DownloadCompleteEvent,
|
||||||
|
DownloadErrorEvent,
|
||||||
|
DownloadProgressEvent,
|
||||||
|
DownloadStartedEvent,
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'BackgroundDownloader'
|
||||||
|
s.version = '1.0.0'
|
||||||
|
s.summary = 'Background file downloader for iOS'
|
||||||
|
s.description = 'Native iOS module for downloading large files in the background using NSURLSession'
|
||||||
|
s.author = ''
|
||||||
|
s.homepage = 'https://docs.expo.dev/modules/'
|
||||||
|
s.platforms = { :ios => '15.6', :tvos => '15.0' }
|
||||||
|
s.source = { git: '' }
|
||||||
|
s.static_framework = true
|
||||||
|
|
||||||
|
s.dependency 'ExpoModulesCore'
|
||||||
|
|
||||||
|
s.pod_target_xcconfig = {
|
||||||
|
'DEFINES_MODULE' => 'YES',
|
||||||
|
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||||
|
}
|
||||||
|
|
||||||
|
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
end
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public class BackgroundDownloaderAppDelegate: ExpoAppDelegateSubscriber {
|
||||||
|
public func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
handleEventsForBackgroundURLSession identifier: String,
|
||||||
|
completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
if identifier == "com.fredrikburmester.streamyfin.backgrounddownloader" {
|
||||||
|
BackgroundDownloaderModule.setBackgroundCompletionHandler(completionHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum DownloadError: Error {
|
||||||
|
case invalidURL
|
||||||
|
case fileOperationFailed
|
||||||
|
case downloadFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DownloadTaskInfo {
|
||||||
|
let url: String
|
||||||
|
let destinationPath: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate delegate class to handle URLSession callbacks
|
||||||
|
class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate {
|
||||||
|
weak var module: BackgroundDownloaderModule?
|
||||||
|
|
||||||
|
init(module: BackgroundDownloaderModule) {
|
||||||
|
self.module = module
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
downloadTask: URLSessionDownloadTask,
|
||||||
|
didWriteData bytesWritten: Int64,
|
||||||
|
totalBytesWritten: Int64,
|
||||||
|
totalBytesExpectedToWrite: Int64
|
||||||
|
) {
|
||||||
|
module?.handleProgress(
|
||||||
|
taskId: downloadTask.taskIdentifier,
|
||||||
|
bytesWritten: totalBytesWritten,
|
||||||
|
totalBytes: totalBytesExpectedToWrite
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
downloadTask: URLSessionDownloadTask,
|
||||||
|
didFinishDownloadingTo location: URL
|
||||||
|
) {
|
||||||
|
module?.handleDownloadComplete(
|
||||||
|
taskId: downloadTask.taskIdentifier,
|
||||||
|
location: location,
|
||||||
|
downloadTask: downloadTask
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
task: URLSessionTask,
|
||||||
|
didCompleteWithError error: Error?
|
||||||
|
) {
|
||||||
|
if let error = error {
|
||||||
|
print("[BackgroundDownloader] Task \(task.taskIdentifier) error: \(error.localizedDescription)")
|
||||||
|
module?.handleError(taskId: task.taskIdentifier, error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let completion = BackgroundDownloaderModule.backgroundCompletionHandler {
|
||||||
|
completion()
|
||||||
|
BackgroundDownloaderModule.backgroundCompletionHandler = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BackgroundDownloaderModule: Module {
|
||||||
|
private var session: URLSession?
|
||||||
|
private var sessionDelegate: DownloadSessionDelegate?
|
||||||
|
fileprivate static var backgroundCompletionHandler: (() -> Void)?
|
||||||
|
private var downloadTasks: [Int: DownloadTaskInfo] = [:]
|
||||||
|
private var downloadQueue: [(url: String, destinationPath: String?)] = []
|
||||||
|
private var lastProgressTime: [Int: Date] = [:]
|
||||||
|
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("BackgroundDownloader")
|
||||||
|
|
||||||
|
Events(
|
||||||
|
"onDownloadProgress",
|
||||||
|
"onDownloadComplete",
|
||||||
|
"onDownloadError",
|
||||||
|
"onDownloadStarted"
|
||||||
|
)
|
||||||
|
|
||||||
|
OnCreate {
|
||||||
|
self.initializeSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("startDownload") { (urlString: String, destinationPath: String?) -> Int in
|
||||||
|
guard let url = URL(string: urlString) else {
|
||||||
|
throw DownloadError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.session == nil {
|
||||||
|
self.initializeSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let session = self.session else {
|
||||||
|
throw DownloadError.downloadFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a URLRequest to ensure proper handling
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.timeoutInterval = 300
|
||||||
|
|
||||||
|
let task = session.downloadTask(with: request)
|
||||||
|
let taskId = task.taskIdentifier
|
||||||
|
|
||||||
|
self.downloadTasks[taskId] = DownloadTaskInfo(
|
||||||
|
url: urlString,
|
||||||
|
destinationPath: destinationPath
|
||||||
|
)
|
||||||
|
|
||||||
|
task.resume()
|
||||||
|
|
||||||
|
self.sendEvent("onDownloadStarted", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"url": urlString
|
||||||
|
])
|
||||||
|
|
||||||
|
return taskId
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("enqueueDownload") { (urlString: String, destinationPath: String?) -> Int in
|
||||||
|
// Add to queue
|
||||||
|
let wasEmpty = self.downloadQueue.isEmpty
|
||||||
|
self.downloadQueue.append((url: urlString, destinationPath: destinationPath))
|
||||||
|
|
||||||
|
// If queue was empty and no active downloads, start processing immediately
|
||||||
|
if wasEmpty {
|
||||||
|
return try await self.processNextInQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return placeholder taskId for queued items
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("cancelDownload") { (taskId: Int) in
|
||||||
|
self.session?.getAllTasks { tasks in
|
||||||
|
for task in tasks where task.taskIdentifier == taskId {
|
||||||
|
task.cancel()
|
||||||
|
self.downloadTasks.removeValue(forKey: taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("cancelQueuedDownload") { (url: String) in
|
||||||
|
// Remove from queue by URL
|
||||||
|
self.downloadQueue.removeAll { queuedItem in
|
||||||
|
queuedItem.url == url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("cancelAllDownloads") {
|
||||||
|
self.session?.getAllTasks { tasks in
|
||||||
|
for task in tasks {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
self.downloadTasks.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let downloadTasks = self.downloadTasks
|
||||||
|
|
||||||
|
self.session?.getAllTasks { tasks in
|
||||||
|
let activeDownloads = tasks.compactMap { task -> [String: Any]? in
|
||||||
|
guard task is URLSessionDownloadTask,
|
||||||
|
let info = downloadTasks[task.taskIdentifier] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"taskId": task.taskIdentifier,
|
||||||
|
"url": info.url
|
||||||
|
]
|
||||||
|
}
|
||||||
|
continuation.resume(returning: activeDownloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initializeSession() {
|
||||||
|
print("[BackgroundDownloader] Initializing URLSession")
|
||||||
|
|
||||||
|
let config = URLSessionConfiguration.background(
|
||||||
|
withIdentifier: "com.fredrikburmester.streamyfin.backgrounddownloader"
|
||||||
|
)
|
||||||
|
config.allowsCellularAccess = true
|
||||||
|
config.sessionSendsLaunchEvents = true
|
||||||
|
config.isDiscretionary = false
|
||||||
|
|
||||||
|
self.sessionDelegate = DownloadSessionDelegate(module: self)
|
||||||
|
self.session = URLSession(
|
||||||
|
configuration: config,
|
||||||
|
delegate: self.sessionDelegate,
|
||||||
|
delegateQueue: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[BackgroundDownloader] URLSession initialized with delegate: \(String(describing: self.sessionDelegate))")
|
||||||
|
print("[BackgroundDownloader] Session identifier: \(config.identifier ?? "nil")")
|
||||||
|
print("[BackgroundDownloader] Delegate queue: nil (uses default)")
|
||||||
|
|
||||||
|
// Verify delegate is connected
|
||||||
|
if let session = self.session, session.delegate != nil {
|
||||||
|
print("[BackgroundDownloader] ✅ Delegate successfully attached to session")
|
||||||
|
} else {
|
||||||
|
print("[BackgroundDownloader] ⚠️ DELEGATE NOT ATTACHED!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler methods called by the delegate
|
||||||
|
func handleProgress(taskId: Int, bytesWritten: Int64, totalBytes: Int64) {
|
||||||
|
let progress = totalBytes > 0
|
||||||
|
? Double(bytesWritten) / Double(totalBytes)
|
||||||
|
: 0.0
|
||||||
|
|
||||||
|
// Throttle progress updates: only send every 500ms
|
||||||
|
let lastTime = lastProgressTime[taskId] ?? Date.distantPast
|
||||||
|
let now = Date()
|
||||||
|
let timeDiff = now.timeIntervalSince(lastTime)
|
||||||
|
|
||||||
|
// Send if 500ms passed
|
||||||
|
if timeDiff >= 0.5 {
|
||||||
|
self.sendEvent("onDownloadProgress", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"bytesWritten": bytesWritten,
|
||||||
|
"totalBytes": totalBytes,
|
||||||
|
"progress": progress
|
||||||
|
])
|
||||||
|
|
||||||
|
lastProgressTime[taskId] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDownloadComplete(taskId: Int, location: URL, downloadTask: URLSessionDownloadTask) {
|
||||||
|
guard let taskInfo = downloadTasks[taskId] else {
|
||||||
|
self.sendEvent("onDownloadError", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"error": "Download task info not found"
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
|
||||||
|
do {
|
||||||
|
let destinationURL: URL
|
||||||
|
|
||||||
|
if let customPath = taskInfo.destinationPath {
|
||||||
|
destinationURL = URL(fileURLWithPath: customPath)
|
||||||
|
} else {
|
||||||
|
let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||||
|
let filename = downloadTask.response?.suggestedFilename
|
||||||
|
?? downloadTask.originalRequest?.url?.lastPathComponent
|
||||||
|
?? "download_\(taskId)"
|
||||||
|
destinationURL = documentsDir.appendingPathComponent(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||||
|
try fileManager.removeItem(at: destinationURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationDirectory = destinationURL.deletingLastPathComponent()
|
||||||
|
if !fileManager.fileExists(atPath: destinationDirectory.path) {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: destinationDirectory,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try fileManager.moveItem(at: location, to: destinationURL)
|
||||||
|
|
||||||
|
self.sendEvent("onDownloadComplete", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"filePath": destinationURL.path,
|
||||||
|
"url": taskInfo.url
|
||||||
|
])
|
||||||
|
|
||||||
|
downloadTasks.removeValue(forKey: taskId)
|
||||||
|
lastProgressTime.removeValue(forKey: taskId)
|
||||||
|
|
||||||
|
// Process next item in queue
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
_ = try await self.processNextInQueue()
|
||||||
|
} catch {
|
||||||
|
print("[BackgroundDownloader] Error processing next: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
self.sendEvent("onDownloadError", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"error": "File operation failed: \(error.localizedDescription)"
|
||||||
|
])
|
||||||
|
|
||||||
|
// Process next item in queue even on error
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
_ = try await self.processNextInQueue()
|
||||||
|
} catch {
|
||||||
|
print("[BackgroundDownloader] Error processing next: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleError(taskId: Int, error: Error) {
|
||||||
|
let isCancelled = (error as NSError).code == NSURLErrorCancelled
|
||||||
|
|
||||||
|
if !isCancelled {
|
||||||
|
print("[BackgroundDownloader] Task \(taskId) error: \(error.localizedDescription)")
|
||||||
|
|
||||||
|
self.sendEvent("onDownloadError", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"error": error.localizedDescription
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadTasks.removeValue(forKey: taskId)
|
||||||
|
lastProgressTime.removeValue(forKey: taskId)
|
||||||
|
|
||||||
|
// Process next item in queue (whether cancelled or errored)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
_ = try await self.processNextInQueue()
|
||||||
|
} catch {
|
||||||
|
print("[BackgroundDownloader] Error processing next: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processNextInQueue() async throws -> Int {
|
||||||
|
// Check if queue has items
|
||||||
|
guard !downloadQueue.isEmpty else {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are active downloads
|
||||||
|
if !downloadTasks.isEmpty {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next item from queue
|
||||||
|
let (url, destinationPath) = downloadQueue.removeFirst()
|
||||||
|
print("[BackgroundDownloader] Starting queued download")
|
||||||
|
|
||||||
|
// Start the download using existing startDownload logic
|
||||||
|
guard let urlObj = URL(string: url) else {
|
||||||
|
print("[BackgroundDownloader] Invalid URL in queue: \(url)")
|
||||||
|
return try await processNextInQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
if session == nil {
|
||||||
|
initializeSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let session = self.session else {
|
||||||
|
throw DownloadError.downloadFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: urlObj)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.timeoutInterval = 300
|
||||||
|
|
||||||
|
let task = session.downloadTask(with: request)
|
||||||
|
let taskId = task.taskIdentifier
|
||||||
|
|
||||||
|
downloadTasks[taskId] = DownloadTaskInfo(
|
||||||
|
url: url,
|
||||||
|
destinationPath: destinationPath
|
||||||
|
)
|
||||||
|
|
||||||
|
task.resume()
|
||||||
|
|
||||||
|
sendEvent("onDownloadStarted", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"url": url
|
||||||
|
])
|
||||||
|
|
||||||
|
return taskId
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) {
|
||||||
|
BackgroundDownloaderModule.backgroundCompletionHandler = handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
|
|
||||||
|
export interface DownloadProgressEvent {
|
||||||
|
taskId: number;
|
||||||
|
bytesWritten: number;
|
||||||
|
totalBytes: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadCompleteEvent {
|
||||||
|
taskId: number;
|
||||||
|
filePath: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadErrorEvent {
|
||||||
|
taskId: number;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadStartedEvent {
|
||||||
|
taskId: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveDownload {
|
||||||
|
taskId: number;
|
||||||
|
url: string;
|
||||||
|
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackgroundDownloaderModuleType {
|
||||||
|
startDownload(url: string, destinationPath?: string): Promise<number>;
|
||||||
|
cancelDownload(taskId: number): void;
|
||||||
|
cancelAllDownloads(): void;
|
||||||
|
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||||
|
addListener(
|
||||||
|
eventName: string,
|
||||||
|
listener: (event: any) => void,
|
||||||
|
): EventSubscription;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { requireNativeModule } from "expo-modules-core";
|
||||||
|
import type { BackgroundDownloaderModuleType } from "./BackgroundDownloader.types";
|
||||||
|
|
||||||
|
const BackgroundDownloaderModule: BackgroundDownloaderModuleType =
|
||||||
|
requireNativeModule("BackgroundDownloader");
|
||||||
|
|
||||||
|
export default BackgroundDownloaderModule;
|
||||||
@@ -12,6 +12,16 @@ import type {
|
|||||||
} from "./VlcPlayer.types";
|
} from "./VlcPlayer.types";
|
||||||
import VlcPlayerView from "./VlcPlayerView";
|
import VlcPlayerView from "./VlcPlayerView";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ActiveDownload,
|
||||||
|
DownloadCompleteEvent,
|
||||||
|
DownloadErrorEvent,
|
||||||
|
DownloadProgressEvent,
|
||||||
|
DownloadStartedEvent,
|
||||||
|
} from "./background-downloader";
|
||||||
|
// Background Downloader
|
||||||
|
export { default as BackgroundDownloader } from "./background-downloader";
|
||||||
|
|
||||||
// Component
|
// Component
|
||||||
export { VlcPlayerView };
|
export { VlcPlayerView };
|
||||||
|
|
||||||
|
|||||||
@@ -212,9 +212,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
fun setSource(source: Map<String, Any>) {
|
fun setSource(source: Map<String, Any>) {
|
||||||
log.debug("setting source $source")
|
log.debug("setting source $source")
|
||||||
if (hasSource) {
|
if (hasSource) {
|
||||||
log.debug("Source already set. Resuming")
|
log.debug("Source already set. Ignoring.")
|
||||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
|
||||||
play()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user