mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 16:18:09 +00:00
Compare commits
2 Commits
refactor/n
...
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:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -64,5 +64,4 @@ credentials.json
|
|||||||
streamyfin-4fec1-firebase-adminsdk.json
|
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,237 +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/playback-controls/page'
|
name='settings/marlin-search/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.playback_controls.title"),
|
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/audio-subtitles/page'
|
name='settings/jellyseerr/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.audio_subtitles.title"),
|
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/appearance/page'
|
name='settings/hide-libraries/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.appearance.title"),
|
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
|
|
||||||
name='settings/appearance/hide-libraries/page'
|
|
||||||
options={{
|
|
||||||
title: t("home.settings.other.hide_libraries"),
|
|
||||||
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
|
|
||||||
name='settings/plugins/page'
|
|
||||||
options={{
|
|
||||||
title: t("home.settings.plugins.plugins_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
|
|
||||||
name='settings/plugins/marlin-search/page'
|
|
||||||
options={{
|
|
||||||
title: "Marlin Search",
|
|
||||||
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
|
|
||||||
name='settings/plugins/jellyseerr/page'
|
|
||||||
options={{
|
|
||||||
title: "Jellyseerr",
|
|
||||||
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
|
|
||||||
name='settings/intro/page'
|
|
||||||
options={{
|
|
||||||
title: t("home.settings.intro.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/logs/page'
|
name='settings/logs/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.logs.logs_title"),
|
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
|
||||||
@@ -279,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",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -294,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,46 +141,45 @@ 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 gap-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>
|
||||||
<Text className='text-xs opacity-70 text-red-600'>
|
<Text className='text-xs opacity-70 text-red-600'>
|
||||||
{t("home.downloads.queue_hint")}
|
{t("home.downloads.queue_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-col gap-y-2 mt-2'>
|
<View className='flex flex-col space-y-2 mt-2'>
|
||||||
{queue.map((q, index) => (
|
{queue.map((q, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function page() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 gap-y-4" : "py-16 gap-y-8"} px-4`}
|
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text className='text-3xl font-bold text-center mb-2'>
|
<Text className='text-3xl font-bold text-center mb-2'>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -255,7 +258,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Session controls */}
|
{/* Session controls */}
|
||||||
<View className='flex flex-row mt-2 gap-x-4 justify-center'>
|
<View className='flex flex-row mt-2 space-x-4 justify-center'>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handlePrevious}
|
onPress={handlePrevious}
|
||||||
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
|
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
|
||||||
|
|||||||
@@ -8,16 +8,34 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
|
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||||
|
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||||
|
import { GestureControls } from "@/components/settings/GestureControls";
|
||||||
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
|
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||||
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { clearLogs } from "@/utils/log";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [_user] = useAtom(userAtom);
|
const [_user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
|
const onClearLogsClicked = async () => {
|
||||||
|
clearLogs();
|
||||||
|
successHapticFeedback();
|
||||||
|
};
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,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>
|
||||||
@@ -38,58 +56,61 @@ 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'
|
<UserInfo />
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
|
||||||
>
|
|
||||||
<View className='mb-4'>
|
|
||||||
<UserInfo />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
<View className='mb-4'>
|
<MediaProvider>
|
||||||
<AppLanguageSelector />
|
<MediaToggles className='mb-4' />
|
||||||
</View>
|
<GestureControls className='mb-4' />
|
||||||
|
<AudioToggles className='mb-4' />
|
||||||
|
<SubtitleToggles className='mb-4' />
|
||||||
|
</MediaProvider>
|
||||||
|
|
||||||
|
<OtherSettings />
|
||||||
|
|
||||||
|
{!Platform.isTV && <DownloadSettings />}
|
||||||
|
|
||||||
|
<PluginSettings />
|
||||||
|
|
||||||
|
<AppLanguageSelector />
|
||||||
|
|
||||||
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
|
|
||||||
|
<ListGroup title={"Intro"}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/intro/page");
|
||||||
|
}}
|
||||||
|
title={t("home.settings.intro.show_intro")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
textColor='red'
|
||||||
|
onPress={() => {
|
||||||
|
storage.set("hasShownIntro", false);
|
||||||
|
}}
|
||||||
|
title={t("home.settings.intro.reset_intro")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<ListGroup title={t("home.settings.categories.title")}>
|
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/playback-controls/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.playback_controls.title")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/audio-subtitles/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.audio_subtitles.title")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/appearance/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.appearance.title")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/plugins/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.plugins.plugins_title")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/intro/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.intro.title")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/logs/page")}
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
showArrow
|
showArrow
|
||||||
title={t("home.settings.logs.logs_title")}
|
title={t("home.settings.logs.logs_title")}
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
textColor='red'
|
||||||
|
onPress={onClearLogsClicked}
|
||||||
|
title={t("home.settings.logs.delete_all_logs")}
|
||||||
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ScrollView, Switch, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ["user-views", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getUserViewsApi(api!).getUserViews({
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<View className='mt-4'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DisabledSetting
|
|
||||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
|
||||||
className='px-4'
|
|
||||||
>
|
|
||||||
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
|
||||||
{data?.map((view) => (
|
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
|
||||||
<Switch
|
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
updateSettings({
|
|
||||||
hiddenLibraries: value
|
|
||||||
? [...(settings.hiddenLibraries || []), view.Id!]
|
|
||||||
: settings.hiddenLibraries?.filter(
|
|
||||||
(id) => id !== view.Id,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</ListGroup>
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
|
||||||
</Text>
|
|
||||||
</DisabledSetting>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Platform, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { AppearanceSettings } from "@/components/settings/AppearanceSettings";
|
|
||||||
|
|
||||||
export default function AppearancePage() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='p-4 flex flex-col'
|
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
|
||||||
>
|
|
||||||
<AppearanceSettings />
|
|
||||||
<View className='h-24' />
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Platform, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
|
||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
|
||||||
|
|
||||||
export default function AudioSubtitlesPage() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='p-4 flex flex-col'
|
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
|
||||||
>
|
|
||||||
<MediaProvider>
|
|
||||||
<AudioToggles className='mb-4' />
|
|
||||||
<SubtitleToggles className='mb-4' />
|
|
||||||
</MediaProvider>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
export default function IntroPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='p-4 flex flex-col'
|
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
|
||||||
>
|
|
||||||
<ListGroup title={t("home.settings.intro.title")}>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/intro/page");
|
|
||||||
}}
|
|
||||||
title={t("home.settings.intro.show_intro")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
textColor='red'
|
|
||||||
onPress={() => {
|
|
||||||
storage.set("hasShownIntro", false);
|
|
||||||
}}
|
|
||||||
title={t("home.settings.intro.reset_intro")}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
<View className='h-24' />
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
16
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
16
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
|
className='p-4'
|
||||||
|
>
|
||||||
|
<JellyseerrSettings />
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { File, Paths } from "expo-file-system";
|
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";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -34,7 +33,6 @@ export default function Page() {
|
|||||||
|
|
||||||
const _orderId = useId();
|
const _orderId = useId();
|
||||||
const _levelsId = useId();
|
const _levelsId = useId();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const filteredLogs = useMemo(
|
const filteredLogs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -49,17 +47,18 @@ export default function Page() {
|
|||||||
|
|
||||||
// Sharing it as txt while its formatted allows us to share it with many more applications
|
// Sharing it as txt while its formatted allows us to share it with many more applications
|
||||||
const share = useCallback(async () => {
|
const share = useCallback(async () => {
|
||||||
const logsFile = new File(Paths.document, "logs.txt");
|
const uri = `${FileSystem.documentDirectory}logs.txt`;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
|
||||||
logsFile.write(JSON.stringify(filteredLogs));
|
.then(() => {
|
||||||
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
setLoading(false);
|
||||||
} catch (e: any) {
|
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
|
||||||
writeErrorLog("Something went wrong attempting to export", e);
|
})
|
||||||
} finally {
|
.catch((e) =>
|
||||||
setLoading(false);
|
writeErrorLog("Something went wrong attempting to export", e),
|
||||||
}
|
)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, [filteredLogs]);
|
}, [filteredLogs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,7 +67,7 @@ export default function Page() {
|
|||||||
loading ? (
|
loading ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity onPress={share} className='px-2'>
|
<TouchableOpacity onPress={share}>
|
||||||
<Text>{t("home.settings.logs.export_logs")}</Text>
|
<Text>{t("home.settings.logs.export_logs")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -76,13 +75,8 @@ export default function Page() {
|
|||||||
}, [share, loading]);
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<>
|
||||||
className='flex-1'
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||||
style={{
|
|
||||||
paddingTop: insets.top + 48,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='flex flex-row justify-end py-2 px-4 gap-x-2'>
|
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
queryKey='log'
|
queryKey='log'
|
||||||
@@ -106,7 +100,7 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView className='pb-4 px-4'>
|
<ScrollView className='pb-4 px-4'>
|
||||||
<View className='flex flex-col gap-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
{filteredLogs?.map((log, index) => (
|
{filteredLogs?.map((log, index) => (
|
||||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -146,7 +140,7 @@ export default function Page() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Collapsible collapsed={!state[log.timestamp]}>
|
<Collapsible collapsed={!state[log.timestamp]}>
|
||||||
<View className='mt-2 flex flex-col gap-y-2'>
|
<View className='mt-2 flex flex-col space-y-2'>
|
||||||
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
||||||
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -163,6 +157,6 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
122
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
122
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
const onSave = (val: string) => {
|
||||||
|
updateSettings({
|
||||||
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
|
});
|
||||||
|
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabled = useMemo(() => {
|
||||||
|
return (
|
||||||
|
pluginSettings?.searchEngine?.locked === true &&
|
||||||
|
pluginSettings?.marlinServerUrl?.locked === true
|
||||||
|
);
|
||||||
|
}, [pluginSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={() => onSave(value)}>
|
||||||
|
<Text className='text-blue-500'>
|
||||||
|
{t("home.settings.plugins.marlin_search.save_button")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [navigation, value]);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting disabled={disabled} className='px-4'>
|
||||||
|
<ListGroup>
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||||
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||||
|
)}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.searchEngine === "Marlin"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</DisabledSetting>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||||
|
showText={!pluginSettings?.searchEngine?.locked}
|
||||||
|
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||||
|
>
|
||||||
|
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
|
||||||
|
<Text className='mr-4'>
|
||||||
|
{t("home.settings.plugins.marlin_search.url")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
editable={settings.searchEngine === "Marlin"}
|
||||||
|
className='text-white'
|
||||||
|
placeholder={t(
|
||||||
|
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||||
|
)}
|
||||||
|
value={value}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
onChangeText={(text) => setValue(text)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</DisabledSetting>
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Platform, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { GestureControls } from "@/components/settings/GestureControls";
|
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
|
||||||
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
|
|
||||||
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
|
|
||||||
|
|
||||||
export default function PlaybackControlsPage() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='p-4 flex flex-col'
|
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
|
||||||
>
|
|
||||||
<View className='mb-4'>
|
|
||||||
<MediaProvider>
|
|
||||||
<MediaToggles className='mb-4' />
|
|
||||||
<GestureControls className='mb-4' />
|
|
||||||
<PlaybackControlsSettings />
|
|
||||||
</MediaProvider>
|
|
||||||
</View>
|
|
||||||
{!Platform.isTV && <ChromecastSettings />}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { ScrollView } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const { pluginSettings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DisabledSetting
|
|
||||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
|
||||||
className='px-4'
|
|
||||||
>
|
|
||||||
<JellyseerrSettings />
|
|
||||||
</DisabledSetting>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Linking,
|
|
||||||
ScrollView,
|
|
||||||
Switch,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
|
||||||
|
|
||||||
const onSave = (val: string) => {
|
|
||||||
updateSettings({
|
|
||||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
|
||||||
});
|
|
||||||
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenLink = () => {
|
|
||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
|
||||||
};
|
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
|
||||||
return (
|
|
||||||
pluginSettings?.searchEngine?.locked === true &&
|
|
||||||
pluginSettings?.marlinServerUrl?.locked === true
|
|
||||||
);
|
|
||||||
}, [pluginSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
|
||||||
<Text className='text-blue-500'>
|
|
||||||
{t("home.settings.plugins.marlin_search.save_button")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [navigation, value]);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DisabledSetting disabled={disabled} className='px-4'>
|
|
||||||
<ListGroup>
|
|
||||||
<DisabledSetting
|
|
||||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
|
||||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
|
||||||
>
|
|
||||||
<ListItem
|
|
||||||
title={t(
|
|
||||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
|
||||||
)}
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.searchEngine === "Marlin"}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
updateSettings({
|
|
||||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</DisabledSetting>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<DisabledSetting
|
|
||||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
|
||||||
showText={!pluginSettings?.searchEngine?.locked}
|
|
||||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
|
||||||
>
|
|
||||||
<Text className='mr-4'>
|
|
||||||
{t("home.settings.plugins.marlin_search.url")}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
editable={settings.searchEngine === "Marlin"}
|
|
||||||
className='text-white'
|
|
||||||
placeholder={t(
|
|
||||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
|
||||||
)}
|
|
||||||
value={value}
|
|
||||||
keyboardType='url'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='URL'
|
|
||||||
onChangeText={(text) => setValue(text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</DisabledSetting>
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</DisabledSetting>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Platform, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
|
||||||
|
|
||||||
export default function PluginsPage() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='px-4 flex flex-col'
|
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
|
||||||
>
|
|
||||||
<PluginSettings />
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const Page: React.FC = () => {
|
|||||||
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
||||||
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||||
<View className='flex flex-row gap-x-1 mb-8'>
|
<View className='flex flex-row space-x-1 mb-8'>
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
@@ -239,7 +216,7 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col'>
|
||||||
<View className='gap-y-4'>
|
<View className='space-y-4'>
|
||||||
<View className='px-4'>
|
<View className='px-4'>
|
||||||
<View className='flex flex-row justify-between w-full'>
|
<View className='flex flex-row justify-between w-full'>
|
||||||
<View className='flex flex-col w-56'>
|
<View className='flex flex-col w-56'>
|
||||||
@@ -282,7 +259,7 @@ const Page: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
details?.mediaInfo?.jellyfinMediaId && (
|
details?.mediaInfo?.jellyfinMediaId && (
|
||||||
<View className='flex flex-row gap-x-2 mt-4'>
|
<View className='flex flex-row space-x-2 mt-4'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<Button
|
<Button
|
||||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||||
@@ -378,36 +355,59 @@ const Page: React.FC = () => {
|
|||||||
backgroundColor: "#171717",
|
backgroundColor: "#171717",
|
||||||
}}
|
}}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
stackBehavior='push'
|
|
||||||
onDismiss={handleIssueModalDismiss}
|
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
{t("jellyseerr.whats_wrong")}
|
{t("jellyseerr.whats_wrong")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col gap-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'>
|
||||||
|
|||||||
@@ -87,15 +87,14 @@ export default function page() {
|
|||||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||||
<Text className='opacity-50'>
|
<Text className='opacity-50'>
|
||||||
{t("jellyseerr.born")}{" "}
|
{t("jellyseerr.born")}{" "}
|
||||||
{data?.details?.birthday &&
|
{new Date(data?.details?.birthday!).toLocaleDateString(
|
||||||
new Date(data.details.birthday).toLocaleDateString(
|
`${locale}-${region}`,
|
||||||
`${locale}-${region}`,
|
{
|
||||||
{
|
year: "numeric",
|
||||||
year: "numeric",
|
month: "long",
|
||||||
month: "long",
|
day: "numeric",
|
||||||
day: "numeric",
|
},
|
||||||
},
|
)}{" "}
|
||||||
)}{" "}
|
|
||||||
| {data?.details?.placeOfBirth}
|
| {data?.details?.placeOfBirth}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default function page() {
|
|||||||
<View className='flex flex-1'>
|
<View className='flex flex-1'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={channels?.Items}
|
data={channels?.Items}
|
||||||
|
estimatedItemSize={76}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View className='flex flex-row items-center px-4 mb-2'>
|
<View className='flex flex-row items-center px-4 mb-2'>
|
||||||
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function page() {
|
|||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col gap-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "recommended"]}
|
queryKey={["livetv", "recommended"]}
|
||||||
title={t("live_tv.on_now")}
|
title={t("live_tv.on_now")}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col gap-y-4 my-4'>
|
<View className='flex flex-col space-y-4 my-4'>
|
||||||
<View className='px-4 mb-4'>
|
<View className='px-4 mb-4'>
|
||||||
<MoviesTitleHeader item={item} className='mb-4' />
|
<MoviesTitleHeader item={item} className='mb-4' />
|
||||||
<OverviewText text={item.Overview} />
|
<OverviewText text={item.Overview} />
|
||||||
|
|||||||
@@ -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"],
|
||||||
@@ -94,7 +92,7 @@ const page: React.FC = () => {
|
|||||||
item &&
|
item &&
|
||||||
allEpisodes &&
|
allEpisodes &&
|
||||||
allEpisodes.length > 0 && (
|
allEpisodes.length > 0 && (
|
||||||
<View className='flex flex-row items-center gap-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadItems
|
<DownloadItems
|
||||||
|
|||||||
@@ -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'>
|
||||||
@@ -418,7 +455,7 @@ export default function search() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : debouncedSearch.length === 0 ? (
|
) : debouncedSearch.length === 0 ? (
|
||||||
<View className='mt-4 flex flex-col items-center gap-y-2'>
|
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||||
{exampleSearches.map((e) => (
|
{exampleSearches.map((e) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { Link, Stack } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
import { Text } from "../components/common/Text";
|
|
||||||
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Oops!" }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
<View style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<Text>This screen doesn't exist.</Text>
|
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
||||||
<Link href={"/home"} style={styles.link}>
|
<Link href={"/home"} style={styles.link}>
|
||||||
<Text>Go to home screen!</Text>
|
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</View>
|
</ThemedView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
267
app/_layout.tsx
267
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,30 +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 "@/global.css";
|
|
||||||
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({
|
||||||
@@ -123,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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -188,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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -197,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(
|
||||||
@@ -226,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", {
|
||||||
@@ -257,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) {
|
||||||
@@ -335,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,13 +371,12 @@ 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 gap-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'>
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
<>
|
<>
|
||||||
@@ -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: ["react-native-worklets/plugin"],
|
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.2.7/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -353,7 +347,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
keyboardBlurBehavior='restore'
|
keyboardBlurBehavior='restore'
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
{title}
|
{title}
|
||||||
@@ -365,18 +359,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col gap-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 gap-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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -124,10 +124,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-row items-center gap-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<Chromecast.Chromecast width={22} height={22} />
|
<Chromecast.Chromecast width={22} height={22} />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center gap-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={item} size='large' />
|
<DownloadSingleItem item={item} size='large' />
|
||||||
)}
|
)}
|
||||||
@@ -201,10 +201,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
<View className='flex flex-col px-4 w-full gap-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) =>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
if (!item)
|
if (!item)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex flex-col gap-y-1.5 w-full items-start h-32'
|
className='flex flex-col space-y-1.5 w-full items-start h-32'
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<View className='w-1/3 h-6 bg-neutral-900 rounded' />
|
<View className='w-1/3 h-6 bg-neutral-900 rounded' />
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
|||||||
<View className='px-4 mt-2 mb-4'>
|
<View className='px-4 mt-2 mb-4'>
|
||||||
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
|
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
|
||||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||||
<View className='flex flex-row gap-x-2'>
|
<View className='flex flex-row space-x-2'>
|
||||||
<VideoStreamInfo source={source} />
|
<VideoStreamInfo source={source} />
|
||||||
</View>
|
</View>
|
||||||
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
|
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
|
||||||
@@ -52,12 +52,12 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<BottomSheetScrollView>
|
<BottomSheetScrollView>
|
||||||
<View className='flex flex-col gap-y-2 p-4 mb-4'>
|
<View className='flex flex-col space-y-2 p-4 mb-4'>
|
||||||
<View>
|
<View>
|
||||||
<Text className='text-lg font-bold mb-4'>
|
<Text className='text-lg font-bold mb-4'>
|
||||||
{t("item_card.video")}
|
{t("item_card.video")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-row gap-x-2'>
|
<View className='flex flex-row space-x-2'>
|
||||||
<VideoStreamInfo source={source} />
|
<VideoStreamInfo source={source} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -50,6 +51,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
selectedOptions,
|
selectedOptions,
|
||||||
isOffline,
|
isOffline,
|
||||||
colors,
|
colors,
|
||||||
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
@@ -163,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,
|
||||||
@@ -362,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 gap-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}
|
||||||
@@ -415,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
|
||||||
@@ -440,13 +397,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
|
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
|
||||||
>
|
>
|
||||||
<View className='flex flex-row items-center gap-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} />
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
||||||
>
|
>
|
||||||
<View className='flex flex-row items-center gap-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(item?.RunTimeTicks)}
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ interface Props extends ViewProps {
|
|||||||
export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-row items-center mt-2 gap-x-2' {...props}>
|
<View className='flex flex-row items-center mt-2 space-x-2' {...props}>
|
||||||
{item.OfficialRating && (
|
{item.OfficialRating && (
|
||||||
<Badge text={item.OfficialRating} variant='gray' />
|
<Badge text={item.OfficialRating} variant='gray' />
|
||||||
)}
|
)}
|
||||||
@@ -79,7 +79,7 @@ export const JellyserrRatings: React.FC<{
|
|||||||
!!result.voteCount ||
|
!!result.voteCount ||
|
||||||
(data?.criticsRating && !!data?.criticsScore) ||
|
(data?.criticsRating && !!data?.criticsScore) ||
|
||||||
(data?.audienceRating && !!data?.audienceScore)) && (
|
(data?.audienceRating && !!data?.audienceScore)) && (
|
||||||
<View className='flex flex-row flex-wrap gap-x-1'>
|
<View className='flex flex-row flex-wrap space-x-1'>
|
||||||
{data?.criticsRating && !!data?.criticsScore && (
|
{data?.criticsRating && !!data?.criticsScore && (
|
||||||
<Badge
|
<Badge
|
||||||
text={`${data.criticsScore}%`}
|
text={`${data.criticsScore}%`}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
54
components/ThemedText.tsx
Normal file
54
components/ThemedText.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { StyleSheet, Text, type TextProps } from "react-native";
|
||||||
|
|
||||||
|
export type ThemedTextProps = TextProps & {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemedText({
|
||||||
|
style,
|
||||||
|
type = "default",
|
||||||
|
...rest
|
||||||
|
}: ThemedTextProps) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
{ color: "white" },
|
||||||
|
type === "default" ? styles.default : undefined,
|
||||||
|
type === "title" ? styles.title : undefined,
|
||||||
|
type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
|
||||||
|
type === "subtitle" ? styles.subtitle : undefined,
|
||||||
|
type === "link" ? styles.link : undefined,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
default: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
defaultSemiBold: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
lineHeight: 32,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
lineHeight: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#0a7ea4",
|
||||||
|
},
|
||||||
|
});
|
||||||
15
components/ThemedView.tsx
Normal file
15
components/ThemedView.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
export type ThemedViewProps = ViewProps & {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemedView({
|
||||||
|
style,
|
||||||
|
lightColor,
|
||||||
|
darkColor,
|
||||||
|
...otherProps
|
||||||
|
}: ThemedViewProps) {
|
||||||
|
return <View style={[{ backgroundColor: "black" }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
@@ -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 ||
|
||||||
@@ -120,6 +119,7 @@ export function InfiniteHorizontalScroll({
|
|||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
<View className='mr-2'>{renderItem(item, index)}</View>
|
<View className='mr-2'>{renderItem(item, index)}</View>
|
||||||
)}
|
)}
|
||||||
|
estimatedItemSize={height}
|
||||||
horizontal
|
horizontal
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { Platform, Text as RNText, type TextProps } from "react-native";
|
import { Platform, Text as RNText, type TextProps } from "react-native";
|
||||||
|
export function Text(props: TextProps) {
|
||||||
export function Text({ className, ...props }: TextProps) {
|
const { style, ...otherProps } = props;
|
||||||
if (Platform.isTV)
|
if (Platform.isTV)
|
||||||
return (
|
return (
|
||||||
<RNText allowFontScaling={false} style={{ color: "white" }} {...props} />
|
<RNText
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[{ color: "white" }, style]}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RNText
|
<RNText
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
className={`text-white ${className}`}
|
style={[{ color: "white" }, style]}
|
||||||
{...props}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -29,15 +29,15 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return item?.Id ? storage.getString(item.Id) : undefined;
|
return storage.getString(item?.Id!);
|
||||||
}, [item?.Id]);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles deleting the file with haptic feedback.
|
* Handles deleting the file with haptic feedback.
|
||||||
*/
|
*/
|
||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id);
|
deleteFile(item.Id, item.Type);
|
||||||
}
|
}
|
||||||
}, [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"];
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const FilterButton = <T,>({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
px-3 py-1.5 rounded-full flex flex-row items-center gap-x-1
|
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||||
${
|
${
|
||||||
values.length > 0
|
values.length > 0
|
||||||
? "bg-purple-600 border border-purple-700"
|
? "bg-purple-600 border border-purple-700"
|
||||||
|
|||||||
@@ -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,506 +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(() => {
|
|
||||||
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: () => (
|
|
||||||
<View className='flex flex-row items-center ml-1.5'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name='download'
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [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 gap-y-4 ${Platform.OS === "android" ? "pt-4" : ""}`}
|
|
||||||
>
|
|
||||||
{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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,9 +83,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<View className='px-4 mb-2'>
|
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||||
<Text className='text-lg font-bold text-neutral-100'>{title}</Text>
|
{title}
|
||||||
</View>
|
</Text>
|
||||||
{isLoading === false && allItems.length === 0 && (
|
{isLoading === false && allItems.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>
|
||||||
@@ -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 }) => (
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ const DetailFacts: React.FC<
|
|||||||
<Facts
|
<Facts
|
||||||
title={t("jellyseerr.release_dates")}
|
title={t("jellyseerr.release_dates")}
|
||||||
facts={filteredReleases?.map?.((r: Release, idx) => (
|
facts={filteredReleases?.map?.((r: Release, idx) => (
|
||||||
<View key={idx} className='flex flex-row gap-x-2 items-center'>
|
<View key={idx} className='flex flex-row space-x-2 items-center'>
|
||||||
{r.type === 3 ? (
|
{r.type === 3 ? (
|
||||||
// Theatrical
|
// Theatrical
|
||||||
<Ionicons name='ticket' size={16} color='white' />
|
<Ionicons name='ticket' size={16} color='white' />
|
||||||
@@ -189,7 +189,7 @@ const DetailFacts: React.FC<
|
|||||||
<Facts
|
<Facts
|
||||||
title={t("jellyseerr.production_country")}
|
title={t("jellyseerr.production_country")}
|
||||||
facts={details?.productionCountries?.map((n, idx) => (
|
facts={details?.productionCountries?.map((n, idx) => (
|
||||||
<View key={idx} className='flex flex-row items-center gap-x-2'>
|
<View key={idx} className='flex flex-row items-center space-x-2'>
|
||||||
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
||||||
<Text>{n.name}</Text>
|
<Text>{n.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const ParallaxSlideShow = <T,>({
|
|||||||
}
|
}
|
||||||
logo={logo}
|
logo={logo}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col gap-y-4 px-4'>
|
<View className='flex flex-col space-y-4 px-4'>
|
||||||
<View className='flex flex-row justify-between w-full'>
|
<View className='flex flex-row justify-between w-full'>
|
||||||
<View className='flex flex-col w-full'>{HeaderContent?.()}</View>
|
<View className='flex flex-col w-full'>{HeaderContent?.()}</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -143,6 +143,7 @@ const ParallaxSlideShow = <T,>({
|
|||||||
renderItem={({ item, index }) => renderItem(item, index)}
|
renderItem={({ item, index }) => renderItem(item, index)}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
numColumns={3}
|
numColumns={3}
|
||||||
|
estimatedItemSize={214}
|
||||||
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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,10 +185,9 @@ const RequestModal = forwardRef<
|
|||||||
appearsOnIndex={0}
|
appearsOnIndex={0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
stackBehavior='push'
|
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
{t("jellyseerr.advanced")}
|
{t("jellyseerr.advanced")}
|
||||||
@@ -311,115 +196,73 @@ const RequestModal = forwardRef<
|
|||||||
<Text className='text-neutral-300'>{seasonTitle}</Text>
|
<Text className='text-neutral-300'>{seasonTitle}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col gap-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>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const Discover: React.FC<Props> = ({ sliders }) => {
|
|||||||
if (!hasSliders) return null;
|
if (!hasSliders) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col gap-y-4 mb-8'>
|
<View className='flex flex-col space-y-4 mb-8'>
|
||||||
{sortedSliders.map((slide) => {
|
{sortedSliders.map((slide) => {
|
||||||
switch (slide.type) {
|
switch (slide.type) {
|
||||||
case DiscoverSliderType.RECENT_REQUESTS:
|
case DiscoverSliderType.RECENT_REQUESTS:
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import type { ContentStyle } from "@shopify/flash-list/src/FlashListProps";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { View, type ViewProps, type ViewStyle } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
|
|
||||||
export interface SlideProps {
|
export interface SlideProps {
|
||||||
slide: DiscoverSlider;
|
slide: DiscoverSlider;
|
||||||
contentContainerStyle?: ViewStyle;
|
contentContainerStyle?: ContentStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props<T> extends SlideProps {
|
interface Props<T> extends SlideProps {
|
||||||
@@ -44,6 +45,7 @@ const Slide = <T,>({
|
|||||||
}}
|
}}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
|
estimatedItemSize={250}
|
||||||
data={data}
|
data={data}
|
||||||
onEndReachedThreshold={1}
|
onEndReachedThreshold={1}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
|
|||||||
@@ -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 gap-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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -34,6 +34,7 @@ export const SearchItemWrapper = <T,>({
|
|||||||
}}
|
}}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
keyExtractor={(_, index) => index.toString()}
|
keyExtractor={(_, index) => index.toString()}
|
||||||
|
estimatedItemSize={250}
|
||||||
data={items}
|
data={items}
|
||||||
onEndReachedThreshold={1}
|
onEndReachedThreshold={1}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -32,7 +32,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
onPress={() =>
|
onPress={() =>
|
||||||
item?.SeriesId && router.push(`/series/${item.SeriesId}`)
|
item?.SeriesId && router.push(`/series/${item.SeriesId}`)
|
||||||
}
|
}
|
||||||
className='flex flex-col gap-y-2 w-28'
|
className='flex flex-col space-y-2 w-28'
|
||||||
>
|
>
|
||||||
<Poster
|
<Poster
|
||||||
id={item?.Id}
|
id={item?.Id}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
|||||||
horizontal
|
horizontal
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
|
estimatedItemSize={50}
|
||||||
data={seasonWithEpisodes?.episodes}
|
data={seasonWithEpisodes?.episodes}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
@@ -283,6 +284,7 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
ItemSeparatorComponent={() => <View className='h-2' />}
|
ItemSeparatorComponent={() => <View className='h-2' />}
|
||||||
|
estimatedItemSize={250}
|
||||||
renderItem={({ item: season }) => (
|
renderItem={({ item: season }) => (
|
||||||
<>
|
<>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user