mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-18 17:18:11 +00:00
Compare commits
2 Commits
v0.47.1
...
codeql-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c0ed076d5 | ||
|
|
118c24ee05 |
6
.github/workflows/ci-codeql.yml
vendored
6
.github/workflows/ci-codeql.yml
vendored
@@ -31,13 +31,13 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||||
|
|||||||
1
.github/workflows/notification.yml
vendored
1
.github/workflows/notification.yml
vendored
@@ -1,4 +1,5 @@
|
|||||||
name: 🛎️ Discord Notification
|
name: 🛎️ Discord Notification
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -65,4 +65,3 @@ streamyfin-4fec1-firebase-adminsdk.json
|
|||||||
|
|
||||||
# Version and Backup Files
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
modules/background-downloader/android/build/*
|
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
# 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,16 +6,14 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
// Add the background downloader plugin only for non-TV builds
|
||||||
const androidConfig = {};
|
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
|
||||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
android: {
|
||||||
|
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
||||||
|
},
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
21
app.json
21
app.json
@@ -2,13 +2,12 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.47.1",
|
"version": "0.39.0",
|
||||||
"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,
|
||||||
@@ -38,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 84,
|
"versionCode": 71,
|
||||||
"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",
|
||||||
@@ -78,7 +77,6 @@
|
|||||||
"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",
|
||||||
@@ -117,6 +115,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
|
["./plugins/withAndroidManifest.js"],
|
||||||
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
|
["./plugins/withGradleProperties.js"],
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -132,12 +134,8 @@
|
|||||||
"color": "#9333EA"
|
"color": "#9333EA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-web-browser",
|
"./plugins/with-runtime-framework-headers.js",
|
||||||
["./plugins/with-runtime-framework-headers.js"],
|
"react-native-bottom-tabs"
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
|
||||||
["./plugins/withAndroidManifest.js"],
|
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
|
||||||
["./plugins/withGradleProperties.js"]
|
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
@@ -156,6 +154,7 @@
|
|||||||
},
|
},
|
||||||
"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: Platform.OS !== "ios",
|
headerShown: true,
|
||||||
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 { Platform, RefreshControl, ScrollView, View } from "react-native";
|
import { 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 style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
<View className='my-4'>
|
||||||
<Favorites />
|
<Favorites />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default function IndexLayout() {
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast background='transparent' />
|
<Chromecast.Chromecast background='transparent' />
|
||||||
|
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
@@ -41,119 +42,49 @@ 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
|
||||||
@@ -161,11 +92,6 @@ 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",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -176,11 +102,6 @@ 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",
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
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, Platform, TouchableOpacity, View } from "react-native";
|
import { Alert, ScrollView, TouchableOpacity, 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 { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||||
import {
|
import {
|
||||||
@@ -25,23 +23,21 @@ export default function page() {
|
|||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const { downloadedItems, deleteItems } = useDownload();
|
const { getDownloadedItems, deleteItems } = useDownload();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const series = useMemo(() => {
|
const series = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
downloadedItems
|
getDownloadedItems()
|
||||||
?.filter((f) => f.item.SeriesId === seriesId)
|
?.filter((f) => f.item.SeriesId === seriesId)
|
||||||
?.sort(
|
?.sort(
|
||||||
(a, b) =>
|
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
||||||
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
|
|
||||||
) || []
|
) || []
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [downloadedItems, seriesId]);
|
}, [getDownloadedItems]);
|
||||||
|
|
||||||
// Group episodes by season in a single pass
|
// Group episodes by season in a single pass
|
||||||
const seasonGroups = useMemo(() => {
|
const seasonGroups = useMemo(() => {
|
||||||
@@ -74,9 +70,8 @@ export default function page() {
|
|||||||
}, [seasonGroups]);
|
}, [seasonGroups]);
|
||||||
|
|
||||||
const seasonIndex =
|
const seasonIndex =
|
||||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
|
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||||
episodeSeasonIndex ??
|
episodeSeasonIndex ||
|
||||||
series?.[0]?.item?.ParentIndexNumber ??
|
|
||||||
"";
|
"";
|
||||||
|
|
||||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||||
@@ -85,9 +80,9 @@ export default function page() {
|
|||||||
|
|
||||||
const initialSeasonIndex = useMemo(
|
const initialSeasonIndex = useMemo(
|
||||||
() =>
|
() =>
|
||||||
groupBySeason?.[0]?.ParentIndexNumber ??
|
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||||
series?.[0]?.item?.ParentIndexNumber,
|
series?.[0]?.item?.ParentIndexNumber,
|
||||||
[groupBySeason, series],
|
[groupBySeason],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,7 +91,7 @@ export default function page() {
|
|||||||
title: series[0].item.SeriesName,
|
title: series[0].item.SeriesName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
storage.remove(seriesId);
|
storage.delete(seriesId);
|
||||||
router.back();
|
router.back();
|
||||||
}
|
}
|
||||||
}, [series]);
|
}, [series]);
|
||||||
@@ -112,70 +107,44 @@ export default function page() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Delete",
|
text: "Delete",
|
||||||
onPress: () =>
|
onPress: () => deleteItems(groupBySeason),
|
||||||
deleteItems(
|
|
||||||
groupBySeason
|
|
||||||
.map((item) => item.Id)
|
|
||||||
.filter((id) => id !== undefined),
|
|
||||||
),
|
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}, [groupBySeason, deleteItems]);
|
}, [groupBySeason]);
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(() => {
|
|
||||||
if (series.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex flex-row items-center justify-start pb-2'>
|
|
||||||
<SeasonDropdown
|
|
||||||
item={series[0].item}
|
|
||||||
seasons={uniqueSeasons}
|
|
||||||
state={seasonIndexState}
|
|
||||||
initialSeasonIndex={initialSeasonIndex!}
|
|
||||||
onSelect={(season) => {
|
|
||||||
setSeasonIndexState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
|
||||||
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
|
||||||
</View>
|
|
||||||
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
|
||||||
<TouchableOpacity onPress={deleteSeries}>
|
|
||||||
<Ionicons name='trash' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
series,
|
|
||||||
uniqueSeasons,
|
|
||||||
seasonIndexState,
|
|
||||||
initialSeasonIndex,
|
|
||||||
groupBySeason,
|
|
||||||
deleteSeries,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-1'>
|
<View className='flex-1'>
|
||||||
<FlashList
|
{series.length > 0 && (
|
||||||
key={seasonIndex}
|
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||||
data={groupBySeason}
|
<SeasonDropdown
|
||||||
renderItem={({ item }) => <EpisodeCard item={item} />}
|
item={series[0].item}
|
||||||
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
|
seasons={uniqueSeasons}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
state={seasonIndexState}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
initialSeasonIndex={initialSeasonIndex!}
|
||||||
contentContainerStyle={{
|
onSelect={(season) => {
|
||||||
paddingHorizontal: 16,
|
setSeasonIndexState((prev) => ({
|
||||||
paddingLeft: insets.left + 16,
|
...prev,
|
||||||
paddingRight: insets.right + 16,
|
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
||||||
paddingTop: Platform.OS === "android" ? 10 : 8,
|
}));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
||||||
|
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
||||||
|
<TouchableOpacity onPress={deleteSeries}>
|
||||||
|
<Ionicons name='trash' size={20} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<ScrollView key={seasonIndex} className='px-4'>
|
||||||
|
{groupBySeason.map((episode, index) => (
|
||||||
|
<EpisodeCard key={index} item={episode} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
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 {
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
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,15 +26,18 @@ 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 { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
|
const {
|
||||||
|
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"),
|
||||||
@@ -59,7 +62,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
|
const downloadedFiles = getDownloadedItems();
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
const movies = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -103,10 +106,7 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
||||||
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,39 +141,38 @@ 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
|
otherMedia.map((item) =>
|
||||||
.filter((item) => item.item.Type)
|
deleteFileByType(item.item.Type)
|
||||||
.map((item) =>
|
.then(() =>
|
||||||
deleteFileByType(item.item.Type!)
|
toast.success(
|
||||||
.then(() =>
|
t("home.downloads.toasts.deleted_media_successfully", {
|
||||||
toast.success(
|
type: item.item.Type,
|
||||||
t("home.downloads.toasts.deleted_media_successfully", {
|
}),
|
||||||
type: item.item.Type,
|
),
|
||||||
}),
|
)
|
||||||
),
|
.catch((reason) => {
|
||||||
)
|
writeToLog("ERROR", reason);
|
||||||
.catch((reason) => {
|
toast.error(
|
||||||
writeToLog("ERROR", reason);
|
t("home.downloads.toasts.failed_to_delete_media", {
|
||||||
toast.error(
|
type: item.item.Type,
|
||||||
t("home.downloads.toasts.failed_to_delete_media", {
|
}),
|
||||||
type: item.item.Type,
|
);
|
||||||
}),
|
}),
|
||||||
);
|
),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deleteAllMedia = async () =>
|
||||||
|
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<>
|
||||||
showsVerticalScrollIndicator={false}
|
<View style={{ flex: 1 }}>
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||||
>
|
<View className='py-4'>
|
||||||
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||||
{/* 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>
|
||||||
@@ -215,96 +214,139 @@ export default function page() {
|
|||||||
{t("home.downloads.no_items_in_queue")}
|
{t("home.downloads.no_items_in_queue")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View> */}
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<ActiveDownloads />
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
{movies.length > 0 && (
|
||||||
{movies?.map((item) => (
|
<View className='mb-4'>
|
||||||
<TouchableItemRouter
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
item={item.item}
|
<Text className='text-lg font-bold'>
|
||||||
isOffline
|
{t("home.downloads.movies")}
|
||||||
key={item.item.Id}
|
</Text>
|
||||||
>
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
<MovieCard item={item.item} />
|
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||||
</TouchableItemRouter>
|
</View>
|
||||||
))}
|
</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>
|
)}
|
||||||
</View>
|
{groupedBySeries.length > 0 && (
|
||||||
)}
|
<View className='mb-4'>
|
||||||
{groupedBySeries.length > 0 && (
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
<View className='mb-4'>
|
<Text className='text-lg font-bold'>
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
{t("home.downloads.tvseries")}
|
||||||
<Text className='text-lg font-bold'>
|
</Text>
|
||||||
{t("home.downloads.tvseries")}
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
</Text>
|
<Text className='text-xs font-bold'>
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
{groupedBySeries?.length}
|
||||||
<Text className='text-xs font-bold'>
|
</Text>
|
||||||
{groupedBySeries?.length}
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
{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>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className='p-4 space-y-4 mb-4'>
|
||||||
|
<Button color='purple' onPress={deleteMovies}>
|
||||||
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
|
</Button>
|
||||||
|
<Button color='purple' onPress={deleteShows}>
|
||||||
|
{t("home.downloads.delete_all_tvseries_button")}
|
||||||
|
</Button>
|
||||||
|
{otherMedia.length > 0 && (
|
||||||
|
<Button color='purple' onPress={deleteOtherMedia}>
|
||||||
|
{t("home.downloads.delete_all_other_media_button")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button color='red' onPress={deleteAllMedia}>
|
||||||
|
{t("home.downloads.delete_all_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||||
import { Home } from "../../../../components/home/Home";
|
|
||||||
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
|
||||||
|
|
||||||
const Index = () => {
|
export default function page() {
|
||||||
const { settings } = useSettings();
|
return <HomeIndex />;
|
||||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
}
|
||||||
|
|
||||||
if (showLargeHomeCarousel) {
|
|
||||||
return <HomeWithCarousel />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Home />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Index;
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
|
import {
|
||||||
|
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";
|
||||||
@@ -11,7 +13,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 { Platform, TouchableOpacity, View } from "react-native";
|
import { 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";
|
||||||
@@ -47,13 +49,14 @@ export default function page() {
|
|||||||
<FlashList
|
<FlashList
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: Platform.OS === "android" ? 17 : 0,
|
paddingTop: 17,
|
||||||
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 px-2'>
|
<Text className='text-red-600'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -56,16 +56,12 @@ 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
|
<View className='p-4 flex flex-col gap-y-4'>
|
||||||
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' />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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,7 +16,6 @@ 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";
|
||||||
@@ -205,154 +204,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(
|
||||||
() => (
|
() => (
|
||||||
<FlatList
|
<View className=''>
|
||||||
horizontal
|
<FlatList
|
||||||
showsHorizontalScrollIndicator={false}
|
horizontal
|
||||||
contentContainerStyle={{
|
showsHorizontalScrollIndicator={false}
|
||||||
display: "flex",
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 15,
|
display: "flex",
|
||||||
paddingVertical: 16,
|
paddingHorizontal: 15,
|
||||||
flexDirection: "row",
|
paddingVertical: 16,
|
||||||
}}
|
flexDirection: "row",
|
||||||
extraData={[
|
}}
|
||||||
selectedGenres,
|
extraData={[
|
||||||
selectedYears,
|
selectedGenres,
|
||||||
selectedTags,
|
selectedYears,
|
||||||
sortBy,
|
selectedTags,
|
||||||
sortOrder,
|
sortBy,
|
||||||
]}
|
sortOrder,
|
||||||
data={[
|
]}
|
||||||
{
|
data={[
|
||||||
key: "reset",
|
{
|
||||||
component: <ResetFiltersButton />,
|
key: "reset",
|
||||||
},
|
component: <ResetFiltersButton />,
|
||||||
{
|
},
|
||||||
key: "genre",
|
{
|
||||||
component: (
|
key: "genre",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={collectionId}
|
className='mr-1'
|
||||||
queryKey='genreFilter'
|
id={collectionId}
|
||||||
queryFn={async () => {
|
queryKey='genreFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: collectionId,
|
userId: user?.Id,
|
||||||
});
|
parentId: collectionId,
|
||||||
return response.data.Genres || [];
|
});
|
||||||
}}
|
return response.data.Genres || [];
|
||||||
set={setSelectedGenres}
|
}}
|
||||||
values={selectedGenres}
|
set={setSelectedGenres}
|
||||||
title={t("library.filters.genres")}
|
values={selectedGenres}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.genres")}
|
||||||
searchFilter={(item, search) =>
|
renderItemLabel={(item) => item.toString()}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "year",
|
{
|
||||||
component: (
|
key: "year",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={collectionId}
|
className='mr-1'
|
||||||
queryKey='yearFilter'
|
id={collectionId}
|
||||||
queryFn={async () => {
|
queryKey='yearFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: collectionId,
|
userId: user?.Id,
|
||||||
});
|
parentId: collectionId,
|
||||||
return response.data.Years || [];
|
});
|
||||||
}}
|
return response.data.Years || [];
|
||||||
set={setSelectedYears}
|
}}
|
||||||
values={selectedYears}
|
set={setSelectedYears}
|
||||||
title={t("library.filters.years")}
|
values={selectedYears}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.years")}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
renderItemLabel={(item) => item.toString()}
|
||||||
/>
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "tags",
|
{
|
||||||
component: (
|
key: "tags",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={collectionId}
|
className='mr-1'
|
||||||
queryKey='tagsFilter'
|
id={collectionId}
|
||||||
queryFn={async () => {
|
queryKey='tagsFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: collectionId,
|
userId: user?.Id,
|
||||||
});
|
parentId: collectionId,
|
||||||
return response.data.Tags || [];
|
});
|
||||||
}}
|
return response.data.Tags || [];
|
||||||
set={setSelectedTags}
|
}}
|
||||||
values={selectedTags}
|
set={setSelectedTags}
|
||||||
title={t("library.filters.tags")}
|
values={selectedTags}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.tags")}
|
||||||
searchFilter={(item, search) =>
|
renderItemLabel={(item) => item.toString()}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "sortBy",
|
{
|
||||||
component: (
|
key: "sortBy",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={collectionId}
|
className='mr-1'
|
||||||
queryKey='sortBy'
|
id={collectionId}
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryKey='sortBy'
|
||||||
set={setSortBy}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
values={sortBy}
|
set={setSortBy}
|
||||||
title={t("library.filters.sort_by")}
|
values={sortBy}
|
||||||
renderItemLabel={(item) =>
|
title={t("library.filters.sort_by")}
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
renderItemLabel={(item) =>
|
||||||
}
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
searchFilter={(item, search) =>
|
}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "sortOrder",
|
{
|
||||||
component: (
|
key: "sortOrder",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={collectionId}
|
className='mr-1'
|
||||||
queryKey='sortOrder'
|
id={collectionId}
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryKey='sortOrder'
|
||||||
set={setSortOrder}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
values={sortOrder}
|
set={setSortOrder}
|
||||||
title={t("library.filters.sort_order")}
|
values={sortOrder}
|
||||||
renderItemLabel={(item) =>
|
title={t("library.filters.sort_order")}
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
renderItemLabel={(item) =>
|
||||||
}
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
searchFilter={(item, search) =>
|
}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
]}
|
},
|
||||||
renderItem={({ item }) => item.component}
|
]}
|
||||||
keyExtractor={(item) => item.key}
|
renderItem={({ item }) => item.component}
|
||||||
/>
|
keyExtractor={(item) => item.key}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
collectionId,
|
collectionId,
|
||||||
@@ -394,6 +393,7 @@ 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,29 +19,31 @@ 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();
|
||||||
@@ -63,7 +65,6 @@ 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);
|
||||||
|
|
||||||
@@ -114,10 +115,6 @@ 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);
|
||||||
@@ -159,31 +156,11 @@ 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
|
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
||||||
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
|
|
||||||
>
|
|
||||||
<ItemActions item={details} />
|
<ItemActions item={details} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -378,8 +355,6 @@ 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'>
|
||||||
@@ -389,25 +364,50 @@ 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 w-full'>
|
<View className='flex flex-col'>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<DropdownMenu.Root>
|
||||||
{t("jellyseerr.issue_type")}
|
<DropdownMenu.Trigger>
|
||||||
</Text>
|
<View className='flex flex-col'>
|
||||||
<PlatformDropdown
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
groups={issueTypeOptionGroups}
|
{t("jellyseerr.issue_type")}
|
||||||
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>
|
||||||
title={t("jellyseerr.types")}
|
<DropdownMenu.Content
|
||||||
open={issueTypeDropdownOpen}
|
loop={false}
|
||||||
onOpenChange={setIssueTypeDropdownOpen}
|
side='bottom'
|
||||||
/>
|
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,11 +65,9 @@ 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 () => {
|
||||||
if (!api || !user?.Id || !item?.Id) return [];
|
const res = await getTvShowsApi(api!).getEpisodes({
|
||||||
|
seriesId: item?.Id!,
|
||||||
const res = await getTvShowsApi(api).getEpisodes({
|
userId: user?.Id!,
|
||||||
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,143 +271,145 @@ const Page = () => {
|
|||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<FlatList
|
<View className=''>
|
||||||
horizontal
|
<FlatList
|
||||||
showsHorizontalScrollIndicator={false}
|
horizontal
|
||||||
contentContainerStyle={{
|
showsHorizontalScrollIndicator={false}
|
||||||
display: "flex",
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 15,
|
display: "flex",
|
||||||
paddingVertical: 16,
|
paddingHorizontal: 15,
|
||||||
flexDirection: "row",
|
paddingVertical: 16,
|
||||||
}}
|
flexDirection: "row",
|
||||||
data={[
|
}}
|
||||||
{
|
data={[
|
||||||
key: "reset",
|
{
|
||||||
component: <ResetFiltersButton />,
|
key: "reset",
|
||||||
},
|
component: <ResetFiltersButton />,
|
||||||
{
|
},
|
||||||
key: "genre",
|
{
|
||||||
component: (
|
key: "genre",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={libraryId}
|
className='mr-1'
|
||||||
queryKey='genreFilter'
|
id={libraryId}
|
||||||
queryFn={async () => {
|
queryKey='genreFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: libraryId,
|
userId: user?.Id,
|
||||||
});
|
parentId: libraryId,
|
||||||
return response.data.Genres || [];
|
});
|
||||||
}}
|
return response.data.Genres || [];
|
||||||
set={setSelectedGenres}
|
}}
|
||||||
values={selectedGenres}
|
set={setSelectedGenres}
|
||||||
title={t("library.filters.genres")}
|
values={selectedGenres}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.genres")}
|
||||||
searchFilter={(item, search) =>
|
renderItemLabel={(item) => item.toString()}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "year",
|
{
|
||||||
component: (
|
key: "year",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={libraryId}
|
className='mr-1'
|
||||||
queryKey='yearFilter'
|
id={libraryId}
|
||||||
queryFn={async () => {
|
queryKey='yearFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: libraryId,
|
userId: user?.Id,
|
||||||
});
|
parentId: libraryId,
|
||||||
return response.data.Years || [];
|
});
|
||||||
}}
|
return response.data.Years || [];
|
||||||
set={setSelectedYears}
|
}}
|
||||||
values={selectedYears}
|
set={setSelectedYears}
|
||||||
title={t("library.filters.years")}
|
values={selectedYears}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.years")}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
renderItemLabel={(item) => item.toString()}
|
||||||
/>
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "tags",
|
{
|
||||||
component: (
|
key: "tags",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={libraryId}
|
className='mr-1'
|
||||||
queryKey='tagsFilter'
|
id={libraryId}
|
||||||
queryFn={async () => {
|
queryKey='tagsFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: libraryId,
|
userId: user?.Id,
|
||||||
});
|
parentId: libraryId,
|
||||||
return response.data.Tags || [];
|
});
|
||||||
}}
|
return response.data.Tags || [];
|
||||||
set={setSelectedTags}
|
}}
|
||||||
values={selectedTags}
|
set={setSelectedTags}
|
||||||
title={t("library.filters.tags")}
|
values={selectedTags}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.tags")}
|
||||||
searchFilter={(item, search) =>
|
renderItemLabel={(item) => item.toString()}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "sortBy",
|
{
|
||||||
component: (
|
key: "sortBy",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={libraryId}
|
className='mr-1'
|
||||||
queryKey='sortBy'
|
id={libraryId}
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryKey='sortBy'
|
||||||
set={setSortBy}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
values={sortBy}
|
set={setSortBy}
|
||||||
title={t("library.filters.sort_by")}
|
values={sortBy}
|
||||||
renderItemLabel={(item) =>
|
title={t("library.filters.sort_by")}
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
renderItemLabel={(item) =>
|
||||||
}
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
searchFilter={(item, search) =>
|
}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "sortOrder",
|
{
|
||||||
component: (
|
key: "sortOrder",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={libraryId}
|
className='mr-1'
|
||||||
queryKey='sortOrder'
|
id={libraryId}
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryKey='sortOrder'
|
||||||
set={setSortOrder}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
values={sortOrder}
|
set={setSortOrder}
|
||||||
title={t("library.filters.sort_order")}
|
values={sortOrder}
|
||||||
renderItemLabel={(item) =>
|
title={t("library.filters.sort_order")}
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
renderItemLabel={(item) =>
|
||||||
}
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
searchFilter={(item, search) =>
|
}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
]}
|
},
|
||||||
renderItem={({ item }) => item.component}
|
]}
|
||||||
keyExtractor={(item) => item.key}
|
renderItem={({ item }) => item.component}
|
||||||
/>
|
keyExtractor={(item) => item.key}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
libraryId,
|
libraryId,
|
||||||
@@ -451,6 +453,7 @@ 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,208 +1,85 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, TouchableOpacity } from "react-native";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
|
||||||
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 [dropdownOpen, setDropdownOpen] = useState(false);
|
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Reset dropdown state when component unmounts or navigates away
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Memoize callbacks to prevent recreating on every render
|
|
||||||
const handleDisplayRow = useCallback(() => {
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
display: "row",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [settings.libraryOptions, updateSettings]);
|
|
||||||
|
|
||||||
const handleDisplayList = useCallback(() => {
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
display: "list",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [settings.libraryOptions, updateSettings]);
|
|
||||||
|
|
||||||
const handleImageStylePoster = useCallback(() => {
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
imageStyle: "poster",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [settings.libraryOptions, updateSettings]);
|
|
||||||
|
|
||||||
const handleImageStyleCover = useCallback(() => {
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
imageStyle: "cover",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [settings.libraryOptions, updateSettings]);
|
|
||||||
|
|
||||||
const handleToggleTitles = useCallback(() => {
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
showTitles: !settings.libraryOptions.showTitles,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [settings.libraryOptions, updateSettings]);
|
|
||||||
|
|
||||||
const handleToggleStats = useCallback(() => {
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
showStats: !settings.libraryOptions.showStats,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [settings.libraryOptions, updateSettings]);
|
|
||||||
|
|
||||||
// Memoize groups to prevent recreating the array on every render
|
|
||||||
const dropdownGroups = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
title: t("library.options.display"),
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t("library.options.row"),
|
|
||||||
value: "row",
|
|
||||||
selected: settings.libraryOptions.display === "row",
|
|
||||||
onPress: handleDisplayRow,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t("library.options.list"),
|
|
||||||
value: "list",
|
|
||||||
selected: settings.libraryOptions.display === "list",
|
|
||||||
onPress: handleDisplayList,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("library.options.image_style"),
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t("library.options.poster"),
|
|
||||||
value: "poster",
|
|
||||||
selected: settings.libraryOptions.imageStyle === "poster",
|
|
||||||
onPress: handleImageStylePoster,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t("library.options.cover"),
|
|
||||||
value: "cover",
|
|
||||||
selected: settings.libraryOptions.imageStyle === "cover",
|
|
||||||
onPress: handleImageStyleCover,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Options",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
type: "toggle" as const,
|
|
||||||
label: t("library.options.show_titles"),
|
|
||||||
value: settings.libraryOptions.showTitles,
|
|
||||||
onToggle: handleToggleTitles,
|
|
||||||
disabled: settings.libraryOptions.imageStyle === "poster",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "toggle" as const,
|
|
||||||
label: t("library.options.show_stats"),
|
|
||||||
value: settings.libraryOptions.showStats,
|
|
||||||
onToggle: handleToggleStats,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
t,
|
|
||||||
settings.libraryOptions,
|
|
||||||
handleDisplayRow,
|
|
||||||
handleDisplayList,
|
|
||||||
handleImageStylePoster,
|
|
||||||
handleImageStyleCover,
|
|
||||||
handleToggleTitles,
|
|
||||||
handleToggleStats,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!settings?.libraryOptions) return null;
|
if (!settings?.libraryOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<>
|
||||||
<Stack.Screen
|
<Stack>
|
||||||
name='index'
|
<Stack.Screen
|
||||||
options={{
|
name='index'
|
||||||
headerShown: !Platform.isTV,
|
options={{
|
||||||
headerTitle: t("tabs.library"),
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerTitle: t("tabs.library"),
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerBlurEffect: "none",
|
||||||
headerShadowVisible: false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerRight: () =>
|
headerShadowVisible: false,
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
headerRight: () =>
|
||||||
!Platform.isTV && (
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
<PlatformDropdown
|
!Platform.isTV && (
|
||||||
open={dropdownOpen}
|
<TouchableOpacity
|
||||||
onOpenChange={setDropdownOpen}
|
onPress={() => setOptionsSheetOpen(true)}
|
||||||
trigger={
|
className='flex flex-row items-center justify-center w-9 h-9'
|
||||||
<View className='pl-1.5'>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
}
|
),
|
||||||
title={t("library.options.display")}
|
}}
|
||||||
groups={dropdownGroups}
|
/>
|
||||||
/>
|
<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>
|
||||||
|
<LibraryOptionsSheet
|
||||||
|
open={optionsSheetOpen}
|
||||||
|
setOpen={setOptionsSheetOpen}
|
||||||
|
settings={settings.libraryOptions}
|
||||||
|
updateSettings={(options) =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={pluginSettings?.libraryOptions?.locked}
|
||||||
/>
|
/>
|
||||||
<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 { Platform, StyleSheet, View } from "react-native";
|
import { 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: Platform.OS === "android" ? 17 : 0,
|
paddingTop: 17,
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
paddingLeft: insets.left + 17,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right + 17,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
data={libraries}
|
data={libraries}
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
@@ -105,6 +105,7 @@ 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: "none",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ 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,
|
||||||
@@ -31,10 +33,8 @@ 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,30 +284,67 @@ export default function search() {
|
|||||||
)}
|
)}
|
||||||
<View
|
<View
|
||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{
|
||||||
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{jellyseerrApi && (
|
{jellyseerrApi && (
|
||||||
<View className='pl-4 pr-4 flex flex-row'>
|
<ScrollView
|
||||||
<SearchTabButtons
|
horizontal
|
||||||
searchType={searchType}
|
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||||
setSearchType={setSearchType}
|
>
|
||||||
t={t}
|
<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>
|
||||||
{searchType === "Discover" &&
|
{searchType === "Discover" &&
|
||||||
!loading &&
|
!loading &&
|
||||||
noResults &&
|
noResults &&
|
||||||
debouncedSearch.length > 0 && (
|
debouncedSearch.length > 0 && (
|
||||||
<DiscoverFilters
|
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||||
searchFilterId={searchFilterId}
|
<FilterButton
|
||||||
orderFilterId={orderFilterId}
|
id={searchFilterId}
|
||||||
jellyseerrOrderBy={jellyseerrOrderBy}
|
queryKey='jellyseerr_search'
|
||||||
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
queryFn={async () =>
|
||||||
jellyseerrSortOrder={jellyseerrSortOrder}
|
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||||
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
Number.isNaN(Number(v)),
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</View>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className='mt-2'>
|
<View className='mt-2'>
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ 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' />
|
||||||
@@ -71,7 +70,10 @@ 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")
|
||||||
: (_e) => ({ sfSymbol: "house.fill" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "house.fill" }
|
||||||
|
: { sfSymbol: "house" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -82,12 +84,14 @@ 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")
|
||||||
: (_e) => ({ sfSymbol: "magnifyingglass" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "magnifyingglass" }
|
||||||
|
: { sfSymbol: "magnifyingglass" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -96,8 +100,14 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.favorites"),
|
title: t("tabs.favorites"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/heart.fill.png")
|
? ({ focused }) =>
|
||||||
: (_e) => ({ sfSymbol: "heart.fill" }),
|
focused
|
||||||
|
? require("@/assets/icons/heart.fill.png")
|
||||||
|
: require("@/assets/icons/heart.png")
|
||||||
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "heart.fill" }
|
||||||
|
: { sfSymbol: "heart" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -107,7 +117,10 @@ 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")
|
||||||
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "rectangle.stack.fill" }
|
||||||
|
: { sfSymbol: "rectangle.stack" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -118,7 +131,10 @@ export default function TabLayout() {
|
|||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "list.dash.fill" }
|
||||||
|
: { sfSymbol: "list.dash" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ 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";
|
||||||
@@ -57,7 +56,6 @@ export default function page() {
|
|||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [isPipMode, setIsPipMode] = useState(false);
|
|
||||||
const [aspectRatio, setAspectRatio] = useState<
|
const [aspectRatio, setAspectRatio] = useState<
|
||||||
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||||
>("default");
|
>("default");
|
||||||
@@ -77,10 +75,7 @@ export default function page() {
|
|||||||
: require("react-native-volume-manager");
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
const downloadUtils = useDownload();
|
const downloadUtils = useDownload();
|
||||||
const downloadedFiles = useMemo(
|
const downloadedFiles = downloadUtils.getDownloadedItems();
|
||||||
() => downloadUtils.getDownloadedItems(),
|
|
||||||
[downloadUtils.getDownloadedItems],
|
|
||||||
);
|
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
@@ -110,7 +105,6 @@ 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();
|
||||||
@@ -173,16 +167,6 @@ 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;
|
||||||
@@ -298,14 +282,12 @@ 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,
|
||||||
@@ -336,9 +318,9 @@ export default function page() {
|
|||||||
}, [navigation, stop]);
|
}, [navigation, stop]);
|
||||||
|
|
||||||
const currentPlayStateInfo = useCallback(() => {
|
const currentPlayStateInfo = useCallback(() => {
|
||||||
if (!stream || !item?.Id) return;
|
if (!stream) 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,
|
||||||
@@ -745,12 +727,9 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
onPipStarted={(e) => {
|
|
||||||
setIsPipMode(e.nativeEvent.pipStarted);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{isMounted === true && item && !isPipMode && (
|
{isMounted === true && item && (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream?.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -782,7 +761,6 @@ export default function page() {
|
|||||||
setAspectRatio={setAspectRatio}
|
setAspectRatio={setAspectRatio}
|
||||||
setScaleFactor={setScaleFactor}
|
setScaleFactor={setScaleFactor}
|
||||||
isVlc
|
isVlc
|
||||||
api={api}
|
|
||||||
downloadedFiles={downloadedFiles}
|
downloadedFiles={downloadedFiles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
266
app/_layout.tsx
266
app/_layout.tsx
@@ -1,24 +1,18 @@
|
|||||||
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 { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
BACKGROUND_FETCH_TASK_SESSIONS,
|
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||||
@@ -32,29 +26,44 @@ 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 { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
|
||||||
import { getLocales } from "expo-localization";
|
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 type { EventSubscription } from "expo-modules-core";
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
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 { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||||
import { router, Stack, useSegments } from "expo-router";
|
import { useAtom } from "jotai";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import { Toaster } from "sonner-native";
|
||||||
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({
|
||||||
@@ -122,7 +131,24 @@ 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -187,7 +213,11 @@ export default function RootLayout() {
|
|||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30000,
|
staleTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retryOnMount: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -196,7 +226,8 @@ 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 _segments = useSegments();
|
const appState = useRef(AppState.currentState);
|
||||||
|
const segments = useSegments();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
@@ -225,7 +256,7 @@ function Layout() {
|
|||||||
} else console.log("No token available");
|
} else console.log("No token available");
|
||||||
}, [api, expoPushToken, user]);
|
}, [api, expoPushToken, user]);
|
||||||
|
|
||||||
const registerNotifications = useCallback(async () => {
|
async function registerNotifications() {
|
||||||
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", {
|
||||||
@@ -256,21 +287,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({
|
Notifications?.getExpoPushTokenAsync()
|
||||||
projectId: "e79219d1-797f-4fbe-9fa1-cfd360690a68",
|
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
||||||
})
|
.catch((reason: any) => console.log("Failed to get token", reason));
|
||||||
.then((token: ExpoPushToken) => {
|
|
||||||
if (token) {
|
|
||||||
console.log("Expo push token obtained:", token.data);
|
|
||||||
setExpoPushToken(token);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((reason: any) => {
|
|
||||||
console.error("Failed to get push token:", reason);
|
|
||||||
writeErrorLog("Failed to get Expo push token", reason);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [user]);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
@@ -334,70 +355,119 @@ function Layout() {
|
|||||||
responseListener.current?.remove();
|
responseListener.current?.remove();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user, api]);
|
||||||
|
|
||||||
|
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}>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<NetworkStatusProvider>
|
<PlaySettingsProvider>
|
||||||
<PlaySettingsProvider>
|
<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'
|
||||||
<Stack.Screen
|
options={{
|
||||||
name='(auth)/player'
|
headerShown: false,
|
||||||
options={{
|
title: "",
|
||||||
headerShown: false,
|
header: () => null,
|
||||||
title: "",
|
}}
|
||||||
header: () => null,
|
/>
|
||||||
}}
|
<Stack.Screen
|
||||||
/>
|
name='login'
|
||||||
<Stack.Screen
|
options={{
|
||||||
name='login'
|
headerShown: true,
|
||||||
options={{
|
title: "",
|
||||||
headerShown: true,
|
headerTransparent: true,
|
||||||
title: "",
|
}}
|
||||||
headerTransparent: Platform.OS === "ios",
|
/>
|
||||||
}}
|
<Stack.Screen name='+not-found' />
|
||||||
/>
|
</Stack>
|
||||||
<Stack.Screen name='+not-found' />
|
<Toaster
|
||||||
</Stack>
|
duration={4000}
|
||||||
<Toaster
|
toastOptions={{
|
||||||
duration={4000}
|
style: {
|
||||||
toastOptions={{
|
backgroundColor: "#262626",
|
||||||
style: {
|
borderColor: "#363639",
|
||||||
backgroundColor: "#262626",
|
borderWidth: 1,
|
||||||
borderColor: "#363639",
|
},
|
||||||
borderWidth: 1,
|
titleStyle: {
|
||||||
},
|
color: "white",
|
||||||
titleStyle: {
|
},
|
||||||
color: "white",
|
}}
|
||||||
},
|
closeButton
|
||||||
}}
|
/>
|
||||||
closeButton
|
</ThemeProvider>
|
||||||
/>
|
</BottomSheetModalProvider>
|
||||||
<GlobalModal />
|
</DownloadProvider>
|
||||||
</ThemeProvider>
|
</WebSocketProvider>
|
||||||
</BottomSheetModalProvider>
|
</LogProvider>
|
||||||
</GlobalModalProvider>
|
</PlaySettingsProvider>
|
||||||
</DownloadProvider>
|
|
||||||
</WebSocketProvider>
|
|
||||||
</LogProvider>
|
|
||||||
</PlaySettingsProvider>
|
|
||||||
</NetworkStatusProvider>
|
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ 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";
|
||||||
@@ -62,13 +63,12 @@ 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);
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 300);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
@@ -82,10 +82,10 @@ const Login: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
}}
|
}}
|
||||||
className='flex flex-row items-center pr-2 pl-1'
|
className='flex flex-row items-center'
|
||||||
>
|
>
|
||||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||||
<Text className=' ml-1 text-purple-600'>
|
<Text className='ml-2 text-purple-600'>
|
||||||
{t("login.change_server")}
|
{t("login.change_server")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -371,11 +371,10 @@ 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" : undefined}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
>
|
||||||
{api?.basePath ? (
|
{api?.basePath ? (
|
||||||
<View className='flex flex-col flex-1 items-center justify-center'>
|
<View className='flex flex-col h-full relative 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'>
|
||||||
@@ -444,7 +443,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 flex-1 items-center justify-center w-full'>
|
<View className='flex flex-col h-full 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 { storage } from "@/utils/mmkv";
|
import { MMKV } from "react-native-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
|
||||||
(storage as any).get = function <T>(key: string): T | undefined {
|
MMKV.prototype.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 @@ declare module "react-native-mmkv" {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
(storage as any).setAny = function (key: string, value: any | undefined): void {
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
try {
|
try {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
this.remove(key);
|
this.delete(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-worklets/plugin"],
|
plugins: ["nativewind/babel", "react-native-reanimated/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 { View, type ViewProps } from "react-native";
|
import { Platform, 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,11 +11,24 @@ 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>
|
||||||
|
|||||||
@@ -10,52 +10,45 @@ import { LinearGradient } from "expo-linear-gradient";
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
interpolate,
|
|
||||||
runOnJS,
|
runOnJS,
|
||||||
type SharedValue,
|
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} 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 { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
import { PlayedStatus } from "./PlayedStatus";
|
||||||
|
|
||||||
interface AppleTVCarouselProps {
|
interface AppleTVCarouselProps {
|
||||||
initialIndex?: number;
|
initialIndex?: number;
|
||||||
onItemChange?: (index: number) => void;
|
onItemChange?: (index: number) => void;
|
||||||
scrollOffset?: SharedValue<number>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||||
|
|
||||||
// Layout Constants
|
// Layout Constants
|
||||||
|
const CAROUSEL_HEIGHT = screenHeight / 1.45;
|
||||||
const GRADIENT_HEIGHT_TOP = 150;
|
const GRADIENT_HEIGHT_TOP = 150;
|
||||||
const GRADIENT_HEIGHT_BOTTOM = 150;
|
const GRADIENT_HEIGHT_BOTTOM = 150;
|
||||||
const LOGO_HEIGHT = 80;
|
const LOGO_HEIGHT = 80;
|
||||||
|
|
||||||
// Position Constants
|
// Position Constants
|
||||||
const LOGO_BOTTOM_POSITION = 260;
|
const LOGO_BOTTOM_POSITION = 210;
|
||||||
const GENRES_BOTTOM_POSITION = 220;
|
const GENRES_BOTTOM_POSITION = 170;
|
||||||
const OVERVIEW_BOTTOM_POSITION = 165;
|
const CONTROLS_BOTTOM_POSITION = 100;
|
||||||
const CONTROLS_BOTTOM_POSITION = 80;
|
const DOTS_BOTTOM_POSITION = 60;
|
||||||
const DOTS_BOTTOM_POSITION = 40;
|
|
||||||
|
|
||||||
// Size Constants
|
// Size Constants
|
||||||
const DOT_HEIGHT = 6;
|
const DOT_HEIGHT = 6;
|
||||||
@@ -65,15 +58,13 @@ 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 = 10;
|
const CONTROLS_GAP = 20;
|
||||||
const _TEXT_MARGIN_TOP = 16;
|
const _TEXT_MARGIN_TOP = 16;
|
||||||
|
|
||||||
// Border Radius Constants
|
// Border Radius Constants
|
||||||
@@ -92,16 +83,13 @@ 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.3;
|
const OVERLAY_OPACITY = 0.4;
|
||||||
const DOT_INACTIVE_OPACITY = 0.6;
|
const DOT_INACTIVE_OPACITY = 0.6;
|
||||||
const TEXT_OPACITY = 0.9;
|
const TEXT_OPACITY = 0.9;
|
||||||
|
|
||||||
@@ -159,21 +147,14 @@ const DotIndicator = ({
|
|||||||
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||||
initialIndex = 0,
|
initialIndex = 0,
|
||||||
onItemChange,
|
onItemChange,
|
||||||
scrollOffset,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const { isConnected, serverConnected } = useNetworkStatus();
|
const { isConnected, serverConnected } = useNetworkStatus();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
|
||||||
const isLandscape = screenWidth >= screenHeight;
|
|
||||||
const carouselHeight = useMemo(
|
|
||||||
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
|
|
||||||
[isLandscape, screenHeight],
|
|
||||||
);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const translateX = useSharedValue(-initialIndex * screenWidth);
|
const translateX = useSharedValue(-currentIndex * screenWidth);
|
||||||
|
|
||||||
const isQueryEnabled =
|
const isQueryEnabled =
|
||||||
!!api && !!user?.Id && isConnected && serverConnected === true;
|
!!api && !!user?.Id && isConnected && serverConnected === true;
|
||||||
@@ -187,7 +168,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", "Overview"],
|
fields: ["Genres"],
|
||||||
limit: 2,
|
limit: 2,
|
||||||
});
|
});
|
||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
@@ -202,7 +183,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", "Overview"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: 2,
|
limit: 2,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
@@ -221,7 +202,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", "Overview"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
});
|
});
|
||||||
@@ -237,21 +218,11 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
const nextItems = nextUpData ?? [];
|
const nextItems = nextUpData ?? [];
|
||||||
const recentItems = recentlyAddedData ?? [];
|
const recentItems = recentlyAddedData ?? [];
|
||||||
|
|
||||||
const allItems = [
|
return [
|
||||||
...continueItems.slice(0, 2),
|
...continueItems.slice(0, 2),
|
||||||
...nextItems.slice(0, 2),
|
...nextItems.slice(0, 2),
|
||||||
...recentItems.slice(0, 2),
|
...recentItems.slice(0, 2),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Deduplicate by item ID to prevent duplicate keys
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return allItems.filter((item) => {
|
|
||||||
if (item.Id && !seen.has(item.Id)) {
|
|
||||||
seen.add(item.Id);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
@@ -310,11 +281,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
translateX.value = -newIndex * screenWidth;
|
translateX.value = -newIndex * screenWidth;
|
||||||
return newIndex;
|
return newIndex;
|
||||||
});
|
});
|
||||||
}, [hasItems, items, initialIndex, screenWidth, translateX]);
|
}, [hasItems, items, initialIndex, translateX]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
translateX.value = -currentIndex * screenWidth;
|
|
||||||
}, [currentIndex, screenWidth, translateX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasItems) {
|
if (hasItems) {
|
||||||
@@ -334,7 +301,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
onItemChange?.(index);
|
onItemChange?.(index);
|
||||||
},
|
},
|
||||||
[hasItems, items, onItemChange, screenWidth, translateX],
|
[hasItems, items, onItemChange, translateX],
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigateToItem = useCallback(
|
const navigateToItem = useCallback(
|
||||||
@@ -381,30 +348,6 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const togglePlayedStatus = useMarkAsPlayed(items);
|
|
||||||
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
if (!scrollOffset) return {};
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[-carouselHeight / 2, 0, carouselHeight * 0.75],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[2, 1, 1],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderDots = () => {
|
const renderDots = () => {
|
||||||
if (!hasItems || items.length <= 1) return null;
|
if (!hasItems || items.length <= 1) return null;
|
||||||
|
|
||||||
@@ -438,7 +381,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: screenWidth,
|
width: screenWidth,
|
||||||
height: carouselHeight,
|
height: CAROUSEL_HEIGHT,
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -530,36 +473,6 @@ 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={{
|
||||||
@@ -636,30 +549,20 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: screenWidth,
|
width: screenWidth,
|
||||||
height: carouselHeight,
|
height: CAROUSEL_HEIGHT,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Background Backdrop */}
|
{/* Background Backdrop */}
|
||||||
<Animated.View
|
<ItemImage
|
||||||
style={[
|
item={item}
|
||||||
{
|
variant='Backdrop'
|
||||||
width: "100%",
|
style={{
|
||||||
height: "100%",
|
width: "100%",
|
||||||
position: "absolute",
|
height: "100%",
|
||||||
},
|
position: "absolute",
|
||||||
headerAnimatedStyle,
|
}}
|
||||||
]}
|
/>
|
||||||
>
|
|
||||||
<ItemImage
|
|
||||||
item={item}
|
|
||||||
variant='Backdrop'
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Dark Overlay */}
|
{/* Dark Overlay */}
|
||||||
<View
|
<View
|
||||||
@@ -786,39 +689,6 @@ 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={{
|
||||||
@@ -849,10 +719,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Mark as Played */}
|
{/* Mark as Played */}
|
||||||
<MarkAsPlayedLargeButton
|
<PlayedStatus items={[item]} size='large' />
|
||||||
isPlayed={item.UserData?.Played ?? false}
|
|
||||||
onToggle={togglePlayedStatus}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -864,7 +731,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: carouselHeight,
|
height: CAROUSEL_HEIGHT,
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
@@ -882,7 +749,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
@@ -891,7 +758,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
width: screenWidth * items.length,
|
width: screenWidth * items.length,
|
||||||
},
|
},
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -18,8 +20,6 @@ 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,58 +31,55 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
[audioStreams, selected],
|
[audioStreams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const optionGroups: OptionGroup[] = useMemo(
|
const { t } = useTranslation();
|
||||||
() => [
|
|
||||||
{
|
|
||||||
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 (
|
||||||
<PlatformDropdown
|
<View
|
||||||
groups={optionGroups}
|
className='flex shrink'
|
||||||
trigger={trigger}
|
style={{
|
||||||
title={t("item_card.audio")}
|
minWidth: 50,
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
onOptionSelect={handleOptionSelect}
|
|
||||||
expoUIConfig={{
|
|
||||||
hostStyle: { flex: 1 },
|
|
||||||
}}
|
}}
|
||||||
bottomSheetConfig={{
|
>
|
||||||
enablePanDownToClose: true,
|
<DropdownMenu.Root>
|
||||||
}}
|
<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,8 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
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;
|
||||||
@@ -59,8 +61,6 @@ 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,59 +76,53 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
}, [inverted]);
|
}, [inverted]);
|
||||||
|
|
||||||
const optionGroups: OptionGroup[] = useMemo(
|
const { t } = useTranslation();
|
||||||
() => [
|
|
||||||
{
|
|
||||||
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 (
|
||||||
<PlatformDropdown
|
<View
|
||||||
groups={optionGroups}
|
className='flex shrink'
|
||||||
trigger={trigger}
|
style={{
|
||||||
title={t("item_card.quality")}
|
minWidth: 60,
|
||||||
open={open}
|
maxWidth: 200,
|
||||||
onOpenChange={setOpen}
|
|
||||||
onOptionSelect={handleOptionSelect}
|
|
||||||
expoUIConfig={{
|
|
||||||
hostStyle: { flex: 1 },
|
|
||||||
}}
|
}}
|
||||||
bottomSheetConfig={{
|
>
|
||||||
enablePanDownToClose: true,
|
<DropdownMenu.Root>
|
||||||
}}
|
<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,8 +64,9 @@ 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, downloadedItems } = useDownload();
|
const { processes, startBackgroundDownload, getDownloadedItems } =
|
||||||
const downloadedFiles = downloadedItems;
|
useDownload();
|
||||||
|
const downloadedFiles = getDownloadedItems();
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
@@ -89,8 +90,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((_index: number) => {
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
// Modal state tracking handled by BottomSheetModal
|
// Ensure modal is fully dismissed when index is -1
|
||||||
|
if (index === -1) {
|
||||||
|
// Modal is fully closed
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
@@ -153,13 +157,6 @@ 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 = () => {
|
||||||
@@ -259,12 +256,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
throw new Error("No item id");
|
throw new Error("No item id");
|
||||||
}
|
}
|
||||||
|
|
||||||
closeModal();
|
// Ensure modal is dismissed before starting download
|
||||||
|
await closeModal();
|
||||||
|
|
||||||
// Wait for modal dismiss animation to complete
|
// Small delay to ensure modal is fully dismissed
|
||||||
requestAnimationFrame(() => {
|
setTimeout(() => {
|
||||||
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"),
|
||||||
@@ -284,14 +282,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderButtonContent = () => {
|
const renderButtonContent = () => {
|
||||||
// For single item downloads, show progress if item is being processed
|
if (processes.length > 0 && itemsProcesses.length > 0) {
|
||||||
// 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 />
|
||||||
) : (
|
) : (
|
||||||
@@ -345,6 +336,9 @@ 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
|
||||||
@@ -365,18 +359,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 w-full'>
|
<View className='flex flex-col space-y-2 w-full items-start'>
|
||||||
<View className='items-start'>
|
<BitrateSelector
|
||||||
<BitrateSelector
|
inverted
|
||||||
inverted
|
onChange={(val) =>
|
||||||
onChange={(val) =>
|
setSelectedOptions(
|
||||||
setSelectedOptions(
|
(prev) => prev && { ...prev, bitrate: val },
|
||||||
(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>
|
||||||
@@ -388,23 +380,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
{itemsNotDownloaded.length === 1 && (
|
{itemsNotDownloaded.length === 1 && (
|
||||||
<View>
|
<View>
|
||||||
<View className='items-start'>
|
<MediaSourceSelector
|
||||||
<MediaSourceSelector
|
item={items[0]}
|
||||||
item={items[0]}
|
onChange={(val) =>
|
||||||
onChange={(val) =>
|
setSelectedOptions(
|
||||||
setSelectedOptions(
|
(prev) =>
|
||||||
(prev) =>
|
prev && {
|
||||||
prev && {
|
...prev,
|
||||||
...prev,
|
mediaSource: val,
|
||||||
mediaSource: val,
|
},
|
||||||
},
|
)
|
||||||
)
|
}
|
||||||
}
|
selected={selectedOptions?.mediaSource}
|
||||||
selected={selectedOptions?.mediaSource}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{selectedOptions?.mediaSource && (
|
{selectedOptions?.mediaSource && (
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
@@ -437,7 +427,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button onPress={acceptDownloadOptions} color='purple'>
|
<Button
|
||||||
|
className='mt-auto'
|
||||||
|
onPress={acceptDownloadOptions}
|
||||||
|
color='purple'
|
||||||
|
>
|
||||||
{t("item_card.download.download_button")}
|
{t("item_card.download.download_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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 mb-2'>
|
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||||
<BitrateSheet
|
<BitrateSheet
|
||||||
className='mr-1'
|
className='mr-1'
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
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> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -21,7 +23,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) => {
|
||||||
@@ -44,60 +46,50 @@ 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 (
|
||||||
<PlatformDropdown
|
<View
|
||||||
groups={optionGroups}
|
className='flex shrink'
|
||||||
trigger={trigger}
|
style={{
|
||||||
title={t("item_card.video")}
|
minWidth: 50,
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
onOptionSelect={handleOptionSelect}
|
|
||||||
expoUIConfig={{
|
|
||||||
hostStyle: { flex: 1 },
|
|
||||||
}}
|
}}
|
||||||
bottomSheetConfig={{
|
>
|
||||||
enablePanDownToClose: true,
|
<DropdownMenu.Root>
|
||||||
}}
|
<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-4'
|
className='relative flex-1 bg-transparent pb-24'
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
// Background Linear Gradient
|
// Background Linear Gradient
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
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 } 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();
|
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS === "android" && controlledOpen === true) {
|
|
||||||
showModal(
|
|
||||||
<BottomSheetContent
|
|
||||||
title={title}
|
|
||||||
groups={groups}
|
|
||||||
onOptionSelect={onOptionSelect}
|
|
||||||
onClose={() => {
|
|
||||||
hideModal();
|
|
||||||
controlledOnOpenChange?.(false);
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
{
|
|
||||||
snapPoints: ["90%"],
|
|
||||||
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [controlledOpen]);
|
|
||||||
|
|
||||||
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: Direct modal trigger
|
|
||||||
const handlePress = () => {
|
|
||||||
showModal(
|
|
||||||
<BottomSheetContent
|
|
||||||
title={title}
|
|
||||||
groups={groups}
|
|
||||||
onOptionSelect={onOptionSelect}
|
|
||||||
onClose={hideModal}
|
|
||||||
/>,
|
|
||||||
{
|
|
||||||
snapPoints: ["90%"],
|
|
||||||
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity onPress={handlePress} 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
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -33,9 +33,10 @@ 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 TouchableOpacity> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
@@ -164,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 ?? 0,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
@@ -363,52 +364,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
* *********************
|
* *********************
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// if (Platform.OS === "ios")
|
|
||||||
// return (
|
|
||||||
// <Host
|
|
||||||
// style={{
|
|
||||||
// height: 50,
|
|
||||||
// flex: 1,
|
|
||||||
// flexShrink: 0,
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
// <Button
|
|
||||||
// variant='glassProminent'
|
|
||||||
// onPress={onPress}
|
|
||||||
// color={effectiveColors.primary}
|
|
||||||
// modifiers={[fixedSize()]}
|
|
||||||
// >
|
|
||||||
// <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 || 0) -
|
|
||||||
// (item?.UserData?.PlaybackPositionTicks || 0),
|
|
||||||
// )}
|
|
||||||
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
|
||||||
// </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}
|
||||||
@@ -416,6 +371,7 @@ 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
|
||||||
@@ -443,11 +399,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
{runtimeTicksToMinutes(
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
(item?.RunTimeTicks || 0) -
|
|
||||||
(item?.UserData?.PlaybackPositionTicks || 0),
|
|
||||||
)}
|
|
||||||
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name='play-circle' size={24} />
|
<Ionicons name='play-circle' size={24} />
|
||||||
|
|||||||
@@ -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 { View, type ViewProps } from "react-native";
|
import { Platform, 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,10 +14,25 @@ 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
|
||||||
color={allPlayed ? "purple" : "white"}
|
fillColor={allPlayed ? "primary" : undefined}
|
||||||
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-transparent"
|
fillColor ? fillColorClass : "bg-neutral-800/80"
|
||||||
}`}
|
}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } 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;
|
||||||
@@ -19,8 +21,6 @@ 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,83 +30,64 @@ 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 (
|
||||||
<PlatformDropdown
|
<View
|
||||||
groups={optionGroups}
|
className='flex col shrink justify-start place-self-start items-start'
|
||||||
trigger={trigger}
|
style={{
|
||||||
title={t("item_card.subtitles")}
|
minWidth: 60,
|
||||||
open={open}
|
maxWidth: 200,
|
||||||
onOpenChange={setOpen}
|
|
||||||
onOptionSelect={handleOptionSelect}
|
|
||||||
expoUIConfig={{
|
|
||||||
hostStyle: { flex: 1 },
|
|
||||||
}}
|
}}
|
||||||
bottomSheetConfig={{
|
>
|
||||||
enablePanDownToClose: true,
|
<DropdownMenu.Root>
|
||||||
}}
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,27 +33,12 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
() => streams?.find((x) => x.Index === selected),
|
() => streams?.find((x) => x.Index === selected),
|
||||||
[streams, selected],
|
[streams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const noneOption = useMemo(
|
|
||||||
() => ({ Index: -1, DisplayTitle: t("common.none") }),
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Creates a modified data array that includes a "None" option for subtitles
|
|
||||||
// We might want to possibly do this for other places, like audio?
|
|
||||||
const addNoneToSubtitles = useMemo(() => {
|
|
||||||
if (streamType === "Subtitle") {
|
|
||||||
const result = streams ? [noneOption, ...streams] : [noneOption];
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return streams;
|
|
||||||
}, [streams, streamType, noneOption]);
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
if (isTv || (streams && streams.length === 0)) return null;
|
if (isTv || (streams && streams.length === 0)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
<View className='flex shrink' style={{ minWidth: 25 }} {...props}>
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col'>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -61,9 +46,7 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
onPress={() => setOpen(true)}
|
onPress={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{selected === -1 && streamType === "Subtitle"
|
{selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
||||||
? t("common.none")
|
|
||||||
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -71,14 +54,8 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
title={title}
|
title={title}
|
||||||
data={addNoneToSubtitles || []}
|
data={streams || []}
|
||||||
values={
|
values={selectedSteam ? [selectedSteam] : []}
|
||||||
selected === -1 && streamType === "Subtitle"
|
|
||||||
? [{ Index: -1, DisplayTitle: t("common.none") }]
|
|
||||||
: selectedSteam
|
|
||||||
? [selectedSteam]
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
multiple={false}
|
multiple={false}
|
||||||
searchFilter={(item, query) => {
|
searchFilter={(item, query) => {
|
||||||
const label = (item as any).DisplayTitle || "";
|
const label = (item as any).DisplayTitle || "";
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
125
components/common/Dropdown.tsx
Normal file
125
components/common/Dropdown.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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=' rounded-full p-2'
|
className=' bg-neutral-800/80 rounded-full p-2'
|
||||||
{...touchableOpacityProps}
|
{...touchableOpacityProps}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -3,12 +3,17 @@ 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 Omit<FlashListProps<T>, "renderItem" | "estimatedItemSize" | "data"> {
|
extends PartialExcept<
|
||||||
|
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;
|
||||||
@@ -39,7 +44,7 @@ export const HorizontalScroll = <T,>(
|
|||||||
...restProps
|
...restProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const flashListRef = useRef<React.ComponentRef<typeof FlashList<T>>>(null);
|
const flashListRef = useRef<FlashList<T>>(null);
|
||||||
|
|
||||||
useImperativeHandle(ref!, () => ({
|
useImperativeHandle(ref!, () => ({
|
||||||
scrollToIndex: (index: number, viewOffset: number) => {
|
scrollToIndex: (index: number, viewOffset: number) => {
|
||||||
@@ -73,6 +78,7 @@ 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,
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export function InfiniteHorizontalScroll({
|
|||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
if (
|
if (
|
||||||
!lastPage?.Items ||
|
!lastPage?.Items ||
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
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 } from "react";
|
import { type PropsWithChildren, useCallback, useMemo } 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 {
|
||||||
@@ -32,33 +38,90 @@ 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 (
|
||||||
<TouchableOpacity
|
<ContextMenu.Root>
|
||||||
onPress={() => {
|
<ContextMenu.Trigger>
|
||||||
if (!result) return;
|
<TouchableOpacity
|
||||||
|
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='gap-y-2'>
|
<View className='space-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,6 +6,7 @@ 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,
|
||||||
@@ -13,36 +14,49 @@ 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 { cancelDownload } = useDownload();
|
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
|
||||||
|
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 cancelDownload(id);
|
await removeProcess(id);
|
||||||
// cancelDownload already shows a toast, so don't show another one
|
toast.success(t("home.downloads.toasts.download_deleted"));
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting download:", error);
|
console.error("Error deleting download:", error);
|
||||||
@@ -50,48 +64,16 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const eta = useMemo(() => {
|
const eta = (p: JobStatus) => {
|
||||||
if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
|
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondsRemaining = calculateSmoothedETA(
|
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
||||||
process.id,
|
if (bytesRemaining <= 0) return null;
|
||||||
process.bytesDownloaded,
|
|
||||||
process.estimatedTotalSizeBytes,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!secondsRemaining || secondsRemaining <= 0) {
|
const secondsRemaining = bytesRemaining / p.speed;
|
||||||
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!);
|
||||||
@@ -116,7 +98,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
>
|
>
|
||||||
{process.status === "downloading" && (
|
{process.status === "downloading" && (
|
||||||
<View
|
<View
|
||||||
className={`bg-purple-600 h-1 absolute bottom-0 left-0 ${isTranscoding ? "animate-pulse" : ""}`}
|
className={`
|
||||||
|
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||||
|
`}
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
sanitizedProgress > 0
|
sanitizedProgress > 0
|
||||||
@@ -127,10 +111,26 @@ 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 z-10'>
|
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 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-2 bg-neutral-800 rounded-full'
|
className='p-1'
|
||||||
>
|
>
|
||||||
<Ionicons name='close' size={20} color='red' />
|
<Ionicons name='close' size={20} color='red' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -152,53 +152,47 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className='shrink mb-1 flex-1 pr-12'>
|
<View className='shrink mb-1 flex-1'>
|
||||||
<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 font-semibold'>
|
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
|
||||||
{sanitizedProgress.toFixed(0)}%
|
)}
|
||||||
|
{process.speed && process.speed > 0 && (
|
||||||
|
<Text className='text-xs'>
|
||||||
|
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{downloadedAmount && (
|
{eta(process) && (
|
||||||
<Text className='text-xs opacity-75'>
|
<Text className='text-xs'>
|
||||||
{downloadedAmount}
|
{t("home.downloads.eta", { eta: eta(process) })}
|
||||||
{estimatedSize
|
|
||||||
? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}`
|
|
||||||
: ""}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Row 2: Speed + ETA */}
|
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
||||||
<View className='flex flex-row items-center gap-x-2 mt-0.5'>
|
<Text className='text-xs capitalize'>{process.status}</Text>
|
||||||
{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,13 +13,14 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
|||||||
items,
|
items,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { getDownloadedItemSize, downloadedItems } = useDownload();
|
const { getDownloadedItemSize, getDownloadedItems } = 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 (!downloadedItems) return;
|
if (!downloadedFiles) return;
|
||||||
|
|
||||||
let s = 0;
|
let s = 0;
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSize(s.bytesToReadable());
|
setSize(s.bytesToReadable());
|
||||||
}, [itemIds, downloadedItems, getDownloadedItemSize]);
|
}, [itemIds]);
|
||||||
|
|
||||||
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);
|
deleteFile(item.Id, "Episode");
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|||||||
@@ -19,13 +19,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
return storage.getString(items[0].SeriesId!);
|
return storage.getString(items[0].SeriesId!);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(async () => deleteItems(items), [items]);
|
||||||
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"];
|
||||||
|
|||||||
@@ -109,22 +109,11 @@ export const FilterSheet = <T,>({
|
|||||||
// to implement efficient "load more" functionality
|
// to implement efficient "load more" functionality
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!_data || _data.length === 0) return;
|
if (!_data || _data.length === 0) return;
|
||||||
|
const tmp = new Set(data);
|
||||||
const newData = [...data];
|
|
||||||
|
|
||||||
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
||||||
const item = _data[i];
|
tmp.add(_data[i]);
|
||||||
// Check if this item already exists in our data array
|
|
||||||
// some dups happened with re-renders during testing
|
|
||||||
const exists = newData.some((existingItem) =>
|
|
||||||
isEqual(existingItem, item),
|
|
||||||
);
|
|
||||||
if (!exists) {
|
|
||||||
newData.push(item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
setData(Array.from(tmp));
|
||||||
setData(newData);
|
|
||||||
}, [offset, _data]);
|
}, [offset, _data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -244,7 +233,7 @@ export const FilterSheet = <T,>({
|
|||||||
{data.length < (_data?.length || 0) && (
|
{data.length < (_data?.length || 0) && (
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setOffset(offset + LIMIT);
|
setOffset(offset + 100);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Load more
|
Load more
|
||||||
|
|||||||
@@ -1,510 +0,0 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
||||||
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 { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
|
||||||
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 InfiniteScrollingCollectionListSection = {
|
|
||||||
type: "InfiniteScrollingCollectionList";
|
|
||||||
title?: string;
|
|
||||||
queryKey: (string | undefined | null)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
pageSize?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaListSectionType = {
|
|
||||||
type: "MediaListSection";
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = InfiniteScrollingCollectionListSection | 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();
|
|
||||||
const [scrollY, setScrollY] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("scrollY", scrollY);
|
|
||||||
}, [scrollY]);
|
|
||||||
|
|
||||||
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,
|
|
||||||
pageSize: number = 10,
|
|
||||||
): InfiniteScrollingCollectionListSection => ({
|
|
||||||
title,
|
|
||||||
queryKey,
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
|
||||||
if (!api) return [];
|
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
|
||||||
const allData =
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 100, // Fetch a larger set for pagination
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes,
|
|
||||||
parentId,
|
|
||||||
})
|
|
||||||
).data || [];
|
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
|
||||||
},
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
pageSize,
|
|
||||||
}),
|
|
||||||
[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,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ss: Section[] = [
|
|
||||||
{
|
|
||||||
title: t("home.continue_watching"),
|
|
||||||
queryKey: ["home", "resumeItems"],
|
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
fields: ["Genres"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.next_up"),
|
|
||||||
queryKey: ["home", "nextUp-all"],
|
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
...latestMediaViews,
|
|
||||||
{
|
|
||||||
title: t("home.suggested_movies"),
|
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
type: ["Movie"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "vertical",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
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}`;
|
|
||||||
const pageSize = 10;
|
|
||||||
ss.push({
|
|
||||||
title: t(`${id}`),
|
|
||||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
|
||||||
if (section.items) {
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: section.items?.limit || pageSize,
|
|
||||||
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"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: section.nextUp?.limit || pageSize,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
if (section.latest) {
|
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
|
||||||
const allData =
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
|
||||||
limit: section.latest?.limit || 100, // Fetch larger set
|
|
||||||
isPlayed: section.latest?.isPlayed,
|
|
||||||
groupItems: section.latest?.groupItems,
|
|
||||||
})
|
|
||||||
).data || [];
|
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
|
||||||
}
|
|
||||||
if (section.custom) {
|
|
||||||
const response = await api.get<BaseItemDtoQueryResult>(
|
|
||||||
section.custom.endpoint,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
...(section.custom.query || {}),
|
|
||||||
userId: user?.Id,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: pageSize,
|
|
||||||
},
|
|
||||||
headers: section.custom.headers || {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: section?.orientation || "vertical",
|
|
||||||
pageSize,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, settings?.home?.sections, t]);
|
|
||||||
|
|
||||||
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'
|
|
||||||
onScroll={(event) => {
|
|
||||||
setScrollY(event.nativeEvent.contentOffset.y - 500);
|
|
||||||
}}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
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 === "InfiniteScrollingCollectionList") {
|
|
||||||
return (
|
|
||||||
<InfiniteScrollingCollectionList
|
|
||||||
key={index}
|
|
||||||
title={section.title}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
orientation={section.orientation}
|
|
||||||
hideIfEmpty
|
|
||||||
pageSize={section.pageSize}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (section.type === "MediaListSection") {
|
|
||||||
return (
|
|
||||||
<MediaListSection
|
|
||||||
key={index}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
scrollY={scrollY}
|
|
||||||
enableLazyLoading={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { Colors } from "../../constants/Colors";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -36,7 +35,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
pageSize = 10,
|
pageSize = 20,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
@@ -53,9 +52,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
return allPages.length * pageSize;
|
return allPages.length * pageSize;
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 0,
|
||||||
refetchOnMount: false,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,13 +179,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
))}
|
))}
|
||||||
{/* Loading indicator for next page */}
|
{/* Loading indicator for next page */}
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<View
|
<View className='justify-center items-center w-16'>
|
||||||
style={{
|
<ActivityIndicator size='small' color='#6366f1' />
|
||||||
marginLeft: 8,
|
|
||||||
marginTop: orientation === "horizontal" ? 37 : 70,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size='small' color={Colors.primary} />
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ScrollView, View, type ViewProps } from "react-native";
|
import { ScrollView, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { useInView } from "@/hooks/useInView";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -22,8 +21,6 @@ interface Props extends ViewProps {
|
|||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
hideIfEmpty?: boolean;
|
hideIfEmpty?: boolean;
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
scrollY?: number; // For lazy loading
|
|
||||||
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
@@ -34,44 +31,33 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey,
|
queryKey,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
scrollY = 0,
|
|
||||||
enableLazyLoading = false,
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { ref, isInView, onLayout } = useInView(scrollY, {
|
|
||||||
enabled: enableLazyLoading,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 0,
|
||||||
refetchOnMount: false,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
enabled: enableLazyLoading ? isInView : true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
if (hideIfEmpty === true && data?.length === 0) return null;
|
||||||
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
|
||||||
|
|
||||||
if (hideIfEmpty === true && data?.length === 0 && !shouldShowSkeleton)
|
|
||||||
return null;
|
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View ref={ref} onLayout={onLayout} {...props}>
|
<View {...props}>
|
||||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{!shouldShowSkeleton && data?.length === 0 && (
|
{isLoading === false && data?.length === 0 && (
|
||||||
<View className='px-4'>
|
<View className='px-4'>
|
||||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{shouldShowSkeleton ? (
|
{isLoading ? (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row gap-2 px-4
|
flex flex-row gap-2 px-4
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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,22 +48,8 @@ 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 () =>
|
||||||
@@ -152,109 +138,6 @@ 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,
|
||||||
@@ -280,12 +163,15 @@ 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={handleDismiss}
|
onDismiss={onDismiss}
|
||||||
handleIndicatorStyle={{
|
handleIndicatorStyle={{
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
}}
|
}}
|
||||||
@@ -299,7 +185,6 @@ 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'>
|
||||||
@@ -314,112 +199,70 @@ 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 && (
|
||||||
<>
|
<>
|
||||||
<View className='flex flex-col'>
|
<Dropdown
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
data={defaultServiceDetails.profiles}
|
||||||
{t("jellyseerr.quality_profile")}
|
titleExtractor={(item) => item.name}
|
||||||
</Text>
|
placeholderText={
|
||||||
<PlatformDropdown
|
requestOverrides.profileName || defaultProfile.name
|
||||||
groups={qualityProfileOptions}
|
}
|
||||||
trigger={
|
keyExtractor={(item) => item.id.toString()}
|
||||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
label={t("jellyseerr.quality_profile")}
|
||||||
<Text numberOfLines={1}>
|
onSelected={(item) =>
|
||||||
{defaultServiceDetails.profiles.find(
|
item &&
|
||||||
(p) =>
|
setRequestOverrides((prev) => ({
|
||||||
p.id ===
|
...prev,
|
||||||
(requestOverrides.profileId ||
|
profileId: item?.id,
|
||||||
defaultProfile?.id),
|
}))
|
||||||
)?.name || defaultProfile?.name}
|
}
|
||||||
</Text>
|
title={t("jellyseerr.quality_profile")}
|
||||||
</View>
|
/>
|
||||||
}
|
<Dropdown
|
||||||
title={t("jellyseerr.quality_profile")}
|
data={defaultServiceDetails.rootFolders}
|
||||||
open={qualityProfileOpen}
|
titleExtractor={pathTitleExtractor}
|
||||||
onOpenChange={setQualityProfileOpen}
|
placeholderText={
|
||||||
/>
|
defaultFolder ? pathTitleExtractor(defaultFolder) : ""
|
||||||
</View>
|
}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
<View className='flex flex-col'>
|
label={t("jellyseerr.root_folder")}
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
onSelected={(item) =>
|
||||||
{t("jellyseerr.root_folder")}
|
item &&
|
||||||
</Text>
|
setRequestOverrides((prev) => ({
|
||||||
<PlatformDropdown
|
...prev,
|
||||||
groups={rootFolderOptions}
|
rootFolder: item.path,
|
||||||
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}>
|
title={t("jellyseerr.root_folder")}
|
||||||
{defaultServiceDetails.rootFolders.find(
|
/>
|
||||||
(f) =>
|
<Dropdown
|
||||||
f.path ===
|
multiple
|
||||||
(requestOverrides.rootFolder ||
|
data={defaultServiceDetails.tags}
|
||||||
defaultFolder?.path),
|
titleExtractor={(item) => item.label}
|
||||||
)
|
placeholderText={defaultTags.map((t) => t.label).join(",")}
|
||||||
? pathTitleExtractor(
|
keyExtractor={(item) => item.id.toString()}
|
||||||
defaultServiceDetails.rootFolders.find(
|
label={t("jellyseerr.tags")}
|
||||||
(f) =>
|
onSelected={(...selected) =>
|
||||||
f.path ===
|
setRequestOverrides((prev) => ({
|
||||||
(requestOverrides.rootFolder ||
|
...prev,
|
||||||
defaultFolder?.path),
|
tags: selected.map((i) => i.id),
|
||||||
)!,
|
}))
|
||||||
)
|
}
|
||||||
: pathTitleExtractor(defaultFolder!)}
|
title={t("jellyseerr.tags")}
|
||||||
</Text>
|
/>
|
||||||
</View>
|
<Dropdown
|
||||||
}
|
data={users}
|
||||||
title={t("jellyseerr.root_folder")}
|
titleExtractor={(item) => item.displayName}
|
||||||
open={rootFolderOpen}
|
placeholderText={jellyseerrUser!.displayName}
|
||||||
onOpenChange={setRootFolderOpen}
|
keyExtractor={(item) => item.id.toString() || ""}
|
||||||
/>
|
label={t("jellyseerr.request_as")}
|
||||||
</View>
|
onSelected={(item) =>
|
||||||
|
item &&
|
||||||
<View className='flex flex-col'>
|
setRequestOverrides((prev) => ({
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
...prev,
|
||||||
{t("jellyseerr.tags")}
|
userId: item?.id,
|
||||||
</Text>
|
}))
|
||||||
<PlatformDropdown
|
}
|
||||||
groups={tagsOptions}
|
title={t("jellyseerr.request_as")}
|
||||||
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>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useInView } from "@/hooks/useInView";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
|
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -22,29 +21,20 @@ import MoviePoster from "../posters/MoviePoster";
|
|||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
scrollY?: number; // For lazy loading
|
|
||||||
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MediaListSection: React.FC<Props> = ({
|
export const MediaListSection: React.FC<Props> = ({
|
||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
scrollY = 0,
|
|
||||||
enableLazyLoading = false,
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const { ref, isInView, onLayout } = useInView(scrollY, {
|
|
||||||
enabled: enableLazyLoading,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection } = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 0,
|
||||||
enabled: enableLazyLoading ? isInView : true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
@@ -70,7 +60,7 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View ref={ref} onLayout={onLayout} {...props}>
|
<View {...props}>
|
||||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||||
{collection.Name}
|
{collection.Name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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,9 +1,11 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { t } from "i18next";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
|
import { t } from "i18next";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { PlatformDropdown } from "../PlatformDropdown";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -31,7 +33,6 @@ 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>(
|
||||||
() =>
|
() =>
|
||||||
@@ -54,31 +55,6 @@ 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) {
|
||||||
@@ -120,23 +96,45 @@ 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 (
|
||||||
<PlatformDropdown
|
<DropdownMenu.Root>
|
||||||
groups={optionGroups}
|
<DropdownMenu.Trigger>
|
||||||
open={open}
|
<View className='flex flex-row'>
|
||||||
onOpenChange={setOpen}
|
<TouchableOpacity className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
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>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
}
|
</DropdownMenu.Trigger>
|
||||||
title={t("item_card.seasons")}
|
<DropdownMenu.Content
|
||||||
/>
|
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,10 +29,7 @@ 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 = useMemo(
|
const downloadedFiles = getDownloadedItems();
|
||||||
() => 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: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
fields: ["Overview", "Trickplay"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.data.TotalRecordCount === 0)
|
if (res.data.TotalRecordCount === 0)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useMemo } from "react";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, 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,31 +15,6 @@ 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;
|
||||||
|
|
||||||
@@ -47,19 +22,54 @@ 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")}>
|
||||||
<PlatformDropdown
|
<DropdownMenu.Root>
|
||||||
groups={optionGroups}
|
<DropdownMenu.Trigger>
|
||||||
trigger={
|
<TouchableOpacity className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
<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>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
}
|
</DropdownMenu.Trigger>
|
||||||
title={t("home.settings.languages.title")}
|
<DropdownMenu.Content
|
||||||
/>
|
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,13 +1,14 @@
|
|||||||
|
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 {}
|
||||||
@@ -21,39 +22,6 @@ 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;
|
||||||
|
|
||||||
@@ -80,10 +48,9 @@ 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")}>
|
||||||
<PlatformDropdown
|
<DropdownMenu.Root>
|
||||||
groups={optionGroups}
|
<DropdownMenu.Trigger>
|
||||||
trigger={
|
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3 '>
|
||||||
<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")}
|
||||||
@@ -93,10 +60,48 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
}
|
</DropdownMenu.Trigger>
|
||||||
title={t("home.settings.audio.language")}
|
<DropdownMenu.Content
|
||||||
/>
|
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,3 +1,44 @@
|
|||||||
export default function DownloadSettings() {
|
import { useMemo } from "react";
|
||||||
return null;
|
import { useTranslation } from "react-i18next";
|
||||||
|
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,4 +1,5 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -19,17 +20,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import Animated, {
|
|
||||||
useAnimatedRef,
|
|
||||||
useScrollViewOffset,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -39,15 +38,14 @@ 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 "../apple-tv-carousel/AppleTVCarousel";
|
import { AppleTVCarousel } from "../AppleTVCarousel";
|
||||||
|
|
||||||
type InfiniteScrollingCollectionListSection = {
|
type ScrollingCollectionListSection = {
|
||||||
type: "InfiniteScrollingCollectionList";
|
type: "ScrollingCollectionList";
|
||||||
title?: string;
|
title?: string;
|
||||||
queryKey: (string | undefined | null)[];
|
queryKey: (string | undefined | null)[];
|
||||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
pageSize?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaListSectionType = {
|
type MediaListSectionType = {
|
||||||
@@ -56,21 +54,26 @@ type MediaListSectionType = {
|
|||||||
queryFn: QueryFunction<BaseItemDto>;
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
type Section = ScrollingCollectionListSection | MediaListSectionType;
|
||||||
|
|
||||||
export const HomeWithCarousel = () => {
|
export const HomeIndex = () => {
|
||||||
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 [_loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||||
const headerOverlayOffset = Platform.isTV ? 0 : 60;
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
||||||
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
const insets = useSafeAreaInsets();
|
||||||
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
|
||||||
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
|
||||||
const prevIsConnected = useRef<boolean | null>(false);
|
const prevIsConnected = useRef<boolean | null>(false);
|
||||||
const {
|
const {
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -79,20 +82,15 @@ export const HomeWithCarousel = () => {
|
|||||||
retryCheck,
|
retryCheck,
|
||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [scrollY, setScrollY] = useState(0);
|
|
||||||
|
|
||||||
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({
|
||||||
@@ -100,24 +98,24 @@ export const HomeWithCarousel = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const hasDownloads = getDownloadedItems().length > 0;
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
className='ml-1.5'
|
className='p-2'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather
|
||||||
name='download'
|
name='download'
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
color={hasDownloads ? Colors.primary : "white"}
|
||||||
size={24}
|
size={22}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [navigation, router, hasDownloads]);
|
}, [navigation, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cleanCacheDirectory().catch((_e) =>
|
cleanCacheDirectory().catch((_e) =>
|
||||||
@@ -129,7 +127,7 @@ export const HomeWithCarousel = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||||
if ((segments as string[])[2] === "(home)")
|
if ((segments as string[])[2] === "(home)")
|
||||||
animatedScrollRef.current?.scrollTo({
|
scrollViewRef.current?.scrollTo({
|
||||||
y: Platform.isTV ? -152 : -100,
|
y: Platform.isTV ? -152 : -100,
|
||||||
animated: true,
|
animated: true,
|
||||||
});
|
});
|
||||||
@@ -175,7 +173,7 @@ export const HomeWithCarousel = () => {
|
|||||||
);
|
);
|
||||||
}, [userViews]);
|
}, [userViews]);
|
||||||
|
|
||||||
const _refetch = async () => {
|
const refetch = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await refreshStreamyfinPluginSettings();
|
await refreshStreamyfinPluginSettings();
|
||||||
await invalidateCache();
|
await invalidateCache();
|
||||||
@@ -188,48 +186,42 @@ export const HomeWithCarousel = () => {
|
|||||||
queryKey: string[],
|
queryKey: string[],
|
||||||
includeItemTypes: BaseItemKind[],
|
includeItemTypes: BaseItemKind[],
|
||||||
parentId: string | undefined,
|
parentId: string | undefined,
|
||||||
pageSize: number = 10,
|
): ScrollingCollectionListSection => ({
|
||||||
): InfiniteScrollingCollectionListSection => ({
|
|
||||||
title,
|
title,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async () => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
return (
|
||||||
const allData =
|
|
||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 100, // Fetch a larger set for pagination
|
limit: 20,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
})
|
})
|
||||||
).data || [];
|
).data || []
|
||||||
|
);
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
|
||||||
},
|
},
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
pageSize,
|
|
||||||
}),
|
}),
|
||||||
[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 [];
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
const latestMediaViews = collections.map((c) => {
|
||||||
const includeItemTypes: BaseItemKind[] =
|
const includeItemTypes: BaseItemKind[] =
|
||||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
? []
|
|
||||||
: ["Movie"];
|
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
const queryKey: string[] = [
|
const queryKey = [
|
||||||
"home",
|
"home",
|
||||||
`recentlyAddedIn${c.CollectionType}`,
|
`recentlyAddedIn${c.CollectionType}`,
|
||||||
user.Id!,
|
user?.Id!,
|
||||||
c.Id!,
|
c.Id!,
|
||||||
];
|
];
|
||||||
return createCollectionConfig(
|
return createCollectionConfig(
|
||||||
@@ -237,7 +229,6 @@ export const HomeWithCarousel = () => {
|
|||||||
queryKey,
|
queryKey,
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
c.Id,
|
c.Id,
|
||||||
10,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -245,56 +236,79 @@ export const HomeWithCarousel = () => {
|
|||||||
{
|
{
|
||||||
title: t("home.continue_watching"),
|
title: t("home.continue_watching"),
|
||||||
queryKey: ["home", "resumeItems"],
|
queryKey: ["home", "resumeItems"],
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
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"],
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
pageSize: 10,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("home.next_up"),
|
title: t("home.next_up"),
|
||||||
queryKey: ["home", "nextUp-all"],
|
queryKey: ["home", "nextUp-all"],
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
startIndex: pageParam,
|
limit: 20,
|
||||||
limit: 10,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
pageSize: 10,
|
|
||||||
},
|
},
|
||||||
...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],
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
limit: 10,
|
||||||
mediaType: ["Video"],
|
mediaType: ["Video"],
|
||||||
type: ["Movie"],
|
type: ["Movie"],
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
orientation: "vertical",
|
orientation: "vertical",
|
||||||
pageSize: 10,
|
},
|
||||||
|
{
|
||||||
|
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;
|
return ss;
|
||||||
@@ -303,18 +317,16 @@ export const HomeWithCarousel = () => {
|
|||||||
const customSections = useMemo(() => {
|
const customSections = useMemo(() => {
|
||||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||||
const ss: Section[] = [];
|
const ss: Section[] = [];
|
||||||
settings.home.sections.forEach((section, index) => {
|
for (const [index, section] of settings.home.sections.entries()) {
|
||||||
const id = section.title || `section-${index}`;
|
const id = section.title || `section-${index}`;
|
||||||
const pageSize = 10;
|
|
||||||
ss.push({
|
ss.push({
|
||||||
title: t(`${id}`),
|
title: t(`${id}`),
|
||||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async () => {
|
||||||
if (section.items) {
|
if (section.items) {
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
startIndex: pageParam,
|
limit: section.items?.limit || 25,
|
||||||
limit: section.items?.limit || pageSize,
|
|
||||||
recursive: true,
|
recursive: true,
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
sortBy: section.items?.sortBy,
|
sortBy: section.items?.sortBy,
|
||||||
@@ -328,8 +340,7 @@ export const HomeWithCarousel = () => {
|
|||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
startIndex: pageParam,
|
limit: section.nextUp?.limit || 25,
|
||||||
limit: section.nextUp?.limit || pageSize,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
@@ -337,31 +348,20 @@ export const HomeWithCarousel = () => {
|
|||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
}
|
}
|
||||||
if (section.latest) {
|
if (section.latest) {
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||||
const allData =
|
userId: user?.Id,
|
||||||
(
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
limit: section.latest?.limit || 25,
|
||||||
userId: user?.Id,
|
isPlayed: section.latest?.isPlayed,
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
groupItems: section.latest?.groupItems,
|
||||||
limit: section.latest?.limit || 100, // Fetch larger set
|
});
|
||||||
isPlayed: section.latest?.isPlayed,
|
return response.data || [];
|
||||||
groupItems: section.latest?.groupItems,
|
|
||||||
})
|
|
||||||
).data || [];
|
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
|
||||||
}
|
}
|
||||||
if (section.custom) {
|
if (section.custom) {
|
||||||
const response = await api.get<BaseItemDtoQueryResult>(
|
const response = await api.get<BaseItemDtoQueryResult>(
|
||||||
section.custom.endpoint,
|
section.custom.endpoint,
|
||||||
{
|
{
|
||||||
params: {
|
params: { ...(section.custom.query || {}), userId: user?.Id },
|
||||||
...(section.custom.query || {}),
|
|
||||||
userId: user?.Id,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: pageSize,
|
|
||||||
},
|
|
||||||
headers: section.custom.headers || {},
|
headers: section.custom.headers || {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -369,13 +369,12 @@ export const HomeWithCarousel = () => {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
orientation: section?.orientation || "vertical",
|
orientation: section?.orientation || "vertical",
|
||||||
pageSize,
|
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
return ss;
|
return ss;
|
||||||
}, [api, user?.Id, settings?.home?.sections, t]);
|
}, [api, user?.Id, settings?.home?.sections]);
|
||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
@@ -384,12 +383,15 @@ export const HomeWithCarousel = () => {
|
|||||||
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");
|
||||||
}
|
}
|
||||||
@@ -452,41 +454,42 @@ export const HomeWithCarousel = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.ScrollView
|
<ScrollView
|
||||||
scrollToOverflowEnabled={true}
|
scrollToOverflowEnabled={true}
|
||||||
ref={animatedScrollRef}
|
ref={scrollViewRef}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior='never'
|
contentInsetAdjustmentBehavior='never'
|
||||||
scrollEventThrottle={16}
|
refreshControl={
|
||||||
bounces={false}
|
<RefreshControl
|
||||||
overScrollMode='never'
|
refreshing={loading}
|
||||||
style={{ marginTop: -headerOverlayOffset }}
|
onRefresh={refetch}
|
||||||
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
tintColor='white' // For iOS
|
||||||
onScroll={(event) => {
|
colors={["white"]} // For Android
|
||||||
setScrollY(event.nativeEvent.contentOffset.y);
|
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
|
||||||
}}
|
/>
|
||||||
|
}
|
||||||
|
style={{ marginTop: Platform.isTV ? 0 : -100 }}
|
||||||
|
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
|
||||||
>
|
>
|
||||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
<AppleTVCarousel initialIndex={0} />
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
paddingTop: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col space-y-4'>
|
<View className='flex flex-col space-y-4'>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
if (section.type === "ScrollingCollectionList") {
|
||||||
return (
|
return (
|
||||||
<InfiniteScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
key={index}
|
key={index}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={section.pageSize}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -496,8 +499,6 @@ export const HomeWithCarousel = () => {
|
|||||||
key={index}
|
key={index}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
scrollY={scrollY}
|
|
||||||
enableLazyLoading={true}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -506,6 +507,33 @@ export const HomeWithCarousel = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='h-24' />
|
<View className='h-24' />
|
||||||
</Animated.ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to get suggestions
|
||||||
|
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 ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get the next up TV show for a series
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
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 { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, Switch, View } from "react-native";
|
import { Linking, Platform, Switch, TouchableOpacity } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import Dropdown from "@/components/common/Dropdown";
|
||||||
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";
|
||||||
@@ -20,8 +27,39 @@ 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 &&
|
||||||
@@ -51,65 +89,41 @@ 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
|
<ListItem
|
||||||
title={t("home.settings.other.video_orientation")}
|
title={t("home.settings.other.follow_device_orientation")}
|
||||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
disabled={pluginSettings?.followDeviceOrientation?.locked}
|
||||||
>
|
>
|
||||||
<PlatformDropdown
|
<Switch
|
||||||
groups={orientationOptions}
|
value={settings.followDeviceOrientation}
|
||||||
trigger={
|
disabled={pluginSettings?.followDeviceOrientation?.locked}
|
||||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ followDeviceOrientation: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.other.video_orientation")}
|
||||||
|
disabled={
|
||||||
|
pluginSettings?.defaultVideoOrientation?.locked ||
|
||||||
|
settings.followDeviceOrientation
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
data={orientations}
|
||||||
|
disabled={
|
||||||
|
pluginSettings?.defaultVideoOrientation?.locked ||
|
||||||
|
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[
|
||||||
@@ -122,9 +136,12 @@ export const OtherSettings: React.FC = () => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
label={t("home.settings.other.orientation")}
|
||||||
|
onSelected={(defaultVideoOrientation) =>
|
||||||
|
updateSettings({ defaultVideoOrientation })
|
||||||
}
|
}
|
||||||
title={t("home.settings.other.orientation")}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@@ -188,14 +205,6 @@ export const OtherSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
|
|
||||||
<Switch
|
|
||||||
value={settings.showLargeHomeCarousel}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ showLargeHomeCarousel: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/hide-libraries/page")}
|
onPress={() => router.push("/settings/hide-libraries/page")}
|
||||||
title={t("home.settings.other.hide_libraries")}
|
title={t("home.settings.other.hide_libraries")}
|
||||||
@@ -205,10 +214,13 @@ 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}
|
||||||
>
|
>
|
||||||
<PlatformDropdown
|
<Dropdown
|
||||||
groups={bitrateOptions}
|
data={BITRATES}
|
||||||
trigger={
|
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
keyExtractor={(item) => item.key}
|
||||||
|
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>
|
||||||
@@ -217,9 +229,10 @@ export const OtherSettings: React.FC = () => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
}
|
}
|
||||||
title={t("home.settings.other.default_quality")}
|
label={t("home.settings.other.default_quality")}
|
||||||
|
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -235,10 +248,12 @@ 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")}>
|
||||||
<PlatformDropdown
|
<Dropdown
|
||||||
groups={autoPlayEpisodeOptions}
|
data={AUTOPLAY_EPISODES_COUNT(t)}
|
||||||
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]'>
|
||||||
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -247,9 +262,12 @@ export const OtherSettings: React.FC = () => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
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 className='mt-4'>
|
<View>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
title={t("home.settings.plugins.plugins_title")}
|
title={t("home.settings.plugins.plugins_title")}
|
||||||
className='mb-4'
|
className='mb-4'
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
@@ -29,6 +27,18 @@ 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,
|
||||||
@@ -46,133 +56,6 @@ 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
|
||||||
@@ -184,10 +67,20 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
||||||
<PlatformDropdown
|
<Dropdown
|
||||||
groups={subtitleLanguageOptionGroups}
|
data={[
|
||||||
trigger={
|
{
|
||||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
DisplayName: t("home.settings.subtitles.none"),
|
||||||
|
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")}
|
||||||
@@ -197,9 +90,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -207,10 +109,13 @@ 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}
|
||||||
>
|
>
|
||||||
<PlatformDropdown
|
<Dropdown
|
||||||
groups={subtitleModeOptionGroups}
|
data={subtitleModes}
|
||||||
trigger={
|
disabled={pluginSettings?.subtitleMode?.locked}
|
||||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
keyExtractor={String}
|
||||||
|
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")}
|
||||||
@@ -220,9 +125,10 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
size={18}
|
size={18}
|
||||||
color='#5A5960'
|
color='#5A5960'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
}
|
}
|
||||||
title={t("home.settings.subtitles.subtitle_mode")}
|
label={t("home.settings.subtitles.subtitle_mode")}
|
||||||
|
onSelected={(subtitleMode) => updateSettings({ subtitleMode })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@@ -253,120 +159,144 @@ 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")}>
|
||||||
<PlatformDropdown
|
<Dropdown
|
||||||
groups={textColorOptionGroups}
|
data={Object.keys(VLC_COLORS)}
|
||||||
trigger={
|
keyExtractor={(item) => item}
|
||||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
titleExtractor={(item) =>
|
||||||
|
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(
|
{t(`home.settings.subtitles.colors.${textColor}`)}
|
||||||
`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'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
}
|
}
|
||||||
title={t("home.settings.subtitles.text_color")}
|
label={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")}>
|
||||||
<PlatformDropdown
|
<Dropdown
|
||||||
groups={backgroundColorOptionGroups}
|
data={Object.keys(VLC_COLORS)}
|
||||||
trigger={
|
keyExtractor={(item) => item}
|
||||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
titleExtractor={(item) =>
|
||||||
|
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(
|
{t(`home.settings.subtitles.colors.${backgroundColor}`)}
|
||||||
`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'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
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")}>
|
||||||
<PlatformDropdown
|
<Dropdown
|
||||||
groups={outlineColorOptionGroups}
|
data={Object.keys(VLC_COLORS)}
|
||||||
trigger={
|
keyExtractor={(item) => item}
|
||||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
titleExtractor={(item) =>
|
||||||
|
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(
|
{t(`home.settings.subtitles.colors.${outlineColor}`)}
|
||||||
`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'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
}
|
}
|
||||||
title={t("home.settings.subtitles.outline_color")}
|
label={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")}>
|
||||||
<PlatformDropdown
|
<Dropdown
|
||||||
groups={outlineThicknessOptionGroups}
|
data={Object.keys(OUTLINE_THICKNESS)}
|
||||||
trigger={
|
keyExtractor={(item) => item}
|
||||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
titleExtractor={(item) =>
|
||||||
|
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(
|
{t(`home.settings.subtitles.thickness.${outlineThickness}`)}
|
||||||
`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'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
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")}>
|
||||||
<PlatformDropdown
|
<Dropdown
|
||||||
groups={backgroundOpacityOptionGroups}
|
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
|
||||||
trigger={
|
keyExtractor={String}
|
||||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
|
title={
|
||||||
|
<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'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
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")}>
|
||||||
<PlatformDropdown
|
<Dropdown
|
||||||
groups={outlineOpacityOptionGroups}
|
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
|
||||||
trigger={
|
keyExtractor={String}
|
||||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
|
title={
|
||||||
|
<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'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
}
|
}
|
||||||
title={t("home.settings.subtitles.outline_opacity")}
|
label={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={settings?.vlcIsBold ?? false}
|
value={isBold}
|
||||||
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|||||||
@@ -114,7 +114,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='flex flex-col items-start shrink'
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
}}
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
>
|
>
|
||||||
{item?.Type === "Episode" && (
|
{item?.Type === "Episode" && (
|
||||||
@@ -130,7 +133,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 shrink-0'>
|
<View className='flex flex-row space-x-2'>
|
||||||
<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,10 +56,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
const downloadedFiles = useMemo(
|
const downloadedFiles = getDownloadedItems();
|
||||||
() => getDownloadedItems(),
|
|
||||||
[getDownloadedItems],
|
|
||||||
);
|
|
||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||||
|
|
||||||
@@ -71,13 +68,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 = Array.from(
|
const seasonNumbers = [
|
||||||
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' pointerEvents='box-none'>
|
<View className='mr-auto'>
|
||||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||||
<VideoProvider
|
<VideoProvider
|
||||||
getAudioTracks={getAudioTracks}
|
getAudioTracks={getAudioTracks}
|
||||||
@@ -120,9 +120,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
setSubtitleTrack={setSubtitleTrack}
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
setSubtitleURL={setSubtitleURL}
|
setSubtitleURL={setSubtitleURL}
|
||||||
>
|
>
|
||||||
<View pointerEvents='auto'>
|
<DropdownView />
|
||||||
<DropdownView />
|
|
||||||
</View>
|
|
||||||
</VideoProvider>
|
</VideoProvider>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React, { useMemo } from "react";
|
import React, { useState } from "react";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, TouchableOpacity } from "react-native";
|
||||||
import {
|
import { Text } from "@/components/common/Text";
|
||||||
type OptionGroup,
|
import { FilterSheet } from "@/components/filters/FilterSheet";
|
||||||
PlatformDropdown,
|
|
||||||
} from "@/components/PlatformDropdown";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
export type ScaleFactor =
|
export type ScaleFactor =
|
||||||
@@ -96,51 +94,56 @@ 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 optionGroups = useMemo<OptionGroup[]>(() => {
|
const currentOption = SCALE_FACTOR_OPTIONS.find(
|
||||||
return [
|
(option) => option.id === currentScale,
|
||||||
{
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const trigger = useMemo(
|
|
||||||
() => (
|
|
||||||
<View
|
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
|
||||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='search-outline' size={24} color='white' />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
[disabled],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hide on TV platforms
|
|
||||||
if (Platform.isTV) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<>
|
||||||
title='Scale Factor'
|
<TouchableOpacity
|
||||||
groups={optionGroups}
|
disabled={disabled}
|
||||||
trigger={trigger}
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
bottomSheetConfig={{
|
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||||
enablePanDownToClose: true,
|
onPress={() => setOpen(true)}
|
||||||
}}
|
>
|
||||||
/>
|
<Ionicons name='search-outline' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
title='Scale Factor'
|
||||||
|
data={SCALE_FACTOR_OPTIONS}
|
||||||
|
values={currentOption ? [currentOption] : []}
|
||||||
|
multiple={false}
|
||||||
|
searchFilter={(item, query) => {
|
||||||
|
const option = item as ScaleFactorOption;
|
||||||
|
return (
|
||||||
|
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,12 +13,6 @@ 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,10 +1,8 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React, { useMemo } from "react";
|
import React, { useState } from "react";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, TouchableOpacity } from "react-native";
|
||||||
import {
|
import { Text } from "@/components/common/Text";
|
||||||
type OptionGroup,
|
import { FilterSheet } from "@/components/filters/FilterSheet";
|
||||||
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";
|
||||||
@@ -55,51 +53,56 @@ 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 optionGroups = useMemo<OptionGroup[]>(() => {
|
const currentOption = ASPECT_RATIO_OPTIONS.find(
|
||||||
return [
|
(option) => option.id === currentRatio,
|
||||||
{
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const trigger = useMemo(
|
|
||||||
() => (
|
|
||||||
<View
|
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
|
||||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='crop-outline' size={24} color='white' />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
[disabled],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hide on TV platforms
|
|
||||||
if (Platform.isTV) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<>
|
||||||
title='Aspect Ratio'
|
<TouchableOpacity
|
||||||
groups={optionGroups}
|
disabled={disabled}
|
||||||
trigger={trigger}
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
bottomSheetConfig={{
|
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||||
enablePanDownToClose: true,
|
onPress={() => setOpen(true)}
|
||||||
}}
|
>
|
||||||
/>
|
<Ionicons name='crop-outline' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
title='Aspect Ratio'
|
||||||
|
data={ASPECT_RATIO_OPTIONS}
|
||||||
|
values={currentOption ? [currentOption] : []}
|
||||||
|
multiple={false}
|
||||||
|
searchFilter={(item, query) => {
|
||||||
|
const option = item as AspectRatioOption;
|
||||||
|
return (
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTrack?.(serverIndex);
|
setTrack?.(index);
|
||||||
router.setParams({
|
router.setParams({
|
||||||
[paramKey]: serverIndex.toString(),
|
[paramKey]: serverIndex.toString(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
|
||||||
import { Platform, View } from "react-native";
|
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import {
|
import {
|
||||||
type OptionGroup,
|
BottomSheetBackdrop,
|
||||||
PlatformDropdown,
|
type BottomSheetBackdropProps,
|
||||||
} from "@/components/PlatformDropdown";
|
BottomSheetModal,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
import { useControlContext } from "../contexts/ControlContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
|
|
||||||
@@ -19,6 +23,10 @@ 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<{
|
||||||
@@ -31,127 +39,248 @@ 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: itemIdRef.current ?? "",
|
itemId: item.Id ?? "",
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
subtitleIndex: subtitleIndex.toString() ?? "",
|
||||||
mediaSourceId: mediaSourceIdRef.current ?? "",
|
mediaSourceId: mediaSource?.Id ?? "",
|
||||||
bitrateValue: bitrate.toString(),
|
bitrateValue: bitrate.toString(),
|
||||||
playbackPosition: playbackPositionRef.current,
|
playbackPosition: playbackPosition,
|
||||||
}).toString();
|
}).toString();
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
},
|
},
|
||||||
[audioIndex, subtitleIndex, router],
|
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create stable identifiers for tracks
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
const subtitleTracksKey = useMemo(
|
if (index === -1) {
|
||||||
() => subtitleTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
|
setOpen(false);
|
||||||
[subtitleTracks],
|
|
||||||
);
|
|
||||||
|
|
||||||
const audioTracksKey = useMemo(
|
|
||||||
() => audioTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
|
|
||||||
[audioTracks],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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
|
const renderBackdrop = useCallback(
|
||||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
(props: BottomSheetBackdropProps) => (
|
||||||
groups.push({
|
<BottomSheetBackdrop
|
||||||
title: "Subtitles",
|
{...props}
|
||||||
options: subtitleTracks.map((sub) => ({
|
disappearsOnIndex={-1}
|
||||||
type: "radio" as const,
|
appearsOnIndex={0}
|
||||||
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
|
<>
|
||||||
title='Playback Options'
|
<TouchableOpacity
|
||||||
groups={optionGroups}
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
trigger={trigger}
|
onPress={handleOpen}
|
||||||
bottomSheetConfig={{
|
>
|
||||||
enablePanDownToClose: true,
|
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
|
||||||
}}
|
</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,23 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { useTVEventHandler } 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>;
|
||||||
@@ -77,7 +63,6 @@ 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 = 10000,
|
timeout = 4000,
|
||||||
}: UseControlsTimeoutProps) => {
|
}: UseControlsTimeoutProps) => {
|
||||||
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
# Nested Modals with PlatformDropdown
|
|
||||||
|
|
||||||
## Issue
|
|
||||||
PlatformDropdowns inside BottomSheetModals don't open on Android, or dropdowns reopen unexpectedly after navigation.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
1. **Add controlled state** for each PlatformDropdown:
|
|
||||||
```tsx
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
<PlatformDropdown
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
// ...
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Memoize groups and callbacks** to prevent unnecessary re-renders:
|
|
||||||
```tsx
|
|
||||||
const handleOption1 = useCallback(() => {
|
|
||||||
// handler logic
|
|
||||||
}, [dependencies]);
|
|
||||||
|
|
||||||
const dropdownGroups = useMemo(() => [
|
|
||||||
{
|
|
||||||
title: "Group 1",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
type: "radio",
|
|
||||||
label: "Option 1",
|
|
||||||
value: "option1",
|
|
||||||
selected: state === "option1",
|
|
||||||
onPress: handleOption1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
], [state, handleOption1]);
|
|
||||||
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={dropdownGroups}
|
|
||||||
// ...
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Use `View` for triggers, not `TouchableOpacity`**:
|
|
||||||
```tsx
|
|
||||||
// ✅ Correct
|
|
||||||
<PlatformDropdown
|
|
||||||
trigger={<View>...</View>}
|
|
||||||
/>
|
|
||||||
|
|
||||||
// ❌ Wrong - causes nested TouchableOpacity conflicts
|
|
||||||
<PlatformDropdown
|
|
||||||
trigger={<TouchableOpacity>...</TouchableOpacity>}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Add `stackBehavior='push'` to parent BottomSheetModal**:
|
|
||||||
```tsx
|
|
||||||
<BottomSheetModal
|
|
||||||
stackBehavior='push'
|
|
||||||
// ...
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **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 tracks internal `isModalShowing` state to prevent duplicate `showModal()` calls when dependencies change.
|
|
||||||
- Memoizing groups prevents the useEffect from re-triggering unnecessarily, which can cause modals to reopen after navigation.
|
|
||||||
- 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.47.1",
|
"channel": "0.39.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.1",
|
"channel": "0.39.0",
|
||||||
"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.47.1",
|
"channel": "0.39.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { type LayoutRectangle, useWindowDimensions } from "react-native";
|
|
||||||
|
|
||||||
interface UseInViewOptions {
|
|
||||||
threshold?: number; // Distance in pixels before component is considered "in view"
|
|
||||||
enabled?: boolean; // Allow disabling the hook
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseInViewReturn {
|
|
||||||
ref: (node: any) => void;
|
|
||||||
isInView: boolean;
|
|
||||||
onLayout: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useInView = (
|
|
||||||
scrollY: number = 0,
|
|
||||||
options: UseInViewOptions = {},
|
|
||||||
): UseInViewReturn => {
|
|
||||||
const { threshold = 400, enabled = true } = options;
|
|
||||||
const { height: windowHeight } = useWindowDimensions();
|
|
||||||
const [layout, setLayout] = useState<LayoutRectangle | null>(null);
|
|
||||||
const [hasBeenInView, setHasBeenInView] = useState(false);
|
|
||||||
const nodeRef = useRef<any>(null);
|
|
||||||
|
|
||||||
const ref = useCallback((node: any) => {
|
|
||||||
nodeRef.current = node;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onLayout = useCallback(() => {
|
|
||||||
if (!nodeRef.current) return;
|
|
||||||
|
|
||||||
// Use measure to get absolute position
|
|
||||||
nodeRef.current.measure(
|
|
||||||
(
|
|
||||||
_x: number,
|
|
||||||
_y: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
pageX: number,
|
|
||||||
pageY: number,
|
|
||||||
) => {
|
|
||||||
setLayout({ x: pageX, y: pageY, width, height });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled || hasBeenInView || !layout) return;
|
|
||||||
|
|
||||||
// Calculate if the section is in view or about to be
|
|
||||||
const sectionTop = layout.y;
|
|
||||||
const viewportBottom = scrollY + windowHeight;
|
|
||||||
|
|
||||||
// Check if section is within threshold distance of viewport
|
|
||||||
const isNearView = viewportBottom + threshold >= sectionTop;
|
|
||||||
|
|
||||||
if (isNearView) {
|
|
||||||
setHasBeenInView(true);
|
|
||||||
}
|
|
||||||
}, [scrollY, windowHeight, threshold, layout, hasBeenInView, enabled]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ref,
|
|
||||||
isInView: hasBeenInView,
|
|
||||||
onLayout,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -43,60 +43,26 @@ 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) {
|
||||||
const shouldShow =
|
setShowSkipButton(
|
||||||
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, showSkipButton]);
|
}, [introTimestamps, currentTime]);
|
||||||
|
|
||||||
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("[INTRO_SKIPPER] Error skipping intro", error);
|
console.error("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.remove(JELLYSEERR_USER);
|
storage.delete(JELLYSEERR_USER);
|
||||||
storage.remove(JELLYSEERR_COOKIES);
|
storage.delete(JELLYSEERR_COOKIES);
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum Endpoints {
|
export enum Endpoints {
|
||||||
|
|||||||
@@ -1,2 +1,58 @@
|
|||||||
// Re-export from provider to maintain backward compatibility
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
export { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
async function checkApiReachable(basePath?: string): Promise<boolean> {
|
||||||
|
if (!basePath) return false;
|
||||||
|
try {
|
||||||
|
const response = await fetch(basePath, { method: "HEAD" });
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNetworkStatus() {
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [serverConnected, setServerConnected] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const validateConnection = useCallback(async () => {
|
||||||
|
if (!api?.basePath) return false;
|
||||||
|
const reachable = await checkApiReachable(api.basePath);
|
||||||
|
setServerConnected(reachable);
|
||||||
|
return reachable;
|
||||||
|
}, [api?.basePath]);
|
||||||
|
|
||||||
|
const retryCheck = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await validateConnection();
|
||||||
|
setLoading(false);
|
||||||
|
}, [validateConnection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = NetInfo.addEventListener(async (state) => {
|
||||||
|
setIsConnected(!!state.isConnected);
|
||||||
|
if (state.isConnected) {
|
||||||
|
await validateConnection();
|
||||||
|
} else {
|
||||||
|
setServerConnected(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial check: wait for NetInfo first
|
||||||
|
NetInfo.fetch().then((state) => {
|
||||||
|
if (state.isConnected) {
|
||||||
|
validateConnection();
|
||||||
|
} else {
|
||||||
|
setServerConnected(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [validateConnection]);
|
||||||
|
|
||||||
|
return { isConnected, serverConnected, loading, retryCheck };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
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 { OrientationLock } from "@/packages/expo-screen-orientation";
|
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||||
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(
|
||||||
@@ -45,20 +29,5 @@ export const useOrientation = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const lockOrientation = async (lock: OrientationLock) => {
|
return { orientation, setOrientation };
|
||||||
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 };
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,7 +96,9 @@ export const useWebSocket = ({
|
|||||||
| Record<string, string>
|
| Record<string, string>
|
||||||
| undefined; // Arguments are Dictionary<string, string>
|
| undefined; // Arguments are Dictionary<string, string>
|
||||||
|
|
||||||
console.log("[WS] ~ ", lastMessage);
|
// Sanitize output to avoid log injection
|
||||||
|
const msgStr = JSON.stringify(lastMessage).replaceAll(/[\n\r]/g, " ");
|
||||||
|
console.log("[WS] ~ %s", msgStr);
|
||||||
|
|
||||||
if (command === "PlayPause") {
|
if (command === "PlayPause") {
|
||||||
console.log("Command ~ PlayPause");
|
console.log("Command ~ PlayPause");
|
||||||
|
|||||||
@@ -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);
|
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
|
||||||
|
|
||||||
// Add Hermes parser
|
// Add Hermes parser
|
||||||
config.transformer.hermesParser = true;
|
config.transformer.hermesParser = true;
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user