mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 16:18:09 +00:00
Compare commits
76 Commits
codeql-fix
...
mpv-player
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1769cbd59 | ||
|
|
2b0e238799 | ||
|
|
b573a25203 | ||
|
|
074222050a | ||
|
|
7135be198a | ||
|
|
2648877eb8 | ||
|
|
bc78346760 | ||
|
|
c76d7eb877 | ||
|
|
36655bba43 | ||
|
|
cf269ba83e | ||
|
|
24d5fdefdf | ||
|
|
c05cef295e | ||
|
|
3c57829360 | ||
|
|
06349a4319 | ||
|
|
55ac9ae9d4 | ||
|
|
c8bdcc4df0 | ||
|
|
e7013edd84 | ||
|
|
991b45de06 | ||
|
|
97fe899cb0 | ||
|
|
86d7642dca | ||
|
|
631a5ef94e | ||
|
|
8b8b928837 | ||
|
|
56a3c62ed2 | ||
|
|
82683407da | ||
|
|
7b146e30bd | ||
|
|
5f48bec0f2 | ||
|
|
94362169b6 | ||
|
|
8aefdac50f | ||
|
|
665a79924a | ||
|
|
b9ddcf8404 | ||
|
|
64ffc8db8b | ||
|
|
2a61124a0d | ||
|
|
36178c2082 | ||
|
|
e1c69a9ec9 | ||
|
|
01110b8d13 | ||
|
|
21034f5671 | ||
|
|
1439bcee0d | ||
|
|
9a906e6d39 | ||
|
|
48de7b7c6d | ||
|
|
85e5c25206 | ||
|
|
3dc84818e8 | ||
|
|
18102a3045 | ||
|
|
2be78a232c | ||
|
|
30dc3980e3 | ||
|
|
f7da29b9c9 | ||
|
|
7a5f0b52b6 | ||
|
|
62dfe7c9e1 | ||
|
|
50d559d528 | ||
|
|
38aba3d67a | ||
|
|
5765793d79 | ||
|
|
222ba13529 | ||
|
|
de6c2072c9 | ||
|
|
76ec8e0e46 | ||
|
|
389d9e2d31 | ||
|
|
485dc6eeac | ||
|
|
154788cf91 | ||
|
|
3e181eca72 | ||
|
|
f5b9e03dd9 | ||
|
|
196f91400b | ||
|
|
51a14c6058 | ||
|
|
5432476ca1 | ||
|
|
9d9ec974ff | ||
|
|
0dadfd3d90 | ||
|
|
96d6220f5e | ||
|
|
b847baa314 | ||
|
|
a4cce27737 | ||
|
|
ce37351099 | ||
|
|
e4e1e556bf | ||
|
|
1ff723b29f | ||
|
|
11d35c846d | ||
|
|
ce82f3044b | ||
|
|
df638dae28 | ||
|
|
9a5e49ae16 | ||
|
|
4f2120f85d | ||
|
|
8eeea35441 | ||
|
|
781464b768 |
7
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
7
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -77,13 +77,8 @@ body:
|
|||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.47.1
|
||||||
- 0.30.2
|
- 0.30.2
|
||||||
- 0.29.0
|
|
||||||
- 0.28.0
|
|
||||||
- 0.27.0
|
|
||||||
- 0.26.1
|
|
||||||
- 0.26.0
|
|
||||||
- 0.25.0
|
|
||||||
- older
|
- older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
14
.github/workflows/build-apps.yml
vendored
14
.github/workflows/build-apps.yml
vendored
@@ -156,7 +156,7 @@ jobs:
|
|||||||
|
|
||||||
build-ios-phone:
|
build-ios-phone:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
runs-on: macos-15
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone)
|
name: 🍎 Build iOS IPA (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -191,6 +191,11 @@ jobs:
|
|||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
|
with:
|
||||||
|
xcode-version: "26.0.1"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@main
|
||||||
with:
|
with:
|
||||||
@@ -219,7 +224,7 @@ jobs:
|
|||||||
# Disabled for now - uncomment when ready to build iOS TV
|
# Disabled for now - uncomment when ready to build iOS TV
|
||||||
# build-ios-tv:
|
# build-ios-tv:
|
||||||
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
# runs-on: macos-15
|
# runs-on: macos-26
|
||||||
# name: 🍎 Build iOS IPA (TV)
|
# name: 🍎 Build iOS IPA (TV)
|
||||||
# permissions:
|
# permissions:
|
||||||
# contents: read
|
# contents: read
|
||||||
@@ -254,6 +259,11 @@ jobs:
|
|||||||
# - name: 🛠️ Generate project files
|
# - name: 🛠️ Generate project files
|
||||||
# run: bun run prebuild:tv
|
# run: bun run prebuild:tv
|
||||||
#
|
#
|
||||||
|
# - name: 🔧 Setup Xcode
|
||||||
|
# uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
# with:
|
||||||
|
# xcode-version: '26.0.1'
|
||||||
|
#
|
||||||
# - name: 🏗️ Setup EAS
|
# - name: 🏗️ Setup EAS
|
||||||
# uses: expo/expo-github-action@main
|
# uses: expo/expo-github-action@main
|
||||||
# with:
|
# with:
|
||||||
|
|||||||
10
.github/workflows/ci-codeql.yml
vendored
10
.github/workflows/ci-codeql.yml
vendored
@@ -25,19 +25,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
show-progress: false
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
|
|||||||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -65,3 +65,4 @@ streamyfin-4fec1-firebase-adminsdk.json
|
|||||||
|
|
||||||
# Version and Backup Files
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
|
modules/background-downloader/android/build/*
|
||||||
|
|||||||
177
.vscode/settings.json
vendored
177
.vscode/settings.json
vendored
@@ -1,178 +1,25 @@
|
|||||||
{
|
{
|
||||||
// ==========================================
|
|
||||||
// FORMATTING & LINTING
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Biome as default formatter
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnPaste": true,
|
"editor.codeActionsOnSave": {
|
||||||
"editor.formatOnType": false,
|
"source.fixAll.biome": "explicit"
|
||||||
|
|
||||||
// Language-specific formatters
|
|
||||||
"[javascript]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
"[javascriptreact]": {
|
"[javascriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[jsonc]": {
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSaveMode": "file"
|
||||||
},
|
|
||||||
"[swift]": {
|
|
||||||
"editor.insertSpaces": true,
|
|
||||||
"editor.tabSize": 2
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// TYPESCRIPT & JAVASCRIPT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// TypeScript performance optimizations
|
|
||||||
"typescript.preferences.includePackageJsonAutoImports": "auto",
|
|
||||||
"typescript.suggest.autoImports": true,
|
|
||||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
|
||||||
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
|
||||||
"typescript.preferences.importModuleSpecifier": "relative",
|
|
||||||
"typescript.preferences.includeCompletionsForImportStatements": true,
|
|
||||||
"typescript.preferences.includeCompletionsWithSnippetText": true,
|
|
||||||
|
|
||||||
// JavaScript settings
|
|
||||||
"javascript.preferences.importModuleSpecifier": "relative",
|
|
||||||
"javascript.suggest.autoImports": true,
|
|
||||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// REACT NATIVE & EXPO
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// File associations for React Native
|
|
||||||
"files.associations": {
|
|
||||||
"*.expo.ts": "typescript",
|
|
||||||
"*.expo.tsx": "typescriptreact",
|
|
||||||
"*.expo.js": "javascript",
|
|
||||||
"*.expo.jsx": "javascriptreact",
|
|
||||||
"metro.config.js": "javascript",
|
|
||||||
"babel.config.js": "javascript",
|
|
||||||
"app.config.js": "javascript",
|
|
||||||
"eas.json": "jsonc"
|
|
||||||
},
|
|
||||||
|
|
||||||
// React Native specific settings
|
|
||||||
"emmet.includeLanguages": {
|
|
||||||
"typescriptreact": "html",
|
|
||||||
"javascriptreact": "html"
|
|
||||||
},
|
|
||||||
"emmet.triggerExpansionOnTab": true,
|
|
||||||
|
|
||||||
// Exclude build directories from search
|
|
||||||
"search.exclude": {
|
|
||||||
"**/node_modules": true
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// EDITOR PERFORMANCE & UX
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Performance optimizations
|
|
||||||
"editor.largeFileOptimizations": true,
|
|
||||||
"files.watcherExclude": {
|
|
||||||
"**/.git/objects/**": true,
|
|
||||||
"**/.git/subtree-cache/**": true,
|
|
||||||
"**/node_modules/**": true,
|
|
||||||
"**/.expo/**": true,
|
|
||||||
"**/ios/**": true,
|
|
||||||
"**/android/**": true,
|
|
||||||
"**/build/**": true,
|
|
||||||
"**/dist/**": true
|
|
||||||
},
|
|
||||||
|
|
||||||
// Better editor behavior
|
|
||||||
"editor.suggestSelection": "first",
|
|
||||||
"editor.quickSuggestions": {
|
|
||||||
"strings": true,
|
|
||||||
"comments": true,
|
|
||||||
"other": true
|
|
||||||
},
|
|
||||||
"editor.snippetSuggestions": "top",
|
|
||||||
"editor.tabCompletion": "on",
|
|
||||||
"editor.wordBasedSuggestions": "off",
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// TERMINAL & DEVELOPMENT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Terminal settings for Bun (Windows-specific)
|
|
||||||
"terminal.integrated.profiles.windows": {
|
|
||||||
"Command Prompt": {
|
|
||||||
"path": "C:\\Windows\\System32\\cmd.exe",
|
|
||||||
"env": {
|
|
||||||
"PATH": "${env:PATH};./node_modules/.bin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// WORKSPACE & NAVIGATION
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Better workspace navigation
|
|
||||||
"explorer.fileNesting.enabled": true,
|
|
||||||
"explorer.fileNesting.expand": false,
|
|
||||||
"explorer.fileNesting.patterns": {
|
|
||||||
"*.ts": "${capture}.js",
|
|
||||||
"*.tsx": "${capture}.js",
|
|
||||||
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
|
|
||||||
"*.jsx": "${capture}.js",
|
|
||||||
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
|
|
||||||
"tsconfig.json": "tsconfig.*.json",
|
|
||||||
".env": ".env.*",
|
|
||||||
"app.json": "app.config.js,eas.json,expo-env.d.ts",
|
|
||||||
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
|
|
||||||
},
|
|
||||||
|
|
||||||
// Better breadcrumbs and navigation
|
|
||||||
"breadcrumbs.enabled": true,
|
|
||||||
"outline.showVariables": true,
|
|
||||||
"outline.showConstants": true,
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// GIT & VERSION CONTROL
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Git integration
|
|
||||||
"git.autofetch": true,
|
|
||||||
"git.enableSmartCommit": true,
|
|
||||||
"git.confirmSync": false,
|
|
||||||
"git.ignoreLimitWarning": true,
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// CODE QUALITY & ERRORS
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Better error detection
|
|
||||||
"typescript.validate.enable": true,
|
|
||||||
"javascript.validate.enable": true,
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll": "explicit",
|
|
||||||
"source.organizeImports": "explicit"
|
|
||||||
},
|
|
||||||
|
|
||||||
// Problem matcher for better error display
|
|
||||||
"typescript.tsc.autoDetect": "on"
|
|
||||||
}
|
}
|
||||||
|
|||||||
232
GLOBAL_MODAL_GUIDE.md
Normal file
232
GLOBAL_MODAL_GUIDE.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Global Modal System with Gorhom Bottom Sheet
|
||||||
|
|
||||||
|
This guide explains how to use the global modal system implemented in this project.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The global modal system allows you to trigger a bottom sheet modal from anywhere in your app programmatically, and render any component inside it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The system consists of three main parts:
|
||||||
|
|
||||||
|
1. **GlobalModalProvider** (`providers/GlobalModalProvider.tsx`) - Context provider that manages modal state
|
||||||
|
2. **GlobalModal** (`components/GlobalModal.tsx`) - The actual modal component rendered at root level
|
||||||
|
3. **useGlobalModal** hook - Hook to interact with the modal from anywhere
|
||||||
|
|
||||||
|
## Setup (Already Configured)
|
||||||
|
|
||||||
|
The system is already integrated into your app:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In app/_layout.tsx
|
||||||
|
<BottomSheetModalProvider>
|
||||||
|
<GlobalModalProvider>
|
||||||
|
{/* Your app content */}
|
||||||
|
<GlobalModal />
|
||||||
|
</GlobalModalProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
import { View, Text } from "react-native";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-white text-2xl'>Hello from Modal!</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onPress={handleOpenModal} title="Open Modal" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Custom Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<YourCustomComponent />,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"], // Custom snap points
|
||||||
|
enablePanDownToClose: true, // Allow swipe to close
|
||||||
|
backgroundStyle: { // Custom background
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic Control
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Open modal
|
||||||
|
showModal(<Content />);
|
||||||
|
|
||||||
|
// Close modal from within the modal content
|
||||||
|
function ModalContent() {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Button onPress={hideModal} title="Close" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal from outside
|
||||||
|
hideModal();
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Event Handlers or Functions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useApiCall() {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.fetch();
|
||||||
|
|
||||||
|
// Show success modal
|
||||||
|
showModal(
|
||||||
|
<SuccessMessage data={result} />
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Show error modal
|
||||||
|
showModal(
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetchData;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### `useGlobalModal()`
|
||||||
|
|
||||||
|
Returns an object with the following properties:
|
||||||
|
|
||||||
|
- **`showModal(content, options?)`** - Show the modal with given content
|
||||||
|
- `content: ReactNode` - Any React component or element to render
|
||||||
|
- `options?: ModalOptions` - Optional configuration object
|
||||||
|
|
||||||
|
- **`hideModal()`** - Programmatically hide the modal
|
||||||
|
|
||||||
|
- **`isVisible: boolean`** - Current visibility state of the modal
|
||||||
|
|
||||||
|
### `ModalOptions`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ModalOptions {
|
||||||
|
enableDynamicSizing?: boolean; // Auto-size based on content (default: true)
|
||||||
|
snapPoints?: (string | number)[]; // Fixed snap points (e.g., ["50%", "90%"])
|
||||||
|
enablePanDownToClose?: boolean; // Allow swipe down to close (default: true)
|
||||||
|
backgroundStyle?: object; // Custom background styles
|
||||||
|
handleIndicatorStyle?: object; // Custom handle indicator styles
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||||
|
- Simple content modal
|
||||||
|
- Modal with custom snap points
|
||||||
|
- Complex component in modal
|
||||||
|
- Success/error modals triggered from functions
|
||||||
|
|
||||||
|
## Default Styling
|
||||||
|
|
||||||
|
The modal uses these default styles (can be overridden via options):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
enableDynamicSizing: true,
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
backgroundStyle: {
|
||||||
|
backgroundColor: "#171717", // Dark background
|
||||||
|
},
|
||||||
|
handleIndicatorStyle: {
|
||||||
|
backgroundColor: "white",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Keep content in separate components** - Don't inline large JSX in `showModal()` calls
|
||||||
|
2. **Use the hook in custom hooks** - Create specialized hooks like `useShowSuccessModal()` for reusable modal patterns
|
||||||
|
3. **Handle cleanup** - The modal automatically clears content when closed
|
||||||
|
4. **Avoid nesting** - Don't show modals from within modals
|
||||||
|
5. **Consider UX** - Only use for important, contextual information that requires user attention
|
||||||
|
|
||||||
|
## Using with PlatformDropdown
|
||||||
|
|
||||||
|
When using `PlatformDropdown` with option groups, avoid setting a `title` on the `OptionGroup` if you're already passing a `title` prop to `PlatformDropdown`. This prevents nested menu behavior on iOS where users have to click through an extra layer.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good - No title in option group (title is on PlatformDropdown)
|
||||||
|
const optionGroups: OptionGroup[] = [
|
||||||
|
{
|
||||||
|
options: items.map((item) => ({
|
||||||
|
type: "radio",
|
||||||
|
label: item.name,
|
||||||
|
value: item,
|
||||||
|
selected: item.id === selected?.id,
|
||||||
|
onPress: () => onChange(item),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={optionGroups}
|
||||||
|
title="Select Item" // Title here
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Bad - Causes nested menu on iOS
|
||||||
|
const optionGroups: OptionGroup[] = [
|
||||||
|
{
|
||||||
|
title: "Items", // This creates a nested Picker on iOS
|
||||||
|
options: items.map((item) => ({
|
||||||
|
type: "radio",
|
||||||
|
label: item.name,
|
||||||
|
value: item,
|
||||||
|
selected: item.id === selected?.id,
|
||||||
|
onPress: () => onChange(item),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Modal doesn't appear
|
||||||
|
- Ensure `GlobalModalProvider` is above the component calling `useGlobalModal()`
|
||||||
|
- Check that `BottomSheetModalProvider` is present in the tree
|
||||||
|
- Verify `GlobalModal` component is rendered
|
||||||
|
|
||||||
|
### Content is cut off
|
||||||
|
- Use `enableDynamicSizing: true` for auto-sizing
|
||||||
|
- Or specify appropriate `snapPoints`
|
||||||
|
|
||||||
|
### Modal won't close
|
||||||
|
- Ensure `enablePanDownToClose` is `true`
|
||||||
|
- Check that backdrop is clickable
|
||||||
|
- Use `hideModal()` for programmatic closing
|
||||||
@@ -6,14 +6,16 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add the background downloader plugin only for non-TV builds
|
|
||||||
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only override googleServicesFile if env var is set
|
||||||
|
const androidConfig = {};
|
||||||
|
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||||
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
android: {
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
|
||||||
},
|
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
27
app.json
27
app.json
@@ -2,12 +2,13 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.39.0",
|
"version": "0.48.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,
|
||||||
@@ -28,16 +29,12 @@
|
|||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
"icon": {
|
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
||||||
"dark": "./assets/images/icon-ios-plain.png",
|
|
||||||
"light": "./assets/images/icon-ios-light.png",
|
|
||||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
|
||||||
},
|
|
||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 71,
|
"versionCode": 85,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -77,6 +74,7 @@
|
|||||||
"useFrameworks": "static"
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||||
"compileSdkVersion": 35,
|
"compileSdkVersion": 35,
|
||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
@@ -115,10 +113,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
|
||||||
["./plugins/withAndroidManifest.js"],
|
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
|
||||||
["./plugins/withGradleProperties.js"],
|
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -134,8 +128,12 @@
|
|||||||
"color": "#9333EA"
|
"color": "#9333EA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./plugins/with-runtime-framework-headers.js",
|
"expo-web-browser",
|
||||||
"react-native-bottom-tabs"
|
["./plugins/with-runtime-framework-headers.js"],
|
||||||
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
|
["./plugins/withAndroidManifest.js"],
|
||||||
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
|
["./plugins/withGradleProperties.js"]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
@@ -154,7 +152,6 @@
|
|||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||||
},
|
}
|
||||||
"newArchEnabled": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: Platform.OS !== "ios",
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.custom_links"),
|
headerTitle: t("tabs.custom_links"),
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Favorites } from "@/components/home/Favorites";
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
@@ -28,7 +28,7 @@ export default function favorites() {
|
|||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='my-4'>
|
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||||
<Favorites />
|
<Favorites />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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 />
|
||||||
</>
|
</>
|
||||||
@@ -42,49 +41,237 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='downloads/index'
|
name='downloads/index'
|
||||||
options={{
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='downloads/[seriesId]'
|
name='downloads/[seriesId]'
|
||||||
options={{
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
title: t("home.downloads.tvseries"),
|
title: t("home.downloads.tvseries"),
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='sessions/index'
|
name='sessions/index'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.sessions.title"),
|
title: t("home.sessions.title"),
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings'
|
name='settings'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.settings_title"),
|
title: t("home.settings.settings_title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/marlin-search/page'
|
name='settings/playback-controls/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: t("home.settings.playback_controls.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/jellyseerr/page'
|
name='settings/audio-subtitles/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: t("home.settings.audio_subtitles.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/hide-libraries/page'
|
name='settings/appearance/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: t("home.settings.appearance.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: "",
|
title: t("home.settings.logs.logs_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
|
||||||
@@ -92,6 +279,11 @@ export default function IndexLayout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "",
|
title: "",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
presentation: "modal",
|
presentation: "modal",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -102,6 +294,11 @@ export default function IndexLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { 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 {
|
||||||
@@ -23,21 +25,23 @@ export default function page() {
|
|||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const { getDownloadedItems, deleteItems } = useDownload();
|
const { downloadedItems, deleteItems } = useDownload();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const series = useMemo(() => {
|
const series = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
getDownloadedItems()
|
downloadedItems
|
||||||
?.filter((f) => f.item.SeriesId === seriesId)
|
?.filter((f) => f.item.SeriesId === seriesId)
|
||||||
?.sort(
|
?.sort(
|
||||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
(a, b) =>
|
||||||
|
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
|
||||||
) || []
|
) || []
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [getDownloadedItems]);
|
}, [downloadedItems, seriesId]);
|
||||||
|
|
||||||
// Group episodes by season in a single pass
|
// Group episodes by season in a single pass
|
||||||
const seasonGroups = useMemo(() => {
|
const seasonGroups = useMemo(() => {
|
||||||
@@ -70,8 +74,9 @@ 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[]>(() => {
|
||||||
@@ -80,9 +85,9 @@ export default function page() {
|
|||||||
|
|
||||||
const initialSeasonIndex = useMemo(
|
const initialSeasonIndex = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
groupBySeason?.[0]?.ParentIndexNumber ??
|
||||||
series?.[0]?.item?.ParentIndexNumber,
|
series?.[0]?.item?.ParentIndexNumber,
|
||||||
[groupBySeason],
|
[groupBySeason, series],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,7 +96,7 @@ export default function page() {
|
|||||||
title: series[0].item.SeriesName,
|
title: series[0].item.SeriesName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
storage.delete(seriesId);
|
storage.remove(seriesId);
|
||||||
router.back();
|
router.back();
|
||||||
}
|
}
|
||||||
}, [series]);
|
}, [series]);
|
||||||
@@ -107,44 +112,70 @@ export default function page() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Delete",
|
text: "Delete",
|
||||||
onPress: () => deleteItems(groupBySeason),
|
onPress: () =>
|
||||||
|
deleteItems(
|
||||||
|
groupBySeason
|
||||||
|
.map((item) => item.Id)
|
||||||
|
.filter((id) => id !== undefined),
|
||||||
|
),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}, [groupBySeason]);
|
}, [groupBySeason, deleteItems]);
|
||||||
|
|
||||||
|
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'>
|
||||||
{series.length > 0 && (
|
<FlashList
|
||||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
key={seasonIndex}
|
||||||
<SeasonDropdown
|
data={groupBySeason}
|
||||||
item={series[0].item}
|
renderItem={({ item }) => <EpisodeCard item={item} />}
|
||||||
seasons={uniqueSeasons}
|
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
|
||||||
state={seasonIndexState}
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
initialSeasonIndex={initialSeasonIndex!}
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
onSelect={(season) => {
|
contentContainerStyle={{
|
||||||
setSeasonIndexState((prev) => ({
|
paddingHorizontal: 16,
|
||||||
...prev,
|
paddingLeft: insets.left + 16,
|
||||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
paddingRight: insets.right + 16,
|
||||||
}));
|
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 { Ionicons } from "@expo/vector-icons";
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||||
@@ -26,18 +26,15 @@ import { writeToLog } from "@/utils/log";
|
|||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [_queue, _setQueue] = useAtom(queueAtom);
|
||||||
const {
|
const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
|
||||||
removeProcess,
|
|
||||||
getDownloadedItems,
|
|
||||||
deleteFileByType,
|
|
||||||
deleteAllFiles,
|
|
||||||
} = useDownload();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const [showMigration, setShowMigration] = useState(false);
|
const [showMigration, setShowMigration] = useState(false);
|
||||||
|
|
||||||
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const migration_20241124 = () => {
|
const migration_20241124 = () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.downloads.new_app_version_requires_re_download"),
|
t("home.downloads.new_app_version_requires_re_download"),
|
||||||
@@ -62,7 +59,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadedFiles = getDownloadedItems();
|
const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
const movies = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -106,7 +103,10 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
<TouchableOpacity
|
||||||
|
onPress={bottomSheetModalRef.current?.present}
|
||||||
|
className='px-2'
|
||||||
|
>
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -119,7 +119,7 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [showMigration]);
|
}, [showMigration]);
|
||||||
|
|
||||||
const deleteMovies = () =>
|
const _deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -130,7 +130,7 @@ export default function page() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const deleteShows = () =>
|
const _deleteShows = () =>
|
||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -141,38 +141,39 @@ export default function page() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
});
|
});
|
||||||
const deleteOtherMedia = () =>
|
const _deleteOtherMedia = () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
otherMedia.map((item) =>
|
otherMedia
|
||||||
deleteFileByType(item.item.Type)
|
.filter((item) => item.item.Type)
|
||||||
.then(() =>
|
.map((item) =>
|
||||||
toast.success(
|
deleteFileByType(item.item.Type!)
|
||||||
t("home.downloads.toasts.deleted_media_successfully", {
|
.then(() =>
|
||||||
type: item.item.Type,
|
toast.success(
|
||||||
}),
|
t("home.downloads.toasts.deleted_media_successfully", {
|
||||||
),
|
type: item.item.Type,
|
||||||
)
|
}),
|
||||||
.catch((reason) => {
|
),
|
||||||
writeToLog("ERROR", reason);
|
)
|
||||||
toast.error(
|
.catch((reason) => {
|
||||||
t("home.downloads.toasts.failed_to_delete_media", {
|
writeToLog("ERROR", reason);
|
||||||
type: item.item.Type,
|
toast.error(
|
||||||
}),
|
t("home.downloads.toasts.failed_to_delete_media", {
|
||||||
);
|
type: item.item.Type,
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAllMedia = async () =>
|
|
||||||
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ScrollView
|
||||||
<View style={{ flex: 1 }}>
|
showsVerticalScrollIndicator={false}
|
||||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
<View className='py-4'>
|
>
|
||||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
||||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
|
{/* Queue card - hidden */}
|
||||||
|
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.downloads.queue")}
|
{t("home.downloads.queue")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -214,139 +215,96 @@ export default function page() {
|
|||||||
{t("home.downloads.no_items_in_queue")}
|
{t("home.downloads.no_items_in_queue")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View> */}
|
||||||
|
|
||||||
<ActiveDownloads />
|
<ActiveDownloads />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{movies.length > 0 && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
|
<Text className='text-lg font-bold'>
|
||||||
|
{t("home.downloads.movies")}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
|
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
{movies.length > 0 && (
|
<View className='px-4 flex flex-row'>
|
||||||
<View className='mb-4'>
|
{movies?.map((item) => (
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
<TouchableItemRouter
|
||||||
<Text className='text-lg font-bold'>
|
item={item.item}
|
||||||
{t("home.downloads.movies")}
|
isOffline
|
||||||
</Text>
|
key={item.item.Id}
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
>
|
||||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
<MovieCard item={item.item} />
|
||||||
</View>
|
</TouchableItemRouter>
|
||||||
</View>
|
))}
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{movies?.map((item) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item.item}
|
|
||||||
isOffline
|
|
||||||
key={item.item.Id}
|
|
||||||
>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
</ScrollView>
|
||||||
{groupedBySeries.length > 0 && (
|
</View>
|
||||||
<View className='mb-4'>
|
)}
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
{groupedBySeries.length > 0 && (
|
||||||
<Text className='text-lg font-bold'>
|
<View className='mb-4'>
|
||||||
{t("home.downloads.tvseries")}
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
</Text>
|
<Text className='text-lg font-bold'>
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
{t("home.downloads.tvseries")}
|
||||||
<Text className='text-xs font-bold'>
|
</Text>
|
||||||
{groupedBySeries?.length}
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
</Text>
|
<Text className='text-xs font-bold'>
|
||||||
</View>
|
{groupedBySeries?.length}
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{groupedBySeries?.map((items) => (
|
|
||||||
<View
|
|
||||||
className='mb-2 last:mb-0'
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
>
|
|
||||||
<SeriesCard
|
|
||||||
items={items.map((i) => i.item)}
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{otherMedia.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.other_media")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>
|
|
||||||
{otherMedia?.length}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{otherMedia?.map((item) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item.item}
|
|
||||||
isOffline
|
|
||||||
key={item.item.Id}
|
|
||||||
>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{downloadedFiles?.length === 0 && (
|
|
||||||
<View className='flex px-4'>
|
|
||||||
<Text className='opacity-50'>
|
|
||||||
{t("home.downloads.no_downloaded_items")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className='px-4 flex flex-row'>
|
||||||
|
{groupedBySeries?.map((items) => (
|
||||||
|
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}>
|
||||||
|
<SeriesCard
|
||||||
|
items={items.map((i) => i.item)}
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
enableDynamicSizing
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<BottomSheetView>
|
{otherMedia.length > 0 && (
|
||||||
<View className='p-4 space-y-4 mb-4'>
|
<View className='mb-4'>
|
||||||
<Button color='purple' onPress={deleteMovies}>
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
{t("home.downloads.delete_all_movies_button")}
|
<Text className='text-lg font-bold'>
|
||||||
</Button>
|
{t("home.downloads.other_media")}
|
||||||
<Button color='purple' onPress={deleteShows}>
|
</Text>
|
||||||
{t("home.downloads.delete_all_tvseries_button")}
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
</Button>
|
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
|
||||||
{otherMedia.length > 0 && (
|
</View>
|
||||||
<Button color='purple' onPress={deleteOtherMedia}>
|
</View>
|
||||||
{t("home.downloads.delete_all_other_media_button")}
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
</Button>
|
<View className='px-4 flex flex-row'>
|
||||||
)}
|
{otherMedia?.map((item) => (
|
||||||
<Button color='red' onPress={deleteAllMedia}>
|
<TouchableItemRouter
|
||||||
{t("home.downloads.delete_all_button")}
|
item={item.item}
|
||||||
</Button>
|
isOffline
|
||||||
|
key={item.item.Id}
|
||||||
|
>
|
||||||
|
<MovieCard item={item.item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
)}
|
||||||
</BottomSheetModal>
|
{downloadedFiles?.length === 0 && (
|
||||||
</>
|
<View className='flex px-4'>
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_downloaded_items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { HomeIndex } from "@/components/settings/HomeIndex";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Home } from "../../../../components/home/Home";
|
||||||
|
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
||||||
|
|
||||||
export default function page() {
|
const Index = () => {
|
||||||
return <HomeIndex />;
|
const { settings } = useSettings();
|
||||||
}
|
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
||||||
|
|
||||||
|
if (showLargeHomeCarousel) {
|
||||||
|
return <HomeWithCarousel />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Home />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import {
|
import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
|
||||||
HardwareAccelerationType,
|
|
||||||
type SessionInfoDto,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
import {
|
||||||
GeneralCommandType,
|
GeneralCommandType,
|
||||||
PlaystateCommand,
|
PlaystateCommand,
|
||||||
|
SessionInfoDto,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
@@ -13,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Badge } from "@/components/Badge";
|
import { Badge } from "@/components/Badge";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -49,14 +47,13 @@ export default function page() {
|
|||||||
<FlashList
|
<FlashList
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 17,
|
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||||
paddingHorizontal: 17,
|
paddingHorizontal: 17,
|
||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
}}
|
}}
|
||||||
data={sessions}
|
data={sessions}
|
||||||
renderItem={({ item }) => <SessionCard session={item} />}
|
renderItem={({ item }) => <SessionCard session={item} />}
|
||||||
keyExtractor={(item) => item.Id || ""}
|
keyExtractor={(item) => item.Id || ""}
|
||||||
estimatedItemSize={200}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,34 +8,16 @@ 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(() => {
|
||||||
@@ -46,7 +28,7 @@ export default function settings() {
|
|||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-red-600'>
|
<Text className='text-red-600 px-2'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -56,61 +38,58 @@ export default function settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='p-4 flex flex-col gap-y-4'>
|
<View
|
||||||
<UserInfo />
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<View className='mb-4'>
|
||||||
|
<UserInfo />
|
||||||
|
</View>
|
||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
<MediaProvider>
|
<View className='mb-4'>
|
||||||
<MediaToggles className='mb-4' />
|
<AppLanguageSelector />
|
||||||
<GestureControls className='mb-4' />
|
</View>
|
||||||
<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.logs.logs_title")}>
|
<ListGroup title={t("home.settings.categories.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>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
app/(auth)/(tabs)/(home)/settings/appearance/page.tsx
Normal file
25
app/(auth)/(tabs)/(home)/settings/appearance/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
Normal file
29
app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/(auth)/(tabs)/(home)/settings/intro/page.tsx
Normal file
45
app/(auth)/(tabs)/(home)/settings/intro/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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,15 +1,21 @@
|
|||||||
import * as FileSystem from "expo-file-system";
|
import { File, Paths } from "expo-file-system";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as Sharing from "expo-sharing";
|
import type * as SharingType 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 { Platform, 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";
|
||||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||||
|
|
||||||
|
// Conditionally import expo-sharing only on non-TV platforms
|
||||||
|
const Sharing = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (require("expo-sharing") as typeof SharingType);
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { logs } = useLog();
|
const { logs } = useLog();
|
||||||
@@ -33,6 +39,7 @@ 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(
|
||||||
() =>
|
() =>
|
||||||
@@ -47,27 +54,30 @@ 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 uri = `${FileSystem.documentDirectory}logs.txt`;
|
if (!Sharing) return;
|
||||||
|
|
||||||
|
const logsFile = new File(Paths.document, "logs.txt");
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
|
try {
|
||||||
.then(() => {
|
logsFile.write(JSON.stringify(filteredLogs));
|
||||||
setLoading(false);
|
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
||||||
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
|
} catch (e: any) {
|
||||||
})
|
writeErrorLog("Something went wrong attempting to export", e);
|
||||||
.catch((e) =>
|
} finally {
|
||||||
writeErrorLog("Something went wrong attempting to export", e),
|
setLoading(false);
|
||||||
)
|
}
|
||||||
.finally(() => setLoading(false));
|
}, [filteredLogs, Sharing]);
|
||||||
}, [filteredLogs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
loading ? (
|
loading ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity onPress={share}>
|
<TouchableOpacity onPress={share} className='px-2'>
|
||||||
<Text>{t("home.settings.logs.export_logs")}</Text>
|
<Text>{t("home.settings.logs.export_logs")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -75,7 +85,12 @@ export default function Page() {
|
|||||||
}, [share, loading]);
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View
|
||||||
|
className='flex-1'
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top + 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
@@ -157,6 +172,6 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +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,
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
35
app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
Normal file
35
app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
Normal file
138
app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/(auth)/(tabs)/(home)/settings/plugins/page.tsx
Normal file
24
app/(auth)/(tabs)/(home)/settings/plugins/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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,6 +16,7 @@ import type React from "react";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
@@ -204,154 +205,154 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className=''>
|
<FlatList
|
||||||
<FlatList
|
horizontal
|
||||||
horizontal
|
showsHorizontalScrollIndicator={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
contentContainerStyle={{
|
||||||
contentContainerStyle={{
|
display: "flex",
|
||||||
display: "flex",
|
paddingHorizontal: 15,
|
||||||
paddingHorizontal: 15,
|
paddingVertical: 16,
|
||||||
paddingVertical: 16,
|
flexDirection: "row",
|
||||||
flexDirection: "row",
|
}}
|
||||||
}}
|
extraData={[
|
||||||
extraData={[
|
selectedGenres,
|
||||||
selectedGenres,
|
selectedYears,
|
||||||
selectedYears,
|
selectedTags,
|
||||||
selectedTags,
|
sortBy,
|
||||||
sortBy,
|
sortOrder,
|
||||||
sortOrder,
|
]}
|
||||||
]}
|
data={[
|
||||||
data={[
|
{
|
||||||
{
|
key: "reset",
|
||||||
key: "reset",
|
component: <ResetFiltersButton />,
|
||||||
component: <ResetFiltersButton />,
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "genre",
|
||||||
key: "genre",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='genreFilter'
|
||||||
queryKey='genreFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: collectionId,
|
||||||
parentId: collectionId,
|
});
|
||||||
});
|
return response.data.Genres || [];
|
||||||
return response.data.Genres || [];
|
}}
|
||||||
}}
|
set={setSelectedGenres}
|
||||||
set={setSelectedGenres}
|
values={selectedGenres}
|
||||||
values={selectedGenres}
|
title={t("library.filters.genres")}
|
||||||
title={t("library.filters.genres")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "year",
|
||||||
key: "year",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='yearFilter'
|
||||||
queryKey='yearFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: collectionId,
|
||||||
parentId: collectionId,
|
});
|
||||||
});
|
return response.data.Years || [];
|
||||||
return response.data.Years || [];
|
}}
|
||||||
}}
|
set={setSelectedYears}
|
||||||
set={setSelectedYears}
|
values={selectedYears}
|
||||||
values={selectedYears}
|
title={t("library.filters.years")}
|
||||||
title={t("library.filters.years")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "tags",
|
||||||
key: "tags",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='tagsFilter'
|
||||||
queryKey='tagsFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: collectionId,
|
||||||
parentId: collectionId,
|
});
|
||||||
});
|
return response.data.Tags || [];
|
||||||
return response.data.Tags || [];
|
}}
|
||||||
}}
|
set={setSelectedTags}
|
||||||
set={setSelectedTags}
|
values={selectedTags}
|
||||||
values={selectedTags}
|
title={t("library.filters.tags")}
|
||||||
title={t("library.filters.tags")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortBy",
|
||||||
key: "sortBy",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='sortBy'
|
||||||
queryKey='sortBy'
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
set={setSortBy}
|
||||||
set={setSortBy}
|
values={sortBy}
|
||||||
values={sortBy}
|
title={t("library.filters.sort_by")}
|
||||||
title={t("library.filters.sort_by")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortOrder",
|
||||||
key: "sortOrder",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='sortOrder'
|
||||||
queryKey='sortOrder'
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
set={setSortOrder}
|
||||||
set={setSortOrder}
|
values={sortOrder}
|
||||||
values={sortOrder}
|
title={t("library.filters.sort_order")}
|
||||||
title={t("library.filters.sort_order")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
]}
|
||||||
]}
|
renderItem={({ item }) => item.component}
|
||||||
renderItem={({ item }) => item.component}
|
keyExtractor={(item) => item.key}
|
||||||
keyExtractor={(item) => item.key}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
collectionId,
|
collectionId,
|
||||||
@@ -393,7 +394,6 @@ const page: React.FC = () => {
|
|||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={255}
|
|
||||||
numColumns={
|
numColumns={
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const Page: React.FC = () => {
|
|||||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
const isOffline = offline === "true";
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
// Fetch item with all fields including MediaSources
|
||||||
|
const { data: item, isError } = useItemQuery(id, isOffline, undefined, []);
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
|||||||
@@ -14,36 +14,43 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
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 {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
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();
|
||||||
@@ -60,11 +67,12 @@ const Page: React.FC = () => {
|
|||||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -92,6 +100,46 @@ const Page: React.FC = () => {
|
|||||||
const [canRequest, hasAdvancedRequestPermission] =
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
useJellyseerrCanRequest(details);
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
|
const canManageRequests = useMemo(() => {
|
||||||
|
if (!jellyseerrUser) return false;
|
||||||
|
return hasPermission(
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
);
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const pendingRequest = useMemo(() => {
|
||||||
|
return details?.mediaInfo?.requests?.find(
|
||||||
|
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
||||||
|
);
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
const handleApproveRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||||
|
console.error("Failed to approve request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
|
const handleDeclineRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||||
|
console.error("Failed to decline request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -115,6 +163,10 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
|
const handleIssueModalDismiss = useCallback(() => {
|
||||||
|
setIssueTypeDropdownOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setRequestBody = useCallback(
|
const setRequestBody = useCallback(
|
||||||
(body: MediaRequestBody) => {
|
(body: MediaRequestBody) => {
|
||||||
_setRequestBody(body);
|
_setRequestBody(body);
|
||||||
@@ -128,9 +180,11 @@ const Page: React.FC = () => {
|
|||||||
mediaId: Number(result.id!),
|
mediaId: Number(result.id!),
|
||||||
mediaType: mediaType!,
|
mediaType: mediaType!,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
seasons: (details as TvDetails)?.seasons
|
...(mediaType === MediaType.TV && {
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
seasons: (details as TvDetails)?.seasons
|
||||||
?.map?.((s) => s.seasonNumber),
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
|
?.map?.((s) => s.seasonNumber),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasAdvancedRequestPermission) {
|
if (hasAdvancedRequestPermission) {
|
||||||
@@ -156,11 +210,31 @@ const Page: React.FC = () => {
|
|||||||
[details],
|
[details],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const issueTypeOptionGroups = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: t("jellyseerr.types"),
|
||||||
|
options: Object.entries(IssueTypeName)
|
||||||
|
.reverse()
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: value,
|
||||||
|
value: key,
|
||||||
|
selected: key === String(issueType),
|
||||||
|
onPress: () => setIssueType(key as unknown as IssueType),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[issueType, t],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (details) {
|
if (details) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
<TouchableOpacity
|
||||||
|
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
|
||||||
|
>
|
||||||
<ItemActions item={details} />
|
<ItemActions item={details} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -309,6 +383,60 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{canManageRequests && pendingRequest && (
|
||||||
|
<View className='flex flex-col space-y-2 mt-4'>
|
||||||
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
|
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||||
|
<Text className='text-sm text-neutral-400'>
|
||||||
|
{t("jellyseerr.requested_by", {
|
||||||
|
user:
|
||||||
|
pendingRequest.requestedBy?.displayName ||
|
||||||
|
pendingRequest.requestedBy?.username ||
|
||||||
|
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||||
|
t("jellyseerr.unknown_user"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex flex-row space-x-2'>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleApproveRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleDeclineRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='close-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<OverviewText text={result.overview} className='mt-4' />
|
<OverviewText text={result.overview} className='mt-4' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -355,6 +483,8 @@ const Page: React.FC = () => {
|
|||||||
backgroundColor: "#171717",
|
backgroundColor: "#171717",
|
||||||
}}
|
}}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
|
stackBehavior='push'
|
||||||
|
onDismiss={handleIssueModalDismiss}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -364,50 +494,25 @@ const Page: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col w-full'>
|
||||||
<DropdownMenu.Root>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
<DropdownMenu.Trigger>
|
{t("jellyseerr.issue_type")}
|
||||||
<View className='flex flex-col'>
|
</Text>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<PlatformDropdown
|
||||||
{t("jellyseerr.issue_type")}
|
groups={issueTypeOptionGroups}
|
||||||
|
trigger={
|
||||||
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{issueType
|
||||||
|
? IssueTypeName[issueType]
|
||||||
|
: t("jellyseerr.select_an_issue")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
|
||||||
{issueType
|
|
||||||
? IssueTypeName[issueType]
|
|
||||||
: t("jellyseerr.select_an_issue")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
}
|
||||||
<DropdownMenu.Content
|
title={t("jellyseerr.types")}
|
||||||
loop={false}
|
open={issueTypeDropdownOpen}
|
||||||
side='bottom'
|
onOpenChange={setIssueTypeDropdownOpen}
|
||||||
align='center'
|
/>
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>
|
|
||||||
{t("jellyseerr.types")}
|
|
||||||
</DropdownMenu.Label>
|
|
||||||
{Object.entries(IssueTypeName)
|
|
||||||
.reverse()
|
|
||||||
.map(([key, value], _idx) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={value}
|
|
||||||
onSelect={() =>
|
|
||||||
setIssueType(key as unknown as IssueType)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{value}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||||
|
|||||||
@@ -87,14 +87,15 @@ 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")}{" "}
|
||||||
{new Date(data?.details?.birthday!).toLocaleDateString(
|
{data?.details?.birthday &&
|
||||||
`${locale}-${region}`,
|
new Date(data.details.birthday).toLocaleDateString(
|
||||||
{
|
`${locale}-${region}`,
|
||||||
year: "numeric",
|
{
|
||||||
month: "long",
|
year: "numeric",
|
||||||
day: "numeric",
|
month: "long",
|
||||||
},
|
day: "numeric",
|
||||||
)}{" "}
|
},
|
||||||
|
)}{" "}
|
||||||
| {data?.details?.placeOfBirth}
|
| {data?.details?.placeOfBirth}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ 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'>
|
||||||
|
|||||||
@@ -65,9 +65,11 @@ const page: React.FC = () => {
|
|||||||
const { data: allEpisodes, isLoading } = useQuery({
|
const { data: allEpisodes, isLoading } = useQuery({
|
||||||
queryKey: ["AllEpisodes", item?.Id],
|
queryKey: ["AllEpisodes", item?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getTvShowsApi(api!).getEpisodes({
|
if (!api || !user?.Id || !item?.Id) return [];
|
||||||
seriesId: item?.Id!,
|
|
||||||
userId: user?.Id!,
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.Id,
|
||||||
|
userId: user.Id,
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
// Note: Including trick play is necessary to enable trick play downloads
|
// Note: Including trick play is necessary to enable trick play downloads
|
||||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
|
|||||||
@@ -271,145 +271,143 @@ const Page = () => {
|
|||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className=''>
|
<FlatList
|
||||||
<FlatList
|
horizontal
|
||||||
horizontal
|
showsHorizontalScrollIndicator={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
contentContainerStyle={{
|
||||||
contentContainerStyle={{
|
display: "flex",
|
||||||
display: "flex",
|
paddingHorizontal: 15,
|
||||||
paddingHorizontal: 15,
|
paddingVertical: 16,
|
||||||
paddingVertical: 16,
|
flexDirection: "row",
|
||||||
flexDirection: "row",
|
}}
|
||||||
}}
|
data={[
|
||||||
data={[
|
{
|
||||||
{
|
key: "reset",
|
||||||
key: "reset",
|
component: <ResetFiltersButton />,
|
||||||
component: <ResetFiltersButton />,
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "genre",
|
||||||
key: "genre",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='genreFilter'
|
||||||
queryKey='genreFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: libraryId,
|
||||||
parentId: libraryId,
|
});
|
||||||
});
|
return response.data.Genres || [];
|
||||||
return response.data.Genres || [];
|
}}
|
||||||
}}
|
set={setSelectedGenres}
|
||||||
set={setSelectedGenres}
|
values={selectedGenres}
|
||||||
values={selectedGenres}
|
title={t("library.filters.genres")}
|
||||||
title={t("library.filters.genres")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "year",
|
||||||
key: "year",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='yearFilter'
|
||||||
queryKey='yearFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: libraryId,
|
||||||
parentId: libraryId,
|
});
|
||||||
});
|
return response.data.Years || [];
|
||||||
return response.data.Years || [];
|
}}
|
||||||
}}
|
set={setSelectedYears}
|
||||||
set={setSelectedYears}
|
values={selectedYears}
|
||||||
values={selectedYears}
|
title={t("library.filters.years")}
|
||||||
title={t("library.filters.years")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "tags",
|
||||||
key: "tags",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='tagsFilter'
|
||||||
queryKey='tagsFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: libraryId,
|
||||||
parentId: libraryId,
|
});
|
||||||
});
|
return response.data.Tags || [];
|
||||||
return response.data.Tags || [];
|
}}
|
||||||
}}
|
set={setSelectedTags}
|
||||||
set={setSelectedTags}
|
values={selectedTags}
|
||||||
values={selectedTags}
|
title={t("library.filters.tags")}
|
||||||
title={t("library.filters.tags")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortBy",
|
||||||
key: "sortBy",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='sortBy'
|
||||||
queryKey='sortBy'
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
set={setSortBy}
|
||||||
set={setSortBy}
|
values={sortBy}
|
||||||
values={sortBy}
|
title={t("library.filters.sort_by")}
|
||||||
title={t("library.filters.sort_by")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortOrder",
|
||||||
key: "sortOrder",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='sortOrder'
|
||||||
queryKey='sortOrder'
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
set={setSortOrder}
|
||||||
set={setSortOrder}
|
values={sortOrder}
|
||||||
values={sortOrder}
|
title={t("library.filters.sort_order")}
|
||||||
title={t("library.filters.sort_order")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
]}
|
||||||
]}
|
renderItem={({ item }) => item.component}
|
||||||
renderItem={({ item }) => item.component}
|
keyExtractor={(item) => item.key}
|
||||||
keyExtractor={(item) => item.key}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
libraryId,
|
libraryId,
|
||||||
@@ -453,7 +451,6 @@ const Page = () => {
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={[orientation, nrOfCols]}
|
extraData={[orientation, nrOfCols]}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={244}
|
|
||||||
numColumns={nrOfCols}
|
numColumns={nrOfCols}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
|
|||||||
@@ -1,85 +1,208 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = 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>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name='index'
|
||||||
name='index'
|
options={{
|
||||||
options={{
|
headerShown: !Platform.isTV,
|
||||||
headerShown: !Platform.isTV,
|
headerTitle: t("tabs.library"),
|
||||||
headerTitle: t("tabs.library"),
|
headerBlurEffect: "none",
|
||||||
headerBlurEffect: "none",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerShadowVisible: false,
|
||||||
headerShadowVisible: false,
|
headerRight: () =>
|
||||||
headerRight: () =>
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
!Platform.isTV && (
|
||||||
!Platform.isTV && (
|
<PlatformDropdown
|
||||||
<TouchableOpacity
|
open={dropdownOpen}
|
||||||
onPress={() => setOptionsSheetOpen(true)}
|
onOpenChange={setDropdownOpen}
|
||||||
className='flex flex-row items-center justify-center w-9 h-9'
|
trigger={
|
||||||
>
|
<View className='pl-1.5'>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
),
|
}
|
||||||
}}
|
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 { StyleSheet, View } from "react-native";
|
import { Platform, StyleSheet, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -84,11 +84,11 @@ export default function index() {
|
|||||||
extraData={settings}
|
extraData={settings}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 17,
|
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left + 17,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right + 17,
|
||||||
}}
|
}}
|
||||||
data={libraries}
|
data={libraries}
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
@@ -105,7 +105,6 @@ export default function index() {
|
|||||||
<View className='h-4' />
|
<View className='h-4' />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
estimatedItemSize={200}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function SearchLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: !Platform.isTV,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { Tag } from "@/components/GenreTags";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import {
|
import {
|
||||||
JellyseerrSearchSort,
|
JellyseerrSearchSort,
|
||||||
@@ -33,8 +31,10 @@ import {
|
|||||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
|
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||||
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -284,67 +284,30 @@ export default function search() {
|
|||||||
)}
|
)}
|
||||||
<View
|
<View
|
||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{jellyseerrApi && (
|
{jellyseerrApi && (
|
||||||
<ScrollView
|
<View className='pl-4 pr-4 flex flex-row'>
|
||||||
horizontal
|
<SearchTabButtons
|
||||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
searchType={searchType}
|
||||||
>
|
setSearchType={setSearchType}
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
t={t}
|
||||||
<Tag
|
/>
|
||||||
text={t("search.library")}
|
|
||||||
textClass='p-1'
|
|
||||||
className={
|
|
||||||
searchType === "Library" ? "bg-purple-600" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
|
||||||
<Tag
|
|
||||||
text={t("search.discover")}
|
|
||||||
textClass='p-1'
|
|
||||||
className={
|
|
||||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{searchType === "Discover" &&
|
{searchType === "Discover" &&
|
||||||
!loading &&
|
!loading &&
|
||||||
noResults &&
|
noResults &&
|
||||||
debouncedSearch.length > 0 && (
|
debouncedSearch.length > 0 && (
|
||||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
<DiscoverFilters
|
||||||
<FilterButton
|
searchFilterId={searchFilterId}
|
||||||
id={searchFilterId}
|
orderFilterId={orderFilterId}
|
||||||
queryKey='jellyseerr_search'
|
jellyseerrOrderBy={jellyseerrOrderBy}
|
||||||
queryFn={async () =>
|
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
||||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
jellyseerrSortOrder={jellyseerrSortOrder}
|
||||||
Number.isNaN(Number(v)),
|
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
||||||
)
|
t={t}
|
||||||
}
|
/>
|
||||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
|
||||||
values={[jellyseerrOrderBy]}
|
|
||||||
title={t("library.filters.sort_by")}
|
|
||||||
renderItemLabel={(item) =>
|
|
||||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
|
||||||
}
|
|
||||||
disableSearch={true}
|
|
||||||
/>
|
|
||||||
<FilterButton
|
|
||||||
id={orderFilterId}
|
|
||||||
queryKey='jellysearr_search'
|
|
||||||
queryFn={async () => ["asc", "desc"]}
|
|
||||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
|
||||||
values={[jellyseerrSortOrder]}
|
|
||||||
title={t("library.filters.sort_order")}
|
|
||||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
|
||||||
disableSearch={true}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className='mt-2'>
|
<View className='mt-2'>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export default function TabLayout() {
|
|||||||
backgroundColor: "#121212",
|
backgroundColor: "#121212",
|
||||||
}}
|
}}
|
||||||
tabBarActiveTintColor={Colors.primary}
|
tabBarActiveTintColor={Colors.primary}
|
||||||
|
activeIndicatorColor={"#392c3b"}
|
||||||
scrollEdgeAppearance='default'
|
scrollEdgeAppearance='default'
|
||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name='index' />
|
<NativeTabs.Screen redirect name='index' />
|
||||||
@@ -70,10 +71,7 @@ export default function TabLayout() {
|
|||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/house.fill.png")
|
? (_e) => require("@/assets/icons/house.fill.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "house.fill" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "house.fill" }
|
|
||||||
: { sfSymbol: "house" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -84,14 +82,12 @@ export default function TabLayout() {
|
|||||||
})}
|
})}
|
||||||
name='(search)'
|
name='(search)'
|
||||||
options={{
|
options={{
|
||||||
|
role: "search",
|
||||||
title: t("tabs.search"),
|
title: t("tabs.search"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "magnifyingglass" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "magnifyingglass" }
|
|
||||||
: { sfSymbol: "magnifyingglass" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -100,14 +96,8 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.favorites"),
|
title: t("tabs.favorites"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) =>
|
? (_e) => require("@/assets/icons/heart.fill.png")
|
||||||
focused
|
: (_e) => ({ sfSymbol: "heart.fill" }),
|
||||||
? require("@/assets/icons/heart.fill.png")
|
|
||||||
: require("@/assets/icons/heart.png")
|
|
||||||
: ({ focused }) =>
|
|
||||||
focused
|
|
||||||
? { sfSymbol: "heart.fill" }
|
|
||||||
: { sfSymbol: "heart" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -117,10 +107,7 @@ export default function TabLayout() {
|
|||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/server.rack.png")
|
? (_e) => require("@/assets/icons/server.rack.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "rectangle.stack.fill" }
|
|
||||||
: { sfSymbol: "rectangle.stack" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -131,10 +118,7 @@ export default function TabLayout() {
|
|||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "list.dash.fill" }
|
|
||||||
: { sfSymbol: "list.dash" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
|||||||
@@ -1,7 +1,33 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { AppState } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply orientation lock when app returns to foreground (iOS resets it)
|
||||||
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
|
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
unlockOrientation();
|
||||||
|
};
|
||||||
|
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
|
|||||||
@@ -22,33 +22,34 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import {
|
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||||
OUTLINE_THICKNESS,
|
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||||
OutlineThickness,
|
|
||||||
VLC_COLORS,
|
|
||||||
VLCColor,
|
|
||||||
} from "@/constants/SubtitleConstants";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
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";
|
||||||
import { VlcPlayerView } from "@/modules";
|
import {
|
||||||
import type {
|
MpvPlayerView,
|
||||||
PlaybackStatePayload,
|
type MpvPlayerViewRef,
|
||||||
ProgressUpdatePayload,
|
type OnPlaybackStateChangePayload,
|
||||||
VlcPlayerViewRef,
|
type OnProgressEventPayload,
|
||||||
} from "@/modules/VlcPlayer.types";
|
type VideoSource,
|
||||||
|
} from "@/modules";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
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 { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import {
|
||||||
|
getMpvAudioId,
|
||||||
|
getMpvSubtitleId,
|
||||||
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -56,6 +57,7 @@ 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");
|
||||||
@@ -66,6 +68,7 @@ export default function page() {
|
|||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
|
const [tracksReady, setTracksReady] = useState(false);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
@@ -75,7 +78,10 @@ export default function page() {
|
|||||||
: require("react-native-volume-manager");
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
const downloadUtils = useDownload();
|
const downloadUtils = useDownload();
|
||||||
const downloadedFiles = downloadUtils.getDownloadedItems();
|
const downloadedFiles = useMemo(
|
||||||
|
() => downloadUtils.getDownloadedItems(),
|
||||||
|
[downloadUtils.getDownloadedItems],
|
||||||
|
);
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
@@ -213,8 +219,6 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const native = generateDeviceProfile();
|
|
||||||
const transcoding = generateDeviceProfile({ transcode: true });
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -224,7 +228,7 @@ export default function page() {
|
|||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: bitrateValue ? transcoding : native,
|
deviceProfile: generateDeviceProfile(),
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
const { mediaSource, sessionId, url } = res;
|
const { mediaSource, sessionId, url } = res;
|
||||||
@@ -282,12 +286,14 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
|
if (!item?.Id || !stream?.sessionId) return;
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.get());
|
const currentTimeInTicks = msToTicks(progress.get());
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
itemId: item?.Id!,
|
itemId: item.Id,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
positionTicks: currentTimeInTicks,
|
positionTicks: currentTimeInTicks,
|
||||||
playSessionId: stream?.sessionId!,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
api,
|
api,
|
||||||
@@ -306,7 +312,8 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.stop();
|
// MPV doesn't have a stop method, use pause instead
|
||||||
|
videoRef.current?.pause();
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
}, [videoRef, reportPlaybackStopped, progress]);
|
}, [videoRef, reportPlaybackStopped, progress]);
|
||||||
|
|
||||||
@@ -318,9 +325,10 @@ export default function page() {
|
|||||||
}, [navigation, stop]);
|
}, [navigation, stop]);
|
||||||
|
|
||||||
const currentPlayStateInfo = useCallback(() => {
|
const currentPlayStateInfo = useCallback(() => {
|
||||||
if (!stream) return;
|
if (!stream || !item?.Id) return;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
itemId: item?.Id!,
|
itemId: item.Id,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
@@ -361,10 +369,13 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: { nativeEvent: OnProgressEventPayload }) => {
|
||||||
if (isSeeking.get() || isPlaybackStopped) return;
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
const { position } = data.nativeEvent;
|
||||||
|
// MPV reports position in seconds, convert to ms
|
||||||
|
const currentTime = position * 1000;
|
||||||
|
|
||||||
if (isBuffering) {
|
if (isBuffering) {
|
||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
}
|
}
|
||||||
@@ -410,6 +421,46 @@ export default function page() {
|
|||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
return ticksToSeconds(getInitialPlaybackTicks());
|
||||||
}, [getInitialPlaybackTicks]);
|
}, [getInitialPlaybackTicks]);
|
||||||
|
|
||||||
|
/** Build video source config for the native player */
|
||||||
|
const videoSource = useMemo<VideoSource | undefined>(() => {
|
||||||
|
if (!stream?.url) return undefined;
|
||||||
|
|
||||||
|
const mediaSource = stream.mediaSource;
|
||||||
|
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
|
// Get external subtitle URLs
|
||||||
|
const externalSubs = mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) =>
|
||||||
|
s.Type === "Subtitle" &&
|
||||||
|
s.DeliveryMethod === "External" &&
|
||||||
|
s.DeliveryUrl,
|
||||||
|
).map((s) => `${api?.basePath}${s.DeliveryUrl}`);
|
||||||
|
|
||||||
|
// Calculate MPV track IDs for initial selection
|
||||||
|
const initialSubtitleId = getMpvSubtitleId(
|
||||||
|
mediaSource,
|
||||||
|
subtitleIndex,
|
||||||
|
isTranscoding,
|
||||||
|
);
|
||||||
|
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: stream.url,
|
||||||
|
startPosition,
|
||||||
|
autoplay: true,
|
||||||
|
externalSubtitles: externalSubs,
|
||||||
|
initialSubtitleId,
|
||||||
|
initialAudioId,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
stream?.url,
|
||||||
|
stream?.mediaSource,
|
||||||
|
startPosition,
|
||||||
|
api?.basePath,
|
||||||
|
subtitleIndex,
|
||||||
|
audioIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
const volumeUpCb = useCallback(async () => {
|
const volumeUpCb = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -490,10 +541,12 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback(
|
const onPlaybackStateChanged = useCallback(
|
||||||
async (e: PlaybackStatePayload) => {
|
async (e: { nativeEvent: OnPlaybackStateChangePayload }) => {
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
|
||||||
if (state === "Playing") {
|
|
||||||
|
if (playing) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
setIsBuffering(false);
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||||
@@ -503,7 +556,7 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === "Paused") {
|
if (isPaused) {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
@@ -514,87 +567,13 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlaying) {
|
if (isLoading) {
|
||||||
setIsPlaying(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
} else if (isBuffering) {
|
|
||||||
setIsBuffering(true);
|
setIsBuffering(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[playbackManager, item?.Id, progress],
|
[playbackManager, item?.Id, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allAudio =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(audio) => audio.Type === "Audio",
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
// Move all the external subtitles last, because vlc places them last.
|
|
||||||
const allSubs =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(sub) => sub.Type === "Subtitle",
|
|
||||||
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
|
|
||||||
|
|
||||||
const externalSubtitles = allSubs
|
|
||||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
|
||||||
.map((sub: any) => ({
|
|
||||||
name: sub.DisplayTitle,
|
|
||||||
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
|
|
||||||
}));
|
|
||||||
/** The text based subtitle tracks */
|
|
||||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
|
||||||
/** The user chosen subtitle track from the server */
|
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
|
||||||
(sub) => sub.Index === subtitleIndex,
|
|
||||||
);
|
|
||||||
/** The user chosen audio track from the server */
|
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
|
||||||
/** Whether the stream we're playing is not transcoding*/
|
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
|
||||||
/** The initial options to pass to the VLC Player */
|
|
||||||
const initOptions = [``];
|
|
||||||
if (
|
|
||||||
chosenSubtitleTrack &&
|
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
|
||||||
) {
|
|
||||||
// If not transcoding, we can the index as normal.
|
|
||||||
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
|
|
||||||
const finalIndex = notTranscoding
|
|
||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
|
||||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
|
||||||
|
|
||||||
// Add VLC subtitle styling options from settings
|
|
||||||
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
|
|
||||||
const backgroundColor = (settings.vlcBackgroundColor ??
|
|
||||||
"Black") as VLCColor;
|
|
||||||
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
|
|
||||||
const outlineThickness = (settings.vlcOutlineThickness ??
|
|
||||||
"Normal") as OutlineThickness;
|
|
||||||
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
|
|
||||||
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
|
|
||||||
const isBold = settings.vlcIsBold ?? false;
|
|
||||||
// Add subtitle styling options
|
|
||||||
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
|
|
||||||
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
|
|
||||||
initOptions.push(
|
|
||||||
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
|
|
||||||
);
|
|
||||||
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
|
|
||||||
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
|
|
||||||
initOptions.push(
|
|
||||||
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
|
|
||||||
);
|
|
||||||
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
|
|
||||||
initOptions.push("--sub-margin=40");
|
|
||||||
if (isBold) {
|
|
||||||
initOptions.push("--freetype-bold");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (notTranscoding && chosenAudioTrack) {
|
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
// Add useEffect to handle mounting
|
// Add useEffect to handle mounting
|
||||||
@@ -607,6 +586,7 @@ export default function page() {
|
|||||||
const startPictureInPicture = useCallback(async () => {
|
const startPictureInPicture = useCallback(async () => {
|
||||||
return videoRef.current?.startPictureInPicture?.();
|
return videoRef.current?.startPictureInPicture?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const play = useCallback(() => {
|
const play = useCallback(() => {
|
||||||
videoRef.current?.play?.();
|
videoRef.current?.play?.();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -616,46 +596,40 @@ export default function page() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seek = useCallback((position: number) => {
|
const seek = useCallback((position: number) => {
|
||||||
videoRef.current?.seekTo?.(position);
|
// MPV expects seconds, convert from ms
|
||||||
}, []);
|
videoRef.current?.seekTo?.(position / 1000);
|
||||||
const getAudioTracks = useCallback(async () => {
|
|
||||||
return videoRef.current?.getAudioTracks?.() || null;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getSubtitleTracks = useCallback(async () => {
|
// Apply MPV subtitle settings when video loads
|
||||||
return videoRef.current?.getSubtitleTracks?.() || null;
|
useEffect(() => {
|
||||||
}, []);
|
if (!isVideoLoaded || !videoRef.current) return;
|
||||||
|
|
||||||
const setSubtitleTrack = useCallback((index: number) => {
|
const applySubtitleSettings = async () => {
|
||||||
videoRef.current?.setSubtitleTrack?.(index);
|
if (settings.mpvSubtitleScale !== undefined) {
|
||||||
}, []);
|
await videoRef.current?.setSubtitleScale(settings.mpvSubtitleScale);
|
||||||
|
}
|
||||||
|
if (settings.mpvSubtitleMarginY !== undefined) {
|
||||||
|
await videoRef.current?.setSubtitleMarginY(settings.mpvSubtitleMarginY);
|
||||||
|
}
|
||||||
|
if (settings.mpvSubtitleAlignX !== undefined) {
|
||||||
|
await videoRef.current?.setSubtitleAlignX(settings.mpvSubtitleAlignX);
|
||||||
|
}
|
||||||
|
if (settings.mpvSubtitleAlignY !== undefined) {
|
||||||
|
await videoRef.current?.setSubtitleAlignY(settings.mpvSubtitleAlignY);
|
||||||
|
}
|
||||||
|
if (settings.mpvSubtitleFontSize !== undefined) {
|
||||||
|
await videoRef.current?.setSubtitleFontSize(
|
||||||
|
settings.mpvSubtitleFontSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Apply subtitle size from general settings
|
||||||
|
if (settings.subtitleSize) {
|
||||||
|
await videoRef.current?.setSubtitleFontSize(settings.subtitleSize);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
|
applySubtitleSettings();
|
||||||
// Note: VlcPlayer type only expects url parameter
|
}, [isVideoLoaded, settings]);
|
||||||
videoRef.current?.setSubtitleURL?.(url);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setAudioTrack = useCallback((index: number) => {
|
|
||||||
videoRef.current?.setAudioTrack?.(index);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setVideoAspectRatio = useCallback(
|
|
||||||
async (aspectRatio: string | null) => {
|
|
||||||
return (
|
|
||||||
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
|
|
||||||
Promise.resolve()
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
|
|
||||||
return (
|
|
||||||
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
console.log("Debug: component render"); // Uncomment to debug re-renders
|
|
||||||
|
|
||||||
// Show error UI first, before checking loading/missing‐data
|
// Show error UI first, before checking loading/missing‐data
|
||||||
if (itemStatus.isError || streamStatus.isError) {
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
@@ -666,7 +640,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then show loader while either side is still fetching or data isn’t present
|
// Then show loader while either side is still fetching or data isn't present
|
||||||
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||||
// …loader UI…
|
// …loader UI…
|
||||||
return (
|
return (
|
||||||
@@ -684,86 +658,80 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlayerProvider
|
||||||
style={{
|
playerRef={videoRef}
|
||||||
flex: 1,
|
item={item}
|
||||||
backgroundColor: "black",
|
mediaSource={stream?.mediaSource}
|
||||||
height: "100%",
|
isVideoLoaded={isVideoLoaded}
|
||||||
width: "100%",
|
tracksReady={tracksReady}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View
|
<VideoProvider>
|
||||||
style={{
|
<View
|
||||||
display: "flex",
|
style={{
|
||||||
width: "100%",
|
flex: 1,
|
||||||
height: "100%",
|
backgroundColor: "black",
|
||||||
position: "relative",
|
height: "100%",
|
||||||
flexDirection: "column",
|
width: "100%",
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VlcPlayerView
|
|
||||||
ref={videoRef}
|
|
||||||
source={{
|
|
||||||
uri: stream?.url || "",
|
|
||||||
autoplay: true,
|
|
||||||
isNetwork: !offline,
|
|
||||||
startPosition,
|
|
||||||
externalSubtitles,
|
|
||||||
initOptions,
|
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
>
|
||||||
onVideoProgress={onProgress}
|
<View
|
||||||
progressUpdateInterval={1000}
|
style={{
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
display: "flex",
|
||||||
onVideoLoadEnd={() => {
|
width: "100%",
|
||||||
setIsVideoLoaded(true);
|
height: "100%",
|
||||||
}}
|
position: "relative",
|
||||||
onVideoError={(e) => {
|
flexDirection: "column",
|
||||||
console.error("Video Error:", e.nativeEvent);
|
justifyContent: "center",
|
||||||
Alert.alert(
|
}}
|
||||||
t("player.error"),
|
>
|
||||||
t("player.an_error_occured_while_playing_the_video"),
|
<MpvPlayerView
|
||||||
);
|
ref={videoRef}
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
source={videoSource}
|
||||||
}}
|
style={{ width: "100%", height: "100%" }}
|
||||||
/>
|
onProgress={onProgress}
|
||||||
</View>
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
{isMounted === true && item && (
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
<Controls
|
onError={(e) => {
|
||||||
mediaSource={stream?.mediaSource}
|
console.error("Video Error:", e.nativeEvent);
|
||||||
item={item}
|
Alert.alert(
|
||||||
videoRef={videoRef}
|
t("player.error"),
|
||||||
togglePlay={togglePlay}
|
t("player.an_error_occured_while_playing_the_video"),
|
||||||
isPlaying={isPlaying}
|
);
|
||||||
isSeeking={isSeeking}
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
progress={progress}
|
}}
|
||||||
cacheProgress={cacheProgress}
|
onTracksReady={() => {
|
||||||
isBuffering={isBuffering}
|
setTracksReady(true);
|
||||||
showControls={showControls}
|
}}
|
||||||
setShowControls={setShowControls}
|
/>
|
||||||
isVideoLoaded={isVideoLoaded}
|
</View>
|
||||||
startPictureInPicture={startPictureInPicture}
|
{isMounted === true && item && !isPipMode && (
|
||||||
play={play}
|
<Controls
|
||||||
pause={pause}
|
mediaSource={stream?.mediaSource}
|
||||||
seek={seek}
|
item={item}
|
||||||
enableTrickplay={true}
|
togglePlay={togglePlay}
|
||||||
getAudioTracks={getAudioTracks}
|
isPlaying={isPlaying}
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
isSeeking={isSeeking}
|
||||||
offline={offline}
|
progress={progress}
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
cacheProgress={cacheProgress}
|
||||||
setSubtitleURL={setSubtitleURL}
|
isBuffering={isBuffering}
|
||||||
setAudioTrack={setAudioTrack}
|
showControls={showControls}
|
||||||
setVideoAspectRatio={setVideoAspectRatio}
|
setShowControls={setShowControls}
|
||||||
setVideoScaleFactor={setVideoScaleFactor}
|
startPictureInPicture={startPictureInPicture}
|
||||||
aspectRatio={aspectRatio}
|
play={play}
|
||||||
scaleFactor={scaleFactor}
|
pause={pause}
|
||||||
setAspectRatio={setAspectRatio}
|
seek={seek}
|
||||||
setScaleFactor={setScaleFactor}
|
enableTrickplay={true}
|
||||||
isVlc
|
offline={offline}
|
||||||
downloadedFiles={downloadedFiles}
|
aspectRatio={aspectRatio}
|
||||||
/>
|
scaleFactor={scaleFactor}
|
||||||
)}
|
setAspectRatio={setAspectRatio}
|
||||||
</View>
|
setScaleFactor={setScaleFactor}
|
||||||
|
api={api}
|
||||||
|
downloadedFiles={downloadedFiles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</VideoProvider>
|
||||||
|
</PlayerProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
266
app/_layout.tsx
266
app/_layout.tsx
@@ -1,18 +1,24 @@
|
|||||||
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 { type Settings, useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
BACKGROUND_FETCH_TASK_SESSIONS,
|
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||||
@@ -26,44 +32,29 @@ import {
|
|||||||
} from "@/utils/log";
|
} from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
const BackGroundDownloader = !Platform.isTV
|
|
||||||
? require("@kesha-antonov/react-native-background-downloader")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import * as BackgroundTask from "expo-background-task";
|
|
||||||
|
|
||||||
import * as Device from "expo-device";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
|
|
||||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
|
|
||||||
import { getLocales } from "expo-localization";
|
|
||||||
import { router, Stack, useSegments } from "expo-router";
|
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
|
||||||
|
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { I18nextProvider } from "react-i18next";
|
|
||||||
import { Appearance, AppState } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import "react-native-reanimated";
|
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import { getLocales } from "expo-localization";
|
||||||
import type { EventSubscription } from "expo-modules-core";
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
import 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 { useAtom } from "jotai";
|
import { router, Stack, useSegments } from "expo-router";
|
||||||
import { Toaster } from "sonner-native";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import * as TaskManager from "expo-task-manager";
|
||||||
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
import { Appearance } from "react-native";
|
||||||
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
|
import "react-native-reanimated";
|
||||||
|
import { Toaster } from "sonner-native";
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
@@ -131,24 +122,7 @@ if (!Platform.isTV) {
|
|||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
console.log("TaskManager ~ trigger");
|
console.log("TaskManager ~ trigger");
|
||||||
|
// Background fetch task placeholder - currently unused
|
||||||
const settingsData = storage.getString("settings");
|
|
||||||
|
|
||||||
if (!settingsData) return BackgroundTask.BackgroundTaskResult.Failed;
|
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
|
||||||
|
|
||||||
if (!settings?.autoDownload)
|
|
||||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
|
||||||
const deviceId = getOrSetDeviceId();
|
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
|
||||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
|
||||||
return BackgroundTask.BackgroundTaskResult.Success;
|
return BackgroundTask.BackgroundTaskResult.Success;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -213,11 +187,7 @@ export default function RootLayout() {
|
|||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 0,
|
staleTime: 30000,
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
retryOnMount: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -226,8 +196,7 @@ function Layout() {
|
|||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const appState = useRef(AppState.currentState);
|
const _segments = useSegments();
|
||||||
const segments = useSegments();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
@@ -256,7 +225,7 @@ function Layout() {
|
|||||||
} else console.log("No token available");
|
} else console.log("No token available");
|
||||||
}, [api, expoPushToken, user]);
|
}, [api, expoPushToken, user]);
|
||||||
|
|
||||||
async function registerNotifications() {
|
const registerNotifications = useCallback(async () => {
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
console.log("Setting android notification channel 'default'");
|
console.log("Setting android notification channel 'default'");
|
||||||
await Notifications?.setNotificationChannelAsync("default", {
|
await Notifications?.setNotificationChannelAsync("default", {
|
||||||
@@ -287,11 +256,21 @@ 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({
|
||||||
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
projectId: "e79219d1-797f-4fbe-9fa1-cfd360690a68",
|
||||||
.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) {
|
||||||
@@ -355,119 +334,70 @@ function Layout() {
|
|||||||
responseListener.current?.remove();
|
responseListener.current?.remove();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [user, api]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segments.includes("direct-player" as never)) {
|
|
||||||
if (
|
|
||||||
!settings.followDeviceOrientation &&
|
|
||||||
settings.defaultVideoOrientation
|
|
||||||
) {
|
|
||||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.followDeviceOrientation === true) {
|
|
||||||
ScreenOrientation.unlockAsync();
|
|
||||||
} else {
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
settings.followDeviceOrientation,
|
|
||||||
settings.defaultVideoOrientation,
|
|
||||||
segments,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
|
||||||
if (
|
|
||||||
appState.current.match(/inactive|background/) &&
|
|
||||||
nextAppState === "active"
|
|
||||||
) {
|
|
||||||
BackGroundDownloader.checkForExistingDownloads().catch(
|
|
||||||
(error: unknown) => {
|
|
||||||
writeErrorLog("Failed to resume background downloads", error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
|
|
||||||
writeErrorLog("Failed to resume background downloads", error);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<PlaySettingsProvider>
|
<NetworkStatusProvider>
|
||||||
<LogProvider>
|
<PlaySettingsProvider>
|
||||||
<WebSocketProvider>
|
<LogProvider>
|
||||||
<DownloadProvider>
|
<WebSocketProvider>
|
||||||
<BottomSheetModalProvider>
|
<DownloadProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<GlobalModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<BottomSheetModalProvider>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack.Screen
|
<SystemBars style='light' hidden={false} />
|
||||||
name='(auth)/(tabs)'
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name='(auth)/(tabs)'
|
||||||
title: "",
|
options={{
|
||||||
header: () => null,
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
header: () => null,
|
||||||
<Stack.Screen
|
}}
|
||||||
name='(auth)/player'
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name='(auth)/player'
|
||||||
title: "",
|
options={{
|
||||||
header: () => null,
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
header: () => null,
|
||||||
<Stack.Screen
|
}}
|
||||||
name='login'
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: true,
|
name='login'
|
||||||
title: "",
|
options={{
|
||||||
headerTransparent: true,
|
headerShown: true,
|
||||||
}}
|
title: "",
|
||||||
/>
|
headerTransparent: Platform.OS === "ios",
|
||||||
<Stack.Screen name='+not-found' />
|
}}
|
||||||
</Stack>
|
/>
|
||||||
<Toaster
|
<Stack.Screen name='+not-found' />
|
||||||
duration={4000}
|
</Stack>
|
||||||
toastOptions={{
|
<Toaster
|
||||||
style: {
|
duration={4000}
|
||||||
backgroundColor: "#262626",
|
toastOptions={{
|
||||||
borderColor: "#363639",
|
style: {
|
||||||
borderWidth: 1,
|
backgroundColor: "#262626",
|
||||||
},
|
borderColor: "#363639",
|
||||||
titleStyle: {
|
borderWidth: 1,
|
||||||
color: "white",
|
},
|
||||||
},
|
titleStyle: {
|
||||||
}}
|
color: "white",
|
||||||
closeButton
|
},
|
||||||
/>
|
}}
|
||||||
</ThemeProvider>
|
closeButton
|
||||||
</BottomSheetModalProvider>
|
/>
|
||||||
</DownloadProvider>
|
<GlobalModal />
|
||||||
</WebSocketProvider>
|
</ThemeProvider>
|
||||||
</LogProvider>
|
</BottomSheetModalProvider>
|
||||||
</PlaySettingsProvider>
|
</GlobalModalProvider>
|
||||||
|
</DownloadProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</NetworkStatusProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,17 +4,16 @@ import { Image } from "expo-image";
|
|||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
@@ -43,14 +42,14 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
|
||||||
const [serverName, setServerName] = useState<string>("");
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
}>({
|
}>({
|
||||||
username: _username,
|
username: _username || "",
|
||||||
password: _password,
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,12 +62,13 @@ const Login: React.FC = () => {
|
|||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for server setup and state updates to complete
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (_username && _password) {
|
if (_username && _password) {
|
||||||
setCredentials({ username: _username, password: _password });
|
setCredentials({ username: _username, password: _password });
|
||||||
login(_username, _password);
|
login(_username, _password);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 0);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
@@ -82,10 +82,10 @@ const Login: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
}}
|
}}
|
||||||
className='flex flex-row items-center'
|
className='flex flex-row items-center pr-2 pl-1'
|
||||||
>
|
>
|
||||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||||
<Text className='ml-2 text-purple-600'>
|
<Text className=' ml-1 text-purple-600'>
|
||||||
{t("login.change_server")}
|
{t("login.change_server")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -264,6 +264,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text: string) =>
|
onChangeText={(text: string) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.username) {
|
||||||
|
setCredentials({ ...credentials, username: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
@@ -272,6 +278,8 @@ const Login: React.FC = () => {
|
|||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
extraClassName='mb-4'
|
extraClassName='mb-4'
|
||||||
|
autoFocus={false}
|
||||||
|
blurOnSubmit={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
@@ -280,6 +288,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text: string) =>
|
onChangeText={(text: string) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials({ ...credentials, password: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.password) {
|
||||||
|
setCredentials({ ...credentials, password: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
@@ -289,10 +303,17 @@ const Login: React.FC = () => {
|
|||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
extraClassName='mb-4'
|
extraClassName='mb-4'
|
||||||
|
autoFocus={false}
|
||||||
|
blurOnSubmit={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View className='mt-4'>
|
<View className='mt-4'>
|
||||||
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={!credentials.username.trim()}
|
||||||
|
>
|
||||||
|
{t("login.login_button")}
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<View className='mt-3'>
|
<View className='mt-3'>
|
||||||
<Button
|
<Button
|
||||||
@@ -334,6 +355,8 @@ const Login: React.FC = () => {
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
|
autoFocus={false}
|
||||||
|
blurOnSubmit={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Full-width primary button */}
|
{/* Full-width primary button */}
|
||||||
@@ -371,10 +394,11 @@ const Login: React.FC = () => {
|
|||||||
// Mobile layout
|
// Mobile layout
|
||||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
{api?.basePath ? (
|
{api?.basePath ? (
|
||||||
<View className='flex flex-col h-full relative items-center justify-center'>
|
<View className='flex flex-col flex-1 items-center justify-center'>
|
||||||
<View className='px-4 -mt-20 w-full'>
|
<View className='px-4 -mt-20 w-full'>
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<Text className='text-2xl font-bold -mb-2'>
|
<Text className='text-2xl font-bold -mb-2'>
|
||||||
@@ -393,6 +417,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.username) {
|
||||||
|
setCredentials({ ...credentials, username: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
@@ -409,6 +439,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials({ ...credentials, password: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.password) {
|
||||||
|
setCredentials({ ...credentials, password: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
@@ -422,6 +458,7 @@ const Login: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
disabled={!credentials.username.trim()}
|
||||||
className='flex-1 mr-2'
|
className='flex-1 mr-2'
|
||||||
>
|
>
|
||||||
{t("login.login_button")}
|
{t("login.login_button")}
|
||||||
@@ -443,7 +480,7 @@ const Login: React.FC = () => {
|
|||||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col h-full items-center justify-center w-full'>
|
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
||||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||||
<Image
|
<Image
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path88" d="M3547.01,1831.49C3493.38,1822.66 3262.53,1779.28 2992.01,1820.24C2424.16,1906.21 2154.85,2275.8 1882,2420.24C1473.31,2636.6 1060.97,2644.95 832,2592.03L832,1445.92C832,1321.76 863.078,1198.06 925.307,1090.27C1057.09,862.011 1323.38,718.405 1586.6,736.145C1695.48,743.482 1801.3,777.735 1895.64,832.199L3357.51,1676.21C3424.47,1714.87 3482.92,1761.76 3532.01,1815.41L3547.01,1831.49Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(149,41,235);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(98,22,247);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path66" d="M3357.51,2903.64L1895.64,3747.65C1670.29,3877.76 1412.33,3877.76 1186.98,3747.65C961.629,3617.55 832.648,3394.14 832.648,3133.93L832.648,1445.92C832.648,1185.71 961.629,962.305 1186.98,832.199C1412.33,702.094 1670.29,702.094 1895.64,832.199L3357.51,1676.21C3582.86,1806.31 3711.84,2029.71 3711.84,2289.93C3711.84,2550.14 3582.86,2773.54 3357.51,2903.64ZM1721.48,3213.68L3098.31,2454.7C3163.9,2418.55 3193.45,2364.85 3193.45,2289.93C3193.45,2215 3163.93,2161.32 3098.31,2125.15L1721.48,1366.18C1655.87,1330.01 1596.09,1328.72 1531.21,1366.18C1466.34,1403.63 1436.08,1456.03 1436.08,1530.96L1436.08,3048.89C1436.08,3123.77 1466.35,3176.23 1531.21,3213.68C1596.08,3251.11 1655.89,3249.83 1721.48,3213.68" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(188,74,241);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(227,105,219);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g>
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path88" d="M0,319.909L0,234C17.667,234.844 138.649,236.708 195,190C220.441,168.912 271.21,169.515 294.001,178.788C332.576,194.487 378.643,259.549 360,270.644C353.455,277.797 345.662,284.049 336.734,289.204L141.818,401.738C129.24,409 115.13,413.567 100.613,414.546C65.517,416.911 30.012,397.763 12.441,367.329C4.144,352.957 0,336.464 0,319.909Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(225,102,222);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(204,88,233);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path28" d="M1427.29,1523.37C1427.29,1447.7 1457.85,1394.77 1523.38,1356.94C1588.91,1319.11 1649.28,1320.41 1715.55,1356.94L3106.14,2123.5C3172.42,2160.03 3202.24,2214.25 3202.24,2289.93C3202.24,2365.6 3172.39,2419.83 3106.14,2456.35L1715.55,3222.91C1649.31,3259.43 1588.89,3260.73 1523.38,3222.91C1457.87,3185.1 1427.29,3132.11 1427.29,3056.48L1427.29,1523.37" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.17673e-13,-1921.74,1921.74,1.17673e-13,2314.76,3250.79)"><stop offset="0" style="stop-color:rgb(93,17,250);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(143,40,236);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
184
assets/images/icon-ios-liquid-glass.icon/icon.json
Normal file
184
assets/images/icon-ios-liquid-glass.icon/icon.json
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
{
|
||||||
|
"fill": {
|
||||||
|
"solid": "display-p3:0.18039,0.18039,0.18039,1.00000"
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"blur-material": 0.3,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"fill-specializations": [
|
||||||
|
{
|
||||||
|
"value": "none"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"automatic-gradient": "display-p3:0.76482,0.76482,0.76482,0.84903"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"glass": true,
|
||||||
|
"hidden": false,
|
||||||
|
"image-name": "streamyfin_logo_layer1.svg",
|
||||||
|
"name": "streamyfin_logo_layer1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"opacity": 1,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, 0]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "none",
|
||||||
|
"opacity": 1
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"blur-material": 0.8,
|
||||||
|
"hidden": false,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"fill-specializations": [
|
||||||
|
{
|
||||||
|
"value": "none"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"automatic-gradient": "gray:0.75000,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden": false,
|
||||||
|
"image-name": "streamyfin_logo_layer2.svg",
|
||||||
|
"name": "streamyfin_logo_layer2",
|
||||||
|
"opacity": 1,
|
||||||
|
"position": {
|
||||||
|
"scale": 1,
|
||||||
|
"translation-in-points": [0, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting": "individual",
|
||||||
|
"name": "Group",
|
||||||
|
"opacity": 1,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, -0.01613253252572302]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "layer-color",
|
||||||
|
"opacity": 0.35
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency-specializations": [
|
||||||
|
{
|
||||||
|
"value": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"blur-material": 0.5,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"fill-specializations": [
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"automatic-gradient": "gray:0.29000,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"glass": true,
|
||||||
|
"hidden": false,
|
||||||
|
"image-name": "streamyfin_logo_layer3.svg",
|
||||||
|
"name": "streamyfin_logo_layer3",
|
||||||
|
"opacity": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Group",
|
||||||
|
"opacity": 0.8,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, 0]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "none",
|
||||||
|
"opacity": 0.5
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blur-material": 0.5,
|
||||||
|
"hidden": false,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"glass": true,
|
||||||
|
"hidden-specializations": [
|
||||||
|
{
|
||||||
|
"value": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"image-name": "streamyfin_logo_layer4.svg",
|
||||||
|
"name": "streamyfin_logo_layer4",
|
||||||
|
"opacity-specializations": [
|
||||||
|
{
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting": "combined",
|
||||||
|
"name": "Group",
|
||||||
|
"opacity": 0.9,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, 0]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "neutral",
|
||||||
|
"opacity": 0.5
|
||||||
|
},
|
||||||
|
"specular": false,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms": {
|
||||||
|
"circles": ["watchOS"],
|
||||||
|
"squares": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MMKV } from "react-native-mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
declare module "react-native-mmkv" {
|
declare module "react-native-mmkv" {
|
||||||
interface MMKV {
|
interface MMKV {
|
||||||
@@ -9,7 +9,7 @@ declare module "react-native-mmkv" {
|
|||||||
|
|
||||||
// Add the augmentation methods directly to the MMKV prototype
|
// Add the augmentation methods directly to the MMKV prototype
|
||||||
// This follows the recommended pattern while adding the helper methods your app uses
|
// This follows the recommended pattern while adding the helper methods your app uses
|
||||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
(storage as any).get = function <T>(key: string): T | undefined {
|
||||||
try {
|
try {
|
||||||
const serializedItem = this.getString(key);
|
const serializedItem = this.getString(key);
|
||||||
if (!serializedItem) return undefined;
|
if (!serializedItem) return undefined;
|
||||||
@@ -20,10 +20,10 @@ MMKV.prototype.get = function <T>(key: string): T | undefined {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
(storage as any).setAny = function (key: string, value: any | undefined): void {
|
||||||
try {
|
try {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
this.delete(key);
|
this.remove(key);
|
||||||
} else {
|
} else {
|
||||||
this.set(key, JSON.stringify(value));
|
this.set(key, JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ module.exports = (api) => {
|
|||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ["babel-preset-expo"],
|
presets: ["babel-preset-expo"],
|
||||||
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
plugins: ["nativewind/babel", "react-native-worklets/plugin"],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.2.7/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.5/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 { Platform, View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { RoundButton } from "@/components/RoundButton";
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
|
||||||
@@ -11,24 +11,11 @@ interface Props extends ViewProps {
|
|||||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton
|
|
||||||
size='large'
|
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
|
||||||
onPress={toggleFavorite}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size='large'
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
icon={isFavorite ? "heart" : "heart-outline"}
|
||||||
fillColor={isFavorite ? "primary" : undefined}
|
|
||||||
onPress={toggleFavorite}
|
onPress={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -20,6 +18,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
@@ -31,55 +31,58 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
[audioStreams, selected],
|
[audioStreams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
audioStreams?.map((audio, idx) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: audio.DisplayTitle || `Audio Stream ${idx + 1}`,
|
||||||
|
value: audio.Index ?? idx,
|
||||||
|
selected: audio.Index === selected,
|
||||||
|
onPress: () => {
|
||||||
|
if (audio.Index !== null && audio.Index !== undefined) {
|
||||||
|
onChange(audio.Index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[audioStreams, selected, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionSelect = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.audio")}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>{selectedAudioSteam?.DisplayTitle}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 50,
|
title={t("item_card.audio")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<View className='flex flex-col' {...props}>
|
/>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.audio")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
|
||||||
<Text className='' numberOfLines={1}>
|
|
||||||
{selectedAudioSteam?.DisplayTitle}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
|
|
||||||
{audioStreams?.map((audio, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
if (audio.Index !== null && audio.Index !== undefined)
|
|
||||||
onChange(audio.Index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{audio.DisplayTitle}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -61,6 +59,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
@@ -76,53 +76,59 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
}, [inverted]);
|
}, [inverted]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: sorted.map((bitrate) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
|
selected: bitrate.value === selected?.value,
|
||||||
|
onPress: () => onChange(bitrate),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[sorted, selected, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionSelect = (optionId: string) => {
|
||||||
|
const selectedBitrate = sorted.find((b) => b.key === optionId);
|
||||||
|
if (selectedBitrate) {
|
||||||
|
onChange(selectedBitrate);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.quality")}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 60,
|
title={t("item_card.quality")}
|
||||||
maxWidth: 200,
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<View className='flex flex-col' {...props}>
|
/>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.quality")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={false}
|
|
||||||
side='bottom'
|
|
||||||
align='center'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
|
||||||
{sorted.map((b) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={b.key}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(b);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type React from "react";
|
|||||||
import {
|
import {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -18,6 +17,58 @@ import {
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
|
const getColorClasses = (
|
||||||
|
color: "purple" | "red" | "black" | "transparent" | "white",
|
||||||
|
variant: "solid" | "border",
|
||||||
|
focused: boolean,
|
||||||
|
): string => {
|
||||||
|
if (variant === "border") {
|
||||||
|
switch (color) {
|
||||||
|
case "purple":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-purple-400"
|
||||||
|
: "bg-transparent border-2 border-purple-600";
|
||||||
|
case "red":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-red-400"
|
||||||
|
: "bg-transparent border-2 border-red-600";
|
||||||
|
case "black":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-neutral-700"
|
||||||
|
: "bg-transparent border-2 border-neutral-900";
|
||||||
|
case "white":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-gray-100"
|
||||||
|
: "bg-transparent border-2 border-white";
|
||||||
|
case "transparent":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-gray-400"
|
||||||
|
: "bg-transparent border-2 border-gray-600";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (color) {
|
||||||
|
case "purple":
|
||||||
|
return focused
|
||||||
|
? "bg-purple-500 border-2 border-white"
|
||||||
|
: "bg-purple-600 border border-purple-700";
|
||||||
|
case "red":
|
||||||
|
return "bg-red-600";
|
||||||
|
case "black":
|
||||||
|
return "bg-neutral-900";
|
||||||
|
case "white":
|
||||||
|
return focused
|
||||||
|
? "bg-gray-100 border-2 border-gray-300"
|
||||||
|
: "bg-white border border-gray-200";
|
||||||
|
case "transparent":
|
||||||
|
return "bg-transparent";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
@@ -26,7 +77,8 @@ export interface ButtonProps
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: string | ReactNode;
|
children?: string | ReactNode;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
color?: "purple" | "red" | "black" | "transparent";
|
color?: "purple" | "red" | "black" | "transparent" | "white";
|
||||||
|
variant?: "solid" | "border";
|
||||||
iconRight?: ReactNode;
|
iconRight?: ReactNode;
|
||||||
iconLeft?: ReactNode;
|
iconLeft?: ReactNode;
|
||||||
justify?: "center" | "between";
|
justify?: "center" | "between";
|
||||||
@@ -39,6 +91,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
color = "purple",
|
color = "purple",
|
||||||
|
variant = "solid",
|
||||||
iconRight,
|
iconRight,
|
||||||
iconLeft,
|
iconLeft,
|
||||||
children,
|
children,
|
||||||
@@ -56,23 +109,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
const colorClasses = useMemo(() => {
|
const colorClasses = getColorClasses(color, variant, focused);
|
||||||
switch (color) {
|
|
||||||
case "purple":
|
|
||||||
return focused
|
|
||||||
? "bg-purple-500 border-2 border-white"
|
|
||||||
: "bg-purple-600 border border-purple-700";
|
|
||||||
case "red":
|
|
||||||
return "bg-red-600";
|
|
||||||
case "black":
|
|
||||||
return "bg-neutral-900";
|
|
||||||
case "transparent":
|
|
||||||
return "bg-transparent";
|
|
||||||
}
|
|
||||||
}, [color, focused]);
|
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const textColorClass =
|
||||||
|
color === "white" && variant === "solid" ? "text-black" : "text-white";
|
||||||
|
|
||||||
return Platform.isTV ? (
|
return Platform.isTV ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
className='w-full'
|
className='w-full'
|
||||||
@@ -98,10 +141,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`rounded-2xl py-5 items-center justify-center
|
className={`rounded-2xl py-5 items-center justify-center
|
||||||
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
|
${colorClasses}
|
||||||
${className}`}
|
${className}`}
|
||||||
>
|
>
|
||||||
<Text className='text-white text-xl font-bold'>{children}</Text>
|
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -135,7 +180,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
text-white font-bold text-base
|
${textColorClass} font-bold text-base
|
||||||
${disabled ? "text-gray-300" : ""}
|
${disabled ? "text-gray-300" : ""}
|
||||||
${textClassName}
|
${textClassName}
|
||||||
${iconRight ? "mr-2" : ""}
|
${iconRight ? "mr-2" : ""}
|
||||||
|
|||||||
@@ -64,9 +64,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||||
|
|
||||||
const { processes, startBackgroundDownload, getDownloadedItems } =
|
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
|
||||||
useDownload();
|
const downloadedFiles = downloadedItems;
|
||||||
const downloadedFiles = getDownloadedItems();
|
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
@@ -90,11 +89,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
const handleSheetChanges = useCallback((_index: number) => {
|
||||||
// Ensure modal is fully dismissed when index is -1
|
// Modal state tracking handled by BottomSheetModal
|
||||||
if (index === -1) {
|
|
||||||
// Modal is fully closed
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
@@ -136,13 +132,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
return itemsNotDownloaded.length === 0;
|
return itemsNotDownloaded.length === 0;
|
||||||
}, [items, itemsNotDownloaded]);
|
}, [items, itemsNotDownloaded]);
|
||||||
const itemsProcesses = useMemo(
|
const itemsProcesses = useMemo(
|
||||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
() =>
|
||||||
|
processes?.filter((p) => p?.item?.Id && itemIds.includes(p.item.Id)) ||
|
||||||
|
[],
|
||||||
[processes, itemIds],
|
[processes, itemIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
if (itemIds.length === 1)
|
if (itemIds.length === 1)
|
||||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
return itemsProcesses.reduce((acc, p) => acc + (p.progress || 0), 0);
|
||||||
return (
|
return (
|
||||||
((itemIds.length -
|
((itemIds.length -
|
||||||
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
||||||
@@ -157,6 +155,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
|
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
|
||||||
);
|
);
|
||||||
}, [queue, itemsNotDownloaded]);
|
}, [queue, itemsNotDownloaded]);
|
||||||
|
|
||||||
|
const itemsInProgressOrQueued = useMemo(() => {
|
||||||
|
const inProgress = itemsProcesses.length;
|
||||||
|
const inQueue = queue.filter((q) => itemIds.includes(q.item.Id)).length;
|
||||||
|
return inProgress + inQueue;
|
||||||
|
}, [itemsProcesses, queue, itemIds]);
|
||||||
|
|
||||||
const navigateToDownloads = () => router.push("/downloads");
|
const navigateToDownloads = () => router.push("/downloads");
|
||||||
|
|
||||||
const onDownloadedPress = () => {
|
const onDownloadedPress = () => {
|
||||||
@@ -256,13 +261,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
throw new Error("No item id");
|
throw new Error("No item id");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure modal is dismissed before starting download
|
closeModal();
|
||||||
await closeModal();
|
|
||||||
|
|
||||||
// Small delay to ensure modal is fully dismissed
|
// Wait for modal dismiss animation to complete
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
initiateDownload(...itemsToDownload);
|
initiateDownload(...itemsToDownload);
|
||||||
}, 100);
|
}, 300);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||||
@@ -282,7 +286,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderButtonContent = () => {
|
const renderButtonContent = () => {
|
||||||
if (processes.length > 0 && itemsProcesses.length > 0) {
|
// For single item downloads, show progress if item is being processed
|
||||||
|
// For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued
|
||||||
|
const shouldShowProgress =
|
||||||
|
itemIds.length === 1
|
||||||
|
? itemsProcesses.length > 0
|
||||||
|
: itemsInProgressOrQueued > 1;
|
||||||
|
|
||||||
|
if (processes.length > 0 && shouldShowProgress) {
|
||||||
return progress === 0 ? (
|
return progress === 0 ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
@@ -336,9 +347,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
backgroundColor: "#171717",
|
backgroundColor: "#171717",
|
||||||
}}
|
}}
|
||||||
onChange={handleSheetChanges}
|
onChange={handleSheetChanges}
|
||||||
onDismiss={() => {
|
|
||||||
// Ensure any pending state is cleared when modal is dismissed
|
|
||||||
}}
|
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
enableDismissOnClose
|
enableDismissOnClose
|
||||||
@@ -359,16 +367,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 w-full items-start'>
|
<View className='flex flex-col space-y-2 w-full'>
|
||||||
<BitrateSelector
|
<View className='items-start'>
|
||||||
inverted
|
<BitrateSelector
|
||||||
onChange={(val) =>
|
inverted
|
||||||
setSelectedOptions(
|
onChange={(val) =>
|
||||||
(prev) => prev && { ...prev, bitrate: val },
|
setSelectedOptions(
|
||||||
)
|
(prev) => prev && { ...prev, bitrate: val },
|
||||||
}
|
)
|
||||||
selected={selectedOptions?.bitrate}
|
}
|
||||||
/>
|
selected={selectedOptions?.bitrate}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
{itemsNotDownloaded.length > 1 && (
|
{itemsNotDownloaded.length > 1 && (
|
||||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||||
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
||||||
@@ -380,21 +390,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
{itemsNotDownloaded.length === 1 && (
|
{itemsNotDownloaded.length === 1 && (
|
||||||
<View>
|
<View>
|
||||||
<MediaSourceSelector
|
<View className='items-start'>
|
||||||
item={items[0]}
|
<MediaSourceSelector
|
||||||
onChange={(val) =>
|
item={items[0]}
|
||||||
setSelectedOptions(
|
onChange={(val) =>
|
||||||
(prev) =>
|
setSelectedOptions(
|
||||||
prev && {
|
(prev) =>
|
||||||
...prev,
|
prev && {
|
||||||
mediaSource: val,
|
...prev,
|
||||||
},
|
mediaSource: val,
|
||||||
)
|
},
|
||||||
}
|
)
|
||||||
selected={selectedOptions?.mediaSource}
|
}
|
||||||
/>
|
selected={selectedOptions?.mediaSource}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
{selectedOptions?.mediaSource && (
|
{selectedOptions?.mediaSource && (
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
@@ -427,11 +439,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button onPress={acceptDownloadOptions} color='purple'>
|
||||||
className='mt-auto'
|
|
||||||
onPress={acceptDownloadOptions}
|
|
||||||
color='purple'
|
|
||||||
>
|
|
||||||
{t("item_card.download.download_button")}
|
{t("item_card.download.download_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
203
components/ExampleGlobalModalUsage.tsx
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Example Usage of Global Modal
|
||||||
|
*
|
||||||
|
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||||
|
* You can delete this file after understanding how it works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 1: Simple Content Modal
|
||||||
|
*/
|
||||||
|
export const SimpleModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This is a simple modal with just some text content.
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400'>
|
||||||
|
Swipe down or tap outside to close.
|
||||||
|
</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 2: Modal with Custom Snap Points
|
||||||
|
*/
|
||||||
|
export const CustomSnapPointsExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6' style={{ minHeight: 400 }}>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Custom Snap Points
|
||||||
|
</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This modal has custom snap points (25%, 50%, 90%).
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||||
|
<Text className='text-white'>
|
||||||
|
Try dragging the modal to different heights!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"],
|
||||||
|
enableDynamicSizing: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 3: Complex Component in Modal
|
||||||
|
*/
|
||||||
|
const SettingsModalContent = () => {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const settings = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Notifications",
|
||||||
|
icon: "notifications-outline" as const,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Auto-play",
|
||||||
|
icon: "play-outline" as const,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||||
|
|
||||||
|
{settings.map((setting, index) => (
|
||||||
|
<View
|
||||||
|
key={setting.id}
|
||||||
|
className={`flex-row items-center justify-between py-4 ${
|
||||||
|
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center gap-3'>
|
||||||
|
<Ionicons name={setting.icon} size={24} color='white' />
|
||||||
|
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${
|
||||||
|
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||||
|
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={hideModal}
|
||||||
|
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComplexModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(<SettingsModalContent />);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||||
|
*/
|
||||||
|
export const useShowSuccessModal = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (message: string) => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6 items-center'>
|
||||||
|
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||||
|
<Ionicons name='checkmark' size={48} color='white' />
|
||||||
|
</View>
|
||||||
|
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||||
|
<Text className='text-white text-center'>{message}</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Demo Component
|
||||||
|
*/
|
||||||
|
export const GlobalModalDemo = () => {
|
||||||
|
const showSuccess = useShowSuccessModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6 gap-4'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Global Modal Examples
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleModalExample />
|
||||||
|
<CustomSnapPointsExample />
|
||||||
|
<ComplexModalExample />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => showSuccess("Operation completed successfully!")}
|
||||||
|
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
73
components/GlobalModal.tsx
Normal file
73
components/GlobalModal.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalModal Component
|
||||||
|
*
|
||||||
|
* This component renders a global bottom sheet modal that can be controlled
|
||||||
|
* from anywhere in the app using the useGlobalModal hook.
|
||||||
|
*
|
||||||
|
* Place this component at the root level of your app (in _layout.tsx)
|
||||||
|
* after BottomSheetModalProvider.
|
||||||
|
*/
|
||||||
|
export const GlobalModal = () => {
|
||||||
|
const { hideModal, modalState, modalRef } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
hideModal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hideModal],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
enableDynamicSizing: true,
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
backgroundStyle: {
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
},
|
||||||
|
handleIndicatorStyle: {
|
||||||
|
backgroundColor: "white",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge default options with provided options
|
||||||
|
const modalOptions = { ...defaultOptions, ...modalState.options };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={modalRef}
|
||||||
|
{...(modalOptions.snapPoints
|
||||||
|
? { snapPoints: modalOptions.snapPoints }
|
||||||
|
: { enableDynamicSizing: modalOptions.enableDynamicSizing })}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
handleIndicatorStyle={modalOptions.handleIndicatorStyle}
|
||||||
|
backgroundStyle={modalOptions.backgroundStyle}
|
||||||
|
enablePanDownToClose={modalOptions.enablePanDownToClose}
|
||||||
|
enableDismissOnClose
|
||||||
|
stackBehavior='push'
|
||||||
|
style={{ zIndex: 1000 }}
|
||||||
|
>
|
||||||
|
{modalState.content}
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,12 +6,12 @@ import { Image } from "expo-image";
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { type Bitrate } from "@/components/BitrateSelector";
|
import { type Bitrate } from "@/components/BitrateSelector";
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
@@ -29,13 +29,10 @@ 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 { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
import { BitrateSheet } from "./BitRateSheet";
|
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSheet } from "./MediaSourceSheet";
|
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
import { TrackSheet } from "./TrackSheet";
|
|
||||||
|
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
@@ -59,7 +56,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const itemColors = useImageColorsReturn({ item });
|
const itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
@@ -75,7 +71,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(item!, settings);
|
} = useDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
@@ -90,7 +86,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
@@ -102,7 +98,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV && item) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item &&
|
item &&
|
||||||
@@ -143,7 +139,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [item, navigation, user]);
|
}, [item, navigation, user, item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -201,76 +197,27 @@ 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 space-y-2 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-2' />
|
<ItemHeader item={item} className='mb-2' />
|
||||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||||
<BitrateSheet
|
<PlayButton
|
||||||
className='mr-1'
|
selectedOptions={selectedOptions}
|
||||||
onChange={(val) =>
|
item={item}
|
||||||
setSelectedOptions(
|
isOffline={isOffline}
|
||||||
(prev) => prev && { ...prev, bitrate: val },
|
colors={itemColors}
|
||||||
)
|
/>
|
||||||
}
|
<View className='w-1' />
|
||||||
selected={selectedOptions.bitrate}
|
{!isOffline && (
|
||||||
/>
|
<MediaSourceButton
|
||||||
<MediaSourceSheet
|
selectedOptions={selectedOptions}
|
||||||
className='mr-1'
|
setSelectedOptions={setSelectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
onChange={(val) =>
|
colors={itemColors}
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
mediaSource: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.mediaSource}
|
|
||||||
/>
|
/>
|
||||||
<TrackSheet
|
)}
|
||||||
className='mr-1'
|
</View>
|
||||||
streamType='Audio'
|
|
||||||
title={t("item_card.audio")}
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
onChange={(val) => {
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
audioIndex: val,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
selected={selectedOptions.audioIndex}
|
|
||||||
/>
|
|
||||||
<TrackSheet
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
streamType='Subtitle'
|
|
||||||
title={t("item_card.subtitles")}
|
|
||||||
onChange={(val) =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
subtitleIndex: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.subtitleIndex}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayButton
|
|
||||||
className='grow'
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
item={item}
|
|
||||||
isOffline={isOffline}
|
|
||||||
colors={itemColors}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel
|
<SeasonEpisodesCarousel
|
||||||
item={item}
|
item={item}
|
||||||
@@ -279,9 +226,12 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline && (
|
{!isOffline &&
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
selectedOptions.mediaSource?.MediaStreams &&
|
||||||
)}
|
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||||
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
|
)}
|
||||||
|
|
||||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
|
|||||||
193
components/MediaSourceButton.tsx
Normal file
193
components/MediaSourceButton.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { BITRATES } from "./BitRateSheet";
|
||||||
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
selectedOptions: SelectedOptions;
|
||||||
|
setSelectedOptions: React.Dispatch<
|
||||||
|
React.SetStateAction<SelectedOptions | undefined>
|
||||||
|
>;
|
||||||
|
colors?: ThemeColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaSourceButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
setSelectedOptions,
|
||||||
|
colors,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const effectiveColors = colors || {
|
||||||
|
primary: "#7c3aed",
|
||||||
|
text: "#000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstMediaSource = item?.MediaSources?.[0];
|
||||||
|
if (!firstMediaSource) return;
|
||||||
|
setSelectedOptions((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
mediaSource: firstMediaSource,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [item, setSelectedOptions]);
|
||||||
|
|
||||||
|
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||||
|
if (source.Name) return source.Name;
|
||||||
|
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
||||||
|
return `Source ${source.Id}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const audioStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Audio",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitleStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Subtitle",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
|
// Bitrate group
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.quality"),
|
||||||
|
options: BITRATES.map((bitrate) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
|
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Media Source group (only if multiple sources)
|
||||||
|
if (item?.MediaSources && item.MediaSources.length > 1) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.video"),
|
||||||
|
options: item.MediaSources.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getMediaSourceDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, mediaSource: source },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track group
|
||||||
|
if (audioStreams.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.audio"),
|
||||||
|
options: audioStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.audioIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle track group (with None option)
|
||||||
|
if (subtitleStreams.length > 0) {
|
||||||
|
const noneOption = {
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("common.none"),
|
||||||
|
value: -1,
|
||||||
|
selected: selectedOptions.subtitleIndex === -1,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitleOptions = subtitleStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.subtitleIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.subtitles"),
|
||||||
|
options: [noneOption, ...subtitleOptions],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
audioStreams,
|
||||||
|
subtitleStreams,
|
||||||
|
getMediaSourceDisplayName,
|
||||||
|
t,
|
||||||
|
setSelectedOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={!item}
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
className='relative'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
|
||||||
|
className='absolute w-12 h-12 rounded-full'
|
||||||
|
/>
|
||||||
|
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
|
||||||
|
{!item ? (
|
||||||
|
<ActivityIndicator size='small' color={effectiveColors.text} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='list' size={24} color={effectiveColors.text} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={optionGroups}
|
||||||
|
trigger={trigger}
|
||||||
|
title={t("item_card.media_options")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
bottomSheetConfig={{
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,13 +2,11 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -23,7 +21,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
@@ -46,50 +44,60 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
return getDisplayName(selected);
|
return getDisplayName(selected);
|
||||||
}, [selected, getDisplayName]);
|
}, [selected, getDisplayName]);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
item.MediaSources?.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selected?.Id,
|
||||||
|
onPress: () => onChange(source),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[item.MediaSources, selected, getDisplayName, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionSelect = (optionId: string) => {
|
||||||
|
const selectedSource = item.MediaSources?.find(
|
||||||
|
(source, idx) => `${source.Id || idx}` === optionId,
|
||||||
|
);
|
||||||
|
if (selectedSource) {
|
||||||
|
onChange(selectedSource);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 50,
|
title={t("item_card.video")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<View className='flex flex-col' {...props}>
|
/>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.video")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
|
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
|
||||||
{item.MediaSources?.map((source, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(source);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{getDisplayName(source)}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
style={{
|
style={{
|
||||||
top: -50,
|
top: -50,
|
||||||
}}
|
}}
|
||||||
className='relative flex-1 bg-transparent pb-24'
|
className='relative flex-1 bg-transparent pb-4'
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
// Background Linear Gradient
|
// Background Linear Gradient
|
||||||
|
|||||||
345
components/PlatformDropdown.tsx
Normal file
345
components/PlatformDropdown.tsx
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
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, isVisible } = 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]);
|
||||||
|
|
||||||
|
// Watch for modal dismissal on Android (e.g., swipe down, backdrop tap)
|
||||||
|
// and sync the controlled open state
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
|
||||||
|
controlledOnOpenChange?.(false);
|
||||||
|
}
|
||||||
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
@@ -24,6 +25,8 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -33,10 +36,11 @@ 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 { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
@@ -51,12 +55,12 @@ 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();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -86,12 +90,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[router, isOffline],
|
[router, isOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const handleNormalPlayFlow = useCallback(async () => {
|
||||||
console.log("onPress");
|
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
@@ -165,7 +166,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
@@ -273,6 +274,117 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
|
goToPlayer,
|
||||||
|
isOffline,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onPress = useCallback(async () => {
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Check if item is downloaded
|
||||||
|
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||||
|
|
||||||
|
if (downloadedItem) {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
// Show bottom sheet for Android
|
||||||
|
showModal(
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className='px-4 mt-4 mb-12'>
|
||||||
|
<View className='pb-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-2'>
|
||||||
|
{t("player.downloaded_file_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-70 text-base'>
|
||||||
|
{t("player.downloaded_file_message")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='space-y-3'>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
hideModal();
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
goToPlayer(queryParams.toString());
|
||||||
|
}}
|
||||||
|
color='purple'
|
||||||
|
>
|
||||||
|
{Platform.OS === "android"
|
||||||
|
? "Play downloaded file"
|
||||||
|
: t("player.downloaded_file_yes")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
hideModal();
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
}}
|
||||||
|
color='white'
|
||||||
|
variant='border'
|
||||||
|
>
|
||||||
|
{Platform.OS === "android"
|
||||||
|
? "Stream file"
|
||||||
|
: t("player.downloaded_file_no")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>,
|
||||||
|
{
|
||||||
|
snapPoints: ["35%"],
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Show alert for iOS
|
||||||
|
Alert.alert(
|
||||||
|
t("player.downloaded_file_title"),
|
||||||
|
t("player.downloaded_file_message"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_yes"),
|
||||||
|
onPress: () => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
goToPlayer(queryParams.toString());
|
||||||
|
},
|
||||||
|
isPreferred: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_no"),
|
||||||
|
onPress: () => {
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_cancel"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not downloaded, proceed with normal flow
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
lightHapticFeedback,
|
||||||
|
handleNormalPlayFlow,
|
||||||
|
goToPlayer,
|
||||||
|
t,
|
||||||
|
showModal,
|
||||||
|
hideModal,
|
||||||
|
effectiveColors,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
@@ -360,9 +472,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[startColor.value.text, endColor.value.text],
|
[startColor.value.text, endColor.value.text],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
/**
|
|
||||||
* *********************
|
|
||||||
*/
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -370,8 +479,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
accessibilityLabel='Play button'
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative flex-1"}
|
||||||
{...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
|
||||||
@@ -399,7 +507,11 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
{runtimeTicksToMinutes(
|
||||||
|
(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} />
|
||||||
@@ -410,15 +522,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<CastButton tintColor='transparent' />
|
<CastButton tintColor='transparent' />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
{!client && settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='vlc'
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -17,7 +17,6 @@ import Animated, {
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
@@ -50,7 +49,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const startColor = useSharedValue(effectiveColors);
|
const startColor = useSharedValue(effectiveColors);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings } = useSettings();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
@@ -61,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onPress = () => {
|
const onPress = () => {
|
||||||
console.log("onpress");
|
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
@@ -207,15 +204,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name='play-circle' size={24} />
|
<Ionicons name='play-circle' size={24} />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='vlc'
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
@@ -14,25 +14,10 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
|||||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||||
const toggle = useMarkAsPlayed(items);
|
const toggle = useMarkAsPlayed(items);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton
|
|
||||||
color={allPlayed ? "purple" : "white"}
|
|
||||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
|
||||||
onPress={async () => {
|
|
||||||
await toggle(!allPlayed);
|
|
||||||
}}
|
|
||||||
size={props.size}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
fillColor={allPlayed ? "primary" : undefined}
|
color={allPlayed ? "purple" : "white"}
|
||||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await toggle(!allPlayed);
|
await toggle(!allPlayed);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||||
fillColor ? fillColorClass : "bg-neutral-800/80"
|
fillColor ? fillColorClass : "bg-transparent"
|
||||||
}`}
|
}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -21,6 +19,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
}, [source]);
|
}, [source]);
|
||||||
@@ -30,64 +30,83 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
[subtitleStreams, selected],
|
[subtitleStreams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("item_card.none"),
|
||||||
|
value: -1,
|
||||||
|
selected: selected === -1,
|
||||||
|
onPress: () => onChange(-1),
|
||||||
|
},
|
||||||
|
...(subtitleStreams?.map((subtitle, idx) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`,
|
||||||
|
value: subtitle.Index,
|
||||||
|
selected: subtitle.Index === selected,
|
||||||
|
onPress: () => onChange(subtitle.Index ?? -1),
|
||||||
|
})) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [subtitleStreams, selected, t, onChange]);
|
||||||
|
|
||||||
|
const handleOptionSelect = (optionId: string) => {
|
||||||
|
if (optionId === "none") {
|
||||||
|
onChange(-1);
|
||||||
|
} else {
|
||||||
|
const selectedStream = subtitleStreams?.find(
|
||||||
|
(subtitle, idx) => `${subtitle.Index || idx}` === optionId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
selectedStream &&
|
||||||
|
selectedStream.Index !== undefined &&
|
||||||
|
selectedStream.Index !== null
|
||||||
|
) {
|
||||||
|
onChange(selectedStream.Index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
|
||||||
|
{t("item_card.subtitles")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
{selectedSubtitleSteam
|
||||||
|
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||||
|
: t("item_card.none")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex col shrink justify-start place-self-start items-start'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 60,
|
title={t("item_card.subtitles")}
|
||||||
maxWidth: 200,
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<View className='flex flex-col ' {...props}>
|
/>
|
||||||
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.subtitles")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
|
||||||
<Text className=' '>
|
|
||||||
{selectedSubtitleSteam
|
|
||||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
|
||||||
: t("item_card.none")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"-1"}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(-1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
if (subtitle.Index !== undefined && subtitle.Index !== null)
|
|
||||||
onChange(subtitle.Index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{subtitle.DisplayTitle}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,12 +33,27 @@ 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: 25 }} {...props}>
|
<View className='flex shrink' style={{ minWidth: 60 }} {...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
|
||||||
@@ -46,7 +61,9 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
onPress={() => setOpen(true)}
|
onPress={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
{selected === -1 && streamType === "Subtitle"
|
||||||
|
? t("common.none")
|
||||||
|
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -54,8 +71,14 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
title={title}
|
title={title}
|
||||||
data={streams || []}
|
data={addNoneToSubtitles || []}
|
||||||
values={selectedSteam ? [selectedSteam] : []}
|
values={
|
||||||
|
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 || "";
|
||||||
|
|||||||
@@ -10,45 +10,52 @@ 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 { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
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 { PlayedStatus } from "./PlayedStatus";
|
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
||||||
|
|
||||||
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 = 210;
|
const LOGO_BOTTOM_POSITION = 260;
|
||||||
const GENRES_BOTTOM_POSITION = 170;
|
const GENRES_BOTTOM_POSITION = 220;
|
||||||
const CONTROLS_BOTTOM_POSITION = 100;
|
const OVERVIEW_BOTTOM_POSITION = 165;
|
||||||
const DOTS_BOTTOM_POSITION = 60;
|
const CONTROLS_BOTTOM_POSITION = 80;
|
||||||
|
const DOTS_BOTTOM_POSITION = 40;
|
||||||
|
|
||||||
// Size Constants
|
// Size Constants
|
||||||
const DOT_HEIGHT = 6;
|
const DOT_HEIGHT = 6;
|
||||||
@@ -58,13 +65,15 @@ const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
|||||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
||||||
const TEXT_SKELETON_HEIGHT = 20;
|
const TEXT_SKELETON_HEIGHT = 20;
|
||||||
const TEXT_SKELETON_WIDTH = 250;
|
const TEXT_SKELETON_WIDTH = 250;
|
||||||
|
const OVERVIEW_SKELETON_HEIGHT = 16;
|
||||||
|
const OVERVIEW_SKELETON_WIDTH = 400;
|
||||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
const _EMPTY_STATE_ICON_SIZE = 64;
|
||||||
|
|
||||||
// Spacing Constants
|
// Spacing Constants
|
||||||
const HORIZONTAL_PADDING = 40;
|
const HORIZONTAL_PADDING = 40;
|
||||||
const DOT_PADDING = 2;
|
const DOT_PADDING = 2;
|
||||||
const DOT_GAP = 4;
|
const DOT_GAP = 4;
|
||||||
const CONTROLS_GAP = 20;
|
const CONTROLS_GAP = 10;
|
||||||
const _TEXT_MARGIN_TOP = 16;
|
const _TEXT_MARGIN_TOP = 16;
|
||||||
|
|
||||||
// Border Radius Constants
|
// Border Radius Constants
|
||||||
@@ -83,13 +92,16 @@ const VELOCITY_THRESHOLD = 400;
|
|||||||
|
|
||||||
// Text Constants
|
// Text Constants
|
||||||
const GENRES_FONT_SIZE = 16;
|
const GENRES_FONT_SIZE = 16;
|
||||||
|
const OVERVIEW_FONT_SIZE = 14;
|
||||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
const _EMPTY_STATE_FONT_SIZE = 18;
|
||||||
const TEXT_SHADOW_RADIUS = 2;
|
const TEXT_SHADOW_RADIUS = 2;
|
||||||
const MAX_GENRES_COUNT = 2;
|
const MAX_GENRES_COUNT = 2;
|
||||||
const MAX_BUTTON_WIDTH = 300;
|
const MAX_BUTTON_WIDTH = 300;
|
||||||
|
const OVERVIEW_MAX_LINES = 2;
|
||||||
|
const OVERVIEW_MAX_WIDTH = "80%";
|
||||||
|
|
||||||
// Opacity Constants
|
// Opacity Constants
|
||||||
const OVERLAY_OPACITY = 0.4;
|
const OVERLAY_OPACITY = 0.3;
|
||||||
const DOT_INACTIVE_OPACITY = 0.6;
|
const DOT_INACTIVE_OPACITY = 0.6;
|
||||||
const TEXT_OPACITY = 0.9;
|
const TEXT_OPACITY = 0.9;
|
||||||
|
|
||||||
@@ -147,14 +159,21 @@ 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(-currentIndex * screenWidth);
|
const translateX = useSharedValue(-initialIndex * screenWidth);
|
||||||
|
|
||||||
const isQueryEnabled =
|
const isQueryEnabled =
|
||||||
!!api && !!user?.Id && isConnected && serverConnected === true;
|
!!api && !!user?.Id && isConnected && serverConnected === true;
|
||||||
@@ -168,7 +187,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
fields: ["Genres", "Overview"],
|
||||||
limit: 2,
|
limit: 2,
|
||||||
});
|
});
|
||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
@@ -183,7 +202,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres", "Overview"],
|
||||||
limit: 2,
|
limit: 2,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
@@ -202,7 +221,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
limit: 2,
|
limit: 2,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
});
|
});
|
||||||
@@ -218,11 +237,21 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
const nextItems = nextUpData ?? [];
|
const nextItems = nextUpData ?? [];
|
||||||
const recentItems = recentlyAddedData ?? [];
|
const recentItems = recentlyAddedData ?? [];
|
||||||
|
|
||||||
return [
|
const allItems = [
|
||||||
...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 =
|
||||||
@@ -281,7 +310,11 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
translateX.value = -newIndex * screenWidth;
|
translateX.value = -newIndex * screenWidth;
|
||||||
return newIndex;
|
return newIndex;
|
||||||
});
|
});
|
||||||
}, [hasItems, items, initialIndex, translateX]);
|
}, [hasItems, items, initialIndex, screenWidth, translateX]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
translateX.value = -currentIndex * screenWidth;
|
||||||
|
}, [currentIndex, screenWidth, translateX]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasItems) {
|
if (hasItems) {
|
||||||
@@ -301,7 +334,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
onItemChange?.(index);
|
onItemChange?.(index);
|
||||||
},
|
},
|
||||||
[hasItems, items, onItemChange, translateX],
|
[hasItems, items, onItemChange, screenWidth, translateX],
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigateToItem = useCallback(
|
const navigateToItem = useCallback(
|
||||||
@@ -348,6 +381,30 @@ 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;
|
||||||
|
|
||||||
@@ -381,7 +438,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: screenWidth,
|
width: screenWidth,
|
||||||
height: CAROUSEL_HEIGHT,
|
height: carouselHeight,
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -473,6 +530,36 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Overview Skeleton */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: OVERVIEW_BOTTOM_POSITION,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: OVERVIEW_SKELETON_HEIGHT,
|
||||||
|
width: OVERVIEW_SKELETON_WIDTH,
|
||||||
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||||
|
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: OVERVIEW_SKELETON_HEIGHT,
|
||||||
|
width: OVERVIEW_SKELETON_WIDTH * 0.7,
|
||||||
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||||
|
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Controls Skeleton */}
|
{/* Controls Skeleton */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -549,20 +636,30 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: screenWidth,
|
width: screenWidth,
|
||||||
height: CAROUSEL_HEIGHT,
|
height: carouselHeight,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Background Backdrop */}
|
{/* Background Backdrop */}
|
||||||
<ItemImage
|
<Animated.View
|
||||||
item={item}
|
style={[
|
||||||
variant='Backdrop'
|
{
|
||||||
style={{
|
width: "100%",
|
||||||
width: "100%",
|
height: "100%",
|
||||||
height: "100%",
|
position: "absolute",
|
||||||
position: "absolute",
|
},
|
||||||
}}
|
headerAnimatedStyle,
|
||||||
/>
|
]}
|
||||||
|
>
|
||||||
|
<ItemImage
|
||||||
|
item={item}
|
||||||
|
variant='Backdrop'
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
{/* Dark Overlay */}
|
{/* Dark Overlay */}
|
||||||
<View
|
<View
|
||||||
@@ -689,6 +786,39 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Overview Section - for Episodes and Movies */}
|
||||||
|
{(item.Type === "Episode" || item.Type === "Movie") &&
|
||||||
|
item.Overview && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: OVERVIEW_BOTTOM_POSITION,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
||||||
|
<Animated.Text
|
||||||
|
numberOfLines={OVERVIEW_MAX_LINES}
|
||||||
|
style={{
|
||||||
|
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
|
||||||
|
fontSize: OVERVIEW_FONT_SIZE,
|
||||||
|
fontWeight: "400",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: OVERVIEW_MAX_WIDTH,
|
||||||
|
textShadowColor: TEXT_SHADOW_COLOR,
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: TEXT_SHADOW_RADIUS,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.Overview}
|
||||||
|
</Animated.Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Controls Section */}
|
{/* Controls Section */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -719,7 +849,10 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Mark as Played */}
|
{/* Mark as Played */}
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<MarkAsPlayedLargeButton
|
||||||
|
isPlayed={item.UserData?.Played ?? false}
|
||||||
|
onToggle={togglePlayedStatus}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -731,7 +864,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: CAROUSEL_HEIGHT,
|
height: carouselHeight,
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
@@ -749,7 +882,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
height: carouselHeight, // Fixed height instead of flex: 1
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
@@ -758,7 +891,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
height: carouselHeight, // Fixed height instead of flex: 1
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
width: screenWidth * items.length,
|
width: screenWidth * items.length,
|
||||||
},
|
},
|
||||||
51
components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
Normal file
51
components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Button, Host } from "@expo/ui/swift-ui";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { RoundButton } from "../RoundButton";
|
||||||
|
|
||||||
|
interface MarkAsPlayedLargeButtonProps {
|
||||||
|
isPlayed: boolean;
|
||||||
|
onToggle: (isPlayed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkAsPlayedLargeButton: React.FC<
|
||||||
|
MarkAsPlayedLargeButtonProps
|
||||||
|
> = ({ isPlayed, onToggle }) => {
|
||||||
|
if (Platform.OS === "ios")
|
||||||
|
return (
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
flex: 0,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
|
||||||
|
<View>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
marginLeft: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</Host>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<RoundButton
|
||||||
|
size='large'
|
||||||
|
icon={isPlayed ? "checkmark" : "checkmark"}
|
||||||
|
onPress={() => onToggle(isPlayed)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import {
|
|
||||||
type PropsWithChildren,
|
|
||||||
type ReactNode,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
|
|
||||||
interface Props<T> {
|
|
||||||
data: T[];
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholderText?: string;
|
|
||||||
keyExtractor: (item: T) => string;
|
|
||||||
titleExtractor: (item: T) => string | undefined;
|
|
||||||
title: string | ReactNode;
|
|
||||||
label: string;
|
|
||||||
onSelected: (...item: T[]) => void;
|
|
||||||
multiple?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Dropdown = <T,>({
|
|
||||||
data,
|
|
||||||
disabled,
|
|
||||||
placeholderText,
|
|
||||||
keyExtractor,
|
|
||||||
titleExtractor,
|
|
||||||
title,
|
|
||||||
label,
|
|
||||||
onSelected,
|
|
||||||
multiple = false,
|
|
||||||
...props
|
|
||||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
|
||||||
const isTv = Platform.isTV;
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState<T[]>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selected !== undefined) {
|
|
||||||
onSelected(...selected);
|
|
||||||
}
|
|
||||||
}, [selected, onSelected]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
{typeof title === "string" ? (
|
|
||||||
<View className='flex flex-col'>
|
|
||||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
|
||||||
{selected?.length !== undefined
|
|
||||||
? selected.map(titleExtractor).join(",")
|
|
||||||
: placeholderText}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
title
|
|
||||||
)}
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={false}
|
|
||||||
side='bottom'
|
|
||||||
align='center'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
|
||||||
{data.map((item, _idx) =>
|
|
||||||
multiple ? (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
value={
|
|
||||||
selected?.some((s) => keyExtractor(s) === keyExtractor(item))
|
|
||||||
? "on"
|
|
||||||
: "off"
|
|
||||||
}
|
|
||||||
key={keyExtractor(item)}
|
|
||||||
onValueChange={(
|
|
||||||
next: "on" | "off",
|
|
||||||
_previous: "on" | "off",
|
|
||||||
) => {
|
|
||||||
setSelected((p) => {
|
|
||||||
const prev = p || [];
|
|
||||||
if (next === "on") {
|
|
||||||
return [...prev, item];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
...prev.filter(
|
|
||||||
(p) => keyExtractor(p) !== keyExtractor(item),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{titleExtractor(item)}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
) : (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={keyExtractor(item)}
|
|
||||||
onSelect={() => setSelected([item])}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{titleExtractor(item)}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Dropdown;
|
|
||||||
@@ -55,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
className=' bg-neutral-800/80 rounded-full p-2'
|
className=' rounded-full p-2'
|
||||||
{...touchableOpacityProps}
|
{...touchableOpacityProps}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -3,17 +3,12 @@ import React, { useImperativeHandle, useRef } from "react";
|
|||||||
import { View, type ViewStyle } from "react-native";
|
import { View, type ViewStyle } from "react-native";
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
|
||||||
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
|
||||||
|
|
||||||
export interface HorizontalScrollRef {
|
export interface HorizontalScrollRef {
|
||||||
scrollToIndex: (index: number, viewOffset: number) => void;
|
scrollToIndex: (index: number, viewOffset: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HorizontalScrollProps<T>
|
interface HorizontalScrollProps<T>
|
||||||
extends PartialExcept<
|
extends Omit<FlashListProps<T>, "renderItem" | "estimatedItemSize" | "data"> {
|
||||||
Omit<FlashListProps<T>, "renderItem">,
|
|
||||||
"estimatedItemSize"
|
|
||||||
> {
|
|
||||||
data?: T[] | null;
|
data?: T[] | null;
|
||||||
renderItem: (item: T, index: number) => React.ReactNode;
|
renderItem: (item: T, index: number) => React.ReactNode;
|
||||||
keyExtractor?: (item: T, index: number) => string;
|
keyExtractor?: (item: T, index: number) => string;
|
||||||
@@ -44,7 +39,7 @@ export const HorizontalScroll = <T,>(
|
|||||||
...restProps
|
...restProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const flashListRef = useRef<FlashList<T>>(null);
|
const flashListRef = useRef<React.ComponentRef<typeof FlashList<T>>>(null);
|
||||||
|
|
||||||
useImperativeHandle(ref!, () => ({
|
useImperativeHandle(ref!, () => ({
|
||||||
scrollToIndex: (index: number, viewOffset: number) => {
|
scrollToIndex: (index: number, viewOffset: number) => {
|
||||||
@@ -78,7 +73,6 @@ export const HorizontalScroll = <T,>(
|
|||||||
extraData={extraData}
|
extraData={extraData}
|
||||||
renderItem={renderFlashListItem}
|
renderItem={renderFlashListItem}
|
||||||
horizontal
|
horizontal
|
||||||
estimatedItemSize={200}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ 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 ||
|
||||||
@@ -119,7 +120,6 @@ 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) {
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export function Input(props: InputProps) {
|
|||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
return Platform.isTV ? (
|
return Platform.isTV ? (
|
||||||
<TouchableOpacity onFocus={() => inputRef?.current?.focus?.()}>
|
<TouchableOpacity
|
||||||
|
onPress={() => inputRef?.current?.focus?.()}
|
||||||
|
activeOpacity={1}
|
||||||
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type PropsWithChildren, useCallback, useMemo } from "react";
|
import { type PropsWithChildren } from "react";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import {
|
|
||||||
hasPermission,
|
|
||||||
Permission,
|
|
||||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
import type {
|
import type {
|
||||||
@@ -38,90 +32,33 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
|
||||||
|
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
const autoApprove = useMemo(() => {
|
|
||||||
return (
|
|
||||||
jellyseerrUser &&
|
|
||||||
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
|
|
||||||
type: "or",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [jellyseerrApi, jellyseerrUser]);
|
|
||||||
|
|
||||||
const request = useCallback(() => {
|
|
||||||
if (!result) return;
|
|
||||||
requestMedia(mediaTitle, {
|
|
||||||
mediaId: result.id,
|
|
||||||
mediaType,
|
|
||||||
});
|
|
||||||
}, [jellyseerrApi, result]);
|
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<TouchableOpacity
|
||||||
<ContextMenu.Trigger>
|
onPress={() => {
|
||||||
<TouchableOpacity
|
if (!result) return;
|
||||||
onPress={() => {
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
params: {
|
params: {
|
||||||
...result,
|
...result,
|
||||||
mediaTitle,
|
mediaTitle,
|
||||||
releaseYear,
|
releaseYear,
|
||||||
canRequest: canRequest.toString(),
|
canRequest: canRequest.toString(),
|
||||||
posterSrc,
|
posterSrc,
|
||||||
mediaType,
|
mediaType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content
|
|
||||||
avoidCollisions
|
|
||||||
alignOffset={0}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={false}
|
|
||||||
key={"content"}
|
|
||||||
>
|
|
||||||
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
|
|
||||||
{canRequest && mediaType === MediaType.MOVIE && (
|
|
||||||
<ContextMenu.Item
|
|
||||||
key='item-1'
|
|
||||||
onSelect={() => {
|
|
||||||
if (autoApprove) {
|
|
||||||
request();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
shouldDismissMenuOnSelect
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle key='item-1-title'>
|
|
||||||
Request
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
<ContextMenu.ItemIcon
|
|
||||||
ios={{
|
|
||||||
name: "arrow.down.to.line",
|
|
||||||
pointSize: 18,
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "purple",
|
|
||||||
light: "purple",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
androidIconName='download'
|
|
||||||
/>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
)}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ interface ActiveDownloadsProps extends ViewProps {}
|
|||||||
|
|
||||||
export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
||||||
const { processes } = useDownload();
|
const { processes } = useDownload();
|
||||||
if (processes?.length === 0)
|
|
||||||
|
// Filter out any invalid processes before rendering
|
||||||
|
const validProcesses = processes?.filter((p) => p?.item?.Id) || [];
|
||||||
|
|
||||||
|
if (validProcesses.length === 0)
|
||||||
return (
|
return (
|
||||||
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
|
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
@@ -26,9 +30,9 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
|||||||
<Text className='text-lg font-bold mb-2'>
|
<Text className='text-lg font-bold mb-2'>
|
||||||
{t("home.downloads.active_downloads")}
|
{t("home.downloads.active_downloads")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='space-y-2'>
|
<View className='gap-y-2'>
|
||||||
{processes?.map((p: JobStatus) => (
|
{validProcesses.map((p: JobStatus) => (
|
||||||
<DownloadCard key={p.item.Id} process={p} />
|
<DownloadCard key={p.id} process={p} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { t } from "i18next";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
type TouchableOpacityProps,
|
type TouchableOpacityProps,
|
||||||
View,
|
View,
|
||||||
@@ -14,49 +13,36 @@ import {
|
|||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
||||||
import { JobStatus } from "@/providers/Downloads/types";
|
import { JobStatus } from "@/providers/Downloads/types";
|
||||||
|
import { estimateDownloadSize } from "@/utils/download";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
import { Button } from "../Button";
|
|
||||||
|
|
||||||
const bytesToMB = (bytes: number) => {
|
const bytesToMB = (bytes: number) => {
|
||||||
return bytes / 1024 / 1024;
|
return bytes / 1024 / 1024;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes >= 1024 * 1024 * 1024) {
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
interface DownloadCardProps extends TouchableOpacityProps {
|
interface DownloadCardProps extends TouchableOpacityProps {
|
||||||
process: JobStatus;
|
process: JobStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
|
const { cancelDownload } = useDownload();
|
||||||
useDownload();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const handlePause = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await pauseDownload(id);
|
|
||||||
toast.success(t("home.downloads.toasts.download_paused"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error pausing download:", error);
|
|
||||||
toast.error(t("home.downloads.toasts.could_not_pause_download"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResume = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await resumeDownload(id);
|
|
||||||
toast.success(t("home.downloads.toasts.download_resumed"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error resuming download:", error);
|
|
||||||
toast.error(t("home.downloads.toasts.could_not_resume_download"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await removeProcess(id);
|
await cancelDownload(id);
|
||||||
toast.success(t("home.downloads.toasts.download_deleted"));
|
// cancelDownload already shows a toast, so don't show another one
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting download:", error);
|
console.error("Error deleting download:", error);
|
||||||
@@ -64,31 +50,75 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const eta = (p: JobStatus) => {
|
const eta = useMemo(() => {
|
||||||
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
|
if (!process?.estimatedTotalSizeBytes || !process?.bytesDownloaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
const secondsRemaining = calculateSmoothedETA(
|
||||||
if (bytesRemaining <= 0) return null;
|
process.id,
|
||||||
|
process.bytesDownloaded,
|
||||||
|
process.estimatedTotalSizeBytes,
|
||||||
|
);
|
||||||
|
|
||||||
const secondsRemaining = bytesRemaining / p.speed;
|
if (!secondsRemaining || secondsRemaining <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return formatTimeString(secondsRemaining, "s");
|
return formatTimeString(secondsRemaining, "s");
|
||||||
};
|
}, [process?.id, process?.bytesDownloaded, process?.estimatedTotalSizeBytes]);
|
||||||
|
|
||||||
|
const estimatedSize = useMemo(() => {
|
||||||
|
if (process?.estimatedTotalSizeBytes)
|
||||||
|
return process.estimatedTotalSizeBytes;
|
||||||
|
|
||||||
|
// Calculate from bitrate + duration (only if bitrate value is defined)
|
||||||
|
if (process?.maxBitrate?.value && process?.item?.RunTimeTicks) {
|
||||||
|
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!);
|
try {
|
||||||
}, []);
|
const itemId = process?.item?.Id;
|
||||||
|
if (!itemId) return undefined;
|
||||||
|
return storage.getString(itemId);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}, [process?.item?.Id]);
|
||||||
|
|
||||||
// Sanitize progress to ensure it's within valid bounds
|
// Sanitize progress to ensure it's within valid bounds
|
||||||
const sanitizedProgress = useMemo(() => {
|
const sanitizedProgress = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
typeof process.progress !== "number" ||
|
typeof process?.progress !== "number" ||
|
||||||
Number.isNaN(process.progress)
|
Number.isNaN(process.progress)
|
||||||
) {
|
) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return Math.max(0, Math.min(100, process.progress));
|
return Math.max(0, Math.min(100, process.progress));
|
||||||
}, [process.progress]);
|
}, [process?.progress]);
|
||||||
|
|
||||||
|
// Return null after all hooks have been called
|
||||||
|
if (!process || !process.item || !process.item.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -98,9 +128,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
>
|
>
|
||||||
{process.status === "downloading" && (
|
{process.status === "downloading" && (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`bg-purple-600 h-1 absolute bottom-0 left-0 ${isTranscoding ? "animate-pulse" : ""}`}
|
||||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
|
||||||
`}
|
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
sanitizedProgress > 0
|
sanitizedProgress > 0
|
||||||
@@ -111,26 +139,10 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons in bottom right corner */}
|
{/* Action buttons in bottom right corner */}
|
||||||
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
|
<View className='absolute bottom-2 right-2 flex flex-row items-center z-10'>
|
||||||
{process.status === "downloading" && Platform.OS !== "ios" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handlePause(process.id)}
|
|
||||||
className='p-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='pause' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{process.status === "paused" && Platform.OS !== "ios" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleResume(process.id)}
|
|
||||||
className='p-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='play' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleDelete(process.id)}
|
onPress={() => handleDelete(process.id)}
|
||||||
className='p-1'
|
className='p-2 bg-neutral-800 rounded-full'
|
||||||
>
|
>
|
||||||
<Ionicons name='close' size={20} color='red' />
|
<Ionicons name='close' size={20} color='red' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -152,47 +164,53 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className='shrink mb-1 flex-1'>
|
<View className='shrink mb-1 flex-1 pr-12'>
|
||||||
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
|
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
|
||||||
<Text className='font-semibold shrink'>{process.item.Name}</Text>
|
<Text className='font-semibold shrink'>{process.item.Name}</Text>
|
||||||
<Text className='text-xs opacity-50'>
|
<Text className='text-xs opacity-50'>
|
||||||
{process.item.ProductionYear}
|
{process.item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
|
||||||
|
{isTranscoding && (
|
||||||
|
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||||
|
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 1: Progress + Downloaded/Total */}
|
||||||
|
<View className='flex flex-row items-center gap-x-2 mt-1.5'>
|
||||||
{sanitizedProgress === 0 ? (
|
{sanitizedProgress === 0 ? (
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
) : (
|
) : (
|
||||||
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
|
<Text className='text-xs font-semibold'>
|
||||||
)}
|
{sanitizedProgress.toFixed(0)}%
|
||||||
{process.speed && process.speed > 0 && (
|
|
||||||
<Text className='text-xs'>
|
|
||||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{eta(process) && (
|
{downloadedAmount && (
|
||||||
<Text className='text-xs'>
|
<Text className='text-xs opacity-75'>
|
||||||
{t("home.downloads.eta", { eta: eta(process) })}
|
{downloadedAmount}
|
||||||
|
{estimatedSize
|
||||||
|
? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}`
|
||||||
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
{/* Row 2: Speed + ETA */}
|
||||||
<Text className='text-xs capitalize'>{process.status}</Text>
|
<View className='flex flex-row items-center gap-x-2 mt-0.5'>
|
||||||
|
{process.speed && process.speed > 0 && (
|
||||||
|
<Text className='text-xs text-purple-400'>
|
||||||
|
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{eta && (
|
||||||
|
<Text className='text-xs text-green-400'>
|
||||||
|
{t("home.downloads.eta", { eta: eta })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{process.status === "completed" && (
|
|
||||||
<View className='flex flex-row mt-4 space-x-4'>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
startDownload(process);
|
|
||||||
}}
|
|
||||||
className='w-full'
|
|
||||||
>
|
|
||||||
Download now
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
|||||||
items,
|
items,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { getDownloadedItemSize, getDownloadedItems } = useDownload();
|
const { getDownloadedItemSize, downloadedItems } = useDownload();
|
||||||
const downloadedFiles = getDownloadedItems();
|
|
||||||
const [size, setSize] = useState<string | undefined>();
|
const [size, setSize] = useState<string | undefined>();
|
||||||
|
|
||||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!downloadedFiles) return;
|
if (!downloadedItems) return;
|
||||||
|
|
||||||
let s = 0;
|
let s = 0;
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSize(s.bytesToReadable());
|
setSize(s.bytesToReadable());
|
||||||
}, [itemIds]);
|
}, [itemIds, downloadedItems, getDownloadedItemSize]);
|
||||||
|
|
||||||
const sizeText = useMemo(() => {
|
const sizeText = useMemo(() => {
|
||||||
if (!size) return "...";
|
if (!size) return "...";
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
*/
|
*/
|
||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id, "Episode");
|
deleteFile(item.Id);
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|||||||
@@ -29,15 +29,15 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(item?.Id!);
|
return item?.Id ? storage.getString(item.Id) : undefined;
|
||||||
}, []);
|
}, [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, item.Type);
|
deleteFile(item.Id);
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
return storage.getString(items[0].SeriesId!);
|
return storage.getString(items[0].SeriesId!);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteSeries = useCallback(async () => deleteItems(items), [items]);
|
const deleteSeries = useCallback(
|
||||||
|
async () =>
|
||||||
|
deleteItems(
|
||||||
|
items.map((item) => item.Id).filter((id) => id !== undefined),
|
||||||
|
),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const showActionSheet = useCallback(() => {
|
||||||
const options = ["Delete", "Cancel"];
|
const options = ["Delete", "Cancel"];
|
||||||
|
|||||||
@@ -109,11 +109,22 @@ 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++) {
|
||||||
tmp.add(_data[i]);
|
const item = _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(() => {
|
||||||
@@ -233,7 +244,7 @@ export const FilterSheet = <T,>({
|
|||||||
{data.length < (_data?.length || 0) && (
|
{data.length < (_data?.length || 0) && (
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setOffset(offset + 100);
|
setOffset(offset + LIMIT);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Load more
|
Load more
|
||||||
|
|||||||
506
components/home/Home.tsx
Normal file
506
components/home/Home.tsx
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
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: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/downloads");
|
||||||
|
}}
|
||||||
|
className='ml-1.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather
|
||||||
|
name='download'
|
||||||
|
color={hasDownloads ? Colors.primary : "white"}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, router, hasDownloads]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cleanCacheDirectory().catch((_e) =>
|
||||||
|
console.error("Something went wrong cleaning cache directory"),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const segments = useSegments();
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||||
|
if ((segments as string[])[2] === "(home)")
|
||||||
|
scrollRef.current?.scrollTo({
|
||||||
|
y: Platform.isTV ? -152 : -100,
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [segments]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isError: e1,
|
||||||
|
isLoading: l1,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["home", "userViews", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userViews = useMemo(
|
||||||
|
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||||
|
[data, settings?.hiddenLibraries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const collections = useMemo(() => {
|
||||||
|
const allow = ["movies", "tvshows"];
|
||||||
|
return (
|
||||||
|
userViews?.filter(
|
||||||
|
(c) => c.CollectionType && allow.includes(c.CollectionType),
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}, [userViews]);
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await refreshStreamyfinPluginSettings();
|
||||||
|
await invalidateCache();
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCollectionConfig = useCallback(
|
||||||
|
(
|
||||||
|
title: string,
|
||||||
|
queryKey: string[],
|
||||||
|
includeItemTypes: BaseItemKind[],
|
||||||
|
parentId: string | undefined,
|
||||||
|
pageSize: number = 10,
|
||||||
|
): InfiniteScrollingCollectionListSection => ({
|
||||||
|
title,
|
||||||
|
queryKey,
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
if (!api) return [];
|
||||||
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
|
const allData =
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 100, // Fetch a larger set for pagination
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
includeItemTypes,
|
||||||
|
parentId,
|
||||||
|
})
|
||||||
|
).data || [];
|
||||||
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
|
},
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[api, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultSections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const latestMediaViews = collections.map((c) => {
|
||||||
|
const includeItemTypes: BaseItemKind[] =
|
||||||
|
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||||
|
? []
|
||||||
|
: ["Movie"];
|
||||||
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
|
const queryKey: string[] = [
|
||||||
|
"home",
|
||||||
|
`recentlyAddedIn${c.CollectionType}`,
|
||||||
|
user.Id!,
|
||||||
|
c.Id!,
|
||||||
|
];
|
||||||
|
return createCollectionConfig(
|
||||||
|
title || "",
|
||||||
|
queryKey,
|
||||||
|
includeItemTypes,
|
||||||
|
c.Id,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ss: Section[] = [
|
||||||
|
{
|
||||||
|
title: t("home.continue_watching"),
|
||||||
|
queryKey: ["home", "resumeItems"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
fields: ["Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.next_up"),
|
||||||
|
queryKey: ["home", "nextUp-all"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
enableResumable: false,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
...latestMediaViews,
|
||||||
|
{
|
||||||
|
title: t("home.suggested_movies"),
|
||||||
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Video"],
|
||||||
|
type: ["Movie"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "vertical",
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
||||||
|
|
||||||
|
const customSections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||||
|
const ss: Section[] = [];
|
||||||
|
settings.home.sections.forEach((section, index) => {
|
||||||
|
const id = section.title || `section-${index}`;
|
||||||
|
const pageSize = 10;
|
||||||
|
ss.push({
|
||||||
|
title: t(`${id}`),
|
||||||
|
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
if (section.items) {
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: section.items?.limit || pageSize,
|
||||||
|
recursive: true,
|
||||||
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
|
sortBy: section.items?.sortBy,
|
||||||
|
sortOrder: section.items?.sortOrder,
|
||||||
|
filters: section.items?.filters,
|
||||||
|
parentId: section.items?.parentId,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
if (section.nextUp) {
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: section.nextUp?.limit || pageSize,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
if (section.latest) {
|
||||||
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
|
const allData =
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
|
limit: section.latest?.limit || 100, // Fetch larger set
|
||||||
|
isPlayed: section.latest?.isPlayed,
|
||||||
|
groupItems: section.latest?.groupItems,
|
||||||
|
})
|
||||||
|
).data || [];
|
||||||
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
|
}
|
||||||
|
if (section.custom) {
|
||||||
|
const response = await api.get<BaseItemDtoQueryResult>(
|
||||||
|
section.custom.endpoint,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
...(section.custom.query || {}),
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
|
},
|
||||||
|
headers: section.custom.headers || {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: section?.orientation || "vertical",
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||||
|
|
||||||
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
|
if (!isConnected || serverConnected !== true) {
|
||||||
|
let title = "";
|
||||||
|
let subtitle = "";
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
title = t("home.no_internet");
|
||||||
|
subtitle = t("home.no_internet_message");
|
||||||
|
} else if (serverConnected === null) {
|
||||||
|
title = t("home.checking_server_connection");
|
||||||
|
subtitle = t("home.checking_server_connection_message");
|
||||||
|
} else if (!serverConnected) {
|
||||||
|
title = t("home.server_unreachable");
|
||||||
|
subtitle = t("home.server_unreachable_message");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
||||||
|
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
||||||
|
<Text className='text-center opacity-70'>{subtitle}</Text>
|
||||||
|
|
||||||
|
<View className='mt-4'>
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<Button
|
||||||
|
color='purple'
|
||||||
|
onPress={() => router.push("/(auth)/downloads")}
|
||||||
|
justify='center'
|
||||||
|
iconRight={
|
||||||
|
<Ionicons name='arrow-forward' size={20} color='white' />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("home.go_to_downloads")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color='black'
|
||||||
|
onPress={retryCheck}
|
||||||
|
justify='center'
|
||||||
|
className='mt-2'
|
||||||
|
iconRight={
|
||||||
|
retryLoading ? null : (
|
||||||
|
<Ionicons name='refresh' size={20} color='white' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{retryLoading ? (
|
||||||
|
<ActivityIndicator size='small' color='white' />
|
||||||
|
) : (
|
||||||
|
t("home.retry")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e1)
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col items-center justify-center h-full -mt-6'>
|
||||||
|
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
|
||||||
|
<Text className='text-center opacity-70'>
|
||||||
|
{t("home.error_message")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (l1)
|
||||||
|
return (
|
||||||
|
<View className='justify-center items-center h-full'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
onScroll={(event) => {
|
||||||
|
setScrollY(event.nativeEvent.contentOffset.y - 500);
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={loading}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='white'
|
||||||
|
colors={["white"]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='flex flex-col space-y-4'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
{sections.map((section, index) => {
|
||||||
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
|
return (
|
||||||
|
<InfiniteScrollingCollectionList
|
||||||
|
key={index}
|
||||||
|
title={section.title}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
orientation={section.orientation}
|
||||||
|
hideIfEmpty
|
||||||
|
pageSize={section.pageSize}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.type === "MediaListSection") {
|
||||||
|
return (
|
||||||
|
<MediaListSection
|
||||||
|
key={index}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
scrollY={scrollY}
|
||||||
|
enableLazyLoading={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -20,15 +19,17 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedRef,
|
||||||
|
useScrollViewOffset,
|
||||||
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -38,14 +39,15 @@ import { useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import { AppleTVCarousel } from "../AppleTVCarousel";
|
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
type InfiniteScrollingCollectionListSection = {
|
||||||
type: "ScrollingCollectionList";
|
type: "InfiniteScrollingCollectionList";
|
||||||
title?: string;
|
title?: string;
|
||||||
queryKey: (string | undefined | null)[];
|
queryKey: (string | undefined | null)[];
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
|
pageSize?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaListSectionType = {
|
type MediaListSectionType = {
|
||||||
@@ -54,26 +56,21 @@ type MediaListSectionType = {
|
|||||||
queryFn: QueryFunction<BaseItemDto>;
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSectionType;
|
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||||
|
|
||||||
export const HomeIndex = () => {
|
export const HomeWithCarousel = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [_loading, setLoading] = useState(false);
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||||
|
const headerOverlayOffset = Platform.isTV ? 0 : 60;
|
||||||
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
|
const navigation = useNavigation();
|
||||||
|
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||||
|
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
||||||
|
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
||||||
const prevIsConnected = useRef<boolean | null>(false);
|
const prevIsConnected = useRef<boolean | null>(false);
|
||||||
const {
|
const {
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -82,15 +79,20 @@ export const HomeIndex = () => {
|
|||||||
retryCheck,
|
retryCheck,
|
||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only invalidate cache when transitioning from offline to online
|
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
invalidateCache();
|
invalidateCache();
|
||||||
}
|
}
|
||||||
// Update the ref to the current state for the next render
|
|
||||||
prevIsConnected.current = isConnected;
|
prevIsConnected.current = isConnected;
|
||||||
}, [isConnected, invalidateCache]);
|
}, [isConnected, invalidateCache]);
|
||||||
|
|
||||||
|
const hasDownloads = useMemo(() => {
|
||||||
|
if (Platform.isTV) return false;
|
||||||
|
return downloadedItems.length > 0;
|
||||||
|
}, [downloadedItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
@@ -98,24 +100,24 @@ export const HomeIndex = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasDownloads = getDownloadedItems().length > 0;
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
className='p-2'
|
className='ml-1.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather
|
||||||
name='download'
|
name='download'
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
color={hasDownloads ? Colors.primary : "white"}
|
||||||
size={22}
|
size={24}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [navigation, router]);
|
}, [navigation, router, hasDownloads]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cleanCacheDirectory().catch((_e) =>
|
cleanCacheDirectory().catch((_e) =>
|
||||||
@@ -127,7 +129,7 @@ export const HomeIndex = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||||
if ((segments as string[])[2] === "(home)")
|
if ((segments as string[])[2] === "(home)")
|
||||||
scrollViewRef.current?.scrollTo({
|
animatedScrollRef.current?.scrollTo({
|
||||||
y: Platform.isTV ? -152 : -100,
|
y: Platform.isTV ? -152 : -100,
|
||||||
animated: true,
|
animated: true,
|
||||||
});
|
});
|
||||||
@@ -173,7 +175,7 @@ export const HomeIndex = () => {
|
|||||||
);
|
);
|
||||||
}, [userViews]);
|
}, [userViews]);
|
||||||
|
|
||||||
const refetch = async () => {
|
const _refetch = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await refreshStreamyfinPluginSettings();
|
await refreshStreamyfinPluginSettings();
|
||||||
await invalidateCache();
|
await invalidateCache();
|
||||||
@@ -186,42 +188,48 @@ export const HomeIndex = () => {
|
|||||||
queryKey: string[],
|
queryKey: string[],
|
||||||
includeItemTypes: BaseItemKind[],
|
includeItemTypes: BaseItemKind[],
|
||||||
parentId: string | undefined,
|
parentId: string | undefined,
|
||||||
): ScrollingCollectionListSection => ({
|
pageSize: number = 10,
|
||||||
|
): InfiniteScrollingCollectionListSection => ({
|
||||||
title,
|
title,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
return (
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
|
const allData =
|
||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 20,
|
limit: 100, // Fetch a larger set for pagination
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
})
|
})
|
||||||
).data || []
|
).data || [];
|
||||||
);
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
},
|
},
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
pageSize,
|
||||||
}),
|
}),
|
||||||
[api, user?.Id],
|
[api, user?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Always call useMemo() at the top-level, using computed dependencies for both "default"/custom sections
|
|
||||||
const defaultSections = useMemo(() => {
|
const defaultSections = useMemo(() => {
|
||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
const latestMediaViews = collections.map((c) => {
|
||||||
const includeItemTypes: BaseItemKind[] =
|
const includeItemTypes: BaseItemKind[] =
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||||
|
? []
|
||||||
|
: ["Movie"];
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
const queryKey = [
|
const queryKey: string[] = [
|
||||||
"home",
|
"home",
|
||||||
`recentlyAddedIn${c.CollectionType}`,
|
`recentlyAddedIn${c.CollectionType}`,
|
||||||
user?.Id!,
|
user.Id!,
|
||||||
c.Id!,
|
c.Id!,
|
||||||
];
|
];
|
||||||
return createCollectionConfig(
|
return createCollectionConfig(
|
||||||
@@ -229,6 +237,7 @@ export const HomeIndex = () => {
|
|||||||
queryKey,
|
queryKey,
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
c.Id,
|
c.Id,
|
||||||
|
10,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,79 +245,56 @@ export const HomeIndex = () => {
|
|||||||
{
|
{
|
||||||
title: t("home.continue_watching"),
|
title: t("home.continue_watching"),
|
||||||
queryKey: ["home", "resumeItems"],
|
queryKey: ["home", "resumeItems"],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
fields: ["Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("home.next_up"),
|
title: t("home.next_up"),
|
||||||
queryKey: ["home", "nextUp-all"],
|
queryKey: ["home", "nextUp-all"],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: 20,
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
},
|
},
|
||||||
...latestMediaViews,
|
...latestMediaViews,
|
||||||
// ...(mediaListCollections?.map(
|
|
||||||
// (ml) =>
|
|
||||||
// ({
|
|
||||||
// title: ml.Name,
|
|
||||||
// queryKey: ["home", "mediaList", ml.Id!],
|
|
||||||
// queryFn: async () => ml,
|
|
||||||
// type: "MediaListSection",
|
|
||||||
// orientation: "vertical",
|
|
||||||
// } as Section)
|
|
||||||
// ) || []),
|
|
||||||
{
|
{
|
||||||
title: t("home.suggested_movies"),
|
title: t("home.suggested_movies"),
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
mediaType: ["Video"],
|
mediaType: ["Video"],
|
||||||
type: ["Movie"],
|
type: ["Movie"],
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "vertical",
|
orientation: "vertical",
|
||||||
},
|
pageSize: 10,
|
||||||
{
|
|
||||||
title: t("home.suggested_episodes"),
|
|
||||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const suggestions = await getSuggestions(api, user.Id);
|
|
||||||
const nextUpPromises = suggestions.map((series) =>
|
|
||||||
getNextUp(api, user.Id, series.Id),
|
|
||||||
);
|
|
||||||
const nextUpResults = await Promise.all(nextUpPromises);
|
|
||||||
|
|
||||||
return nextUpResults.filter((item) => item !== null) || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return ss;
|
return ss;
|
||||||
@@ -317,16 +303,18 @@ export const HomeIndex = () => {
|
|||||||
const customSections = useMemo(() => {
|
const customSections = useMemo(() => {
|
||||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||||
const ss: Section[] = [];
|
const ss: Section[] = [];
|
||||||
for (const [index, section] of settings.home.sections.entries()) {
|
settings.home.sections.forEach((section, index) => {
|
||||||
const id = section.title || `section-${index}`;
|
const id = section.title || `section-${index}`;
|
||||||
|
const pageSize = 10;
|
||||||
ss.push({
|
ss.push({
|
||||||
title: t(`${id}`),
|
title: t(`${id}`),
|
||||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||||
queryFn: async () => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (section.items) {
|
if (section.items) {
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: section.items?.limit || 25,
|
startIndex: pageParam,
|
||||||
|
limit: section.items?.limit || pageSize,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
sortBy: section.items?.sortBy,
|
sortBy: section.items?.sortBy,
|
||||||
@@ -340,7 +328,8 @@ export const HomeIndex = () => {
|
|||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: section.nextUp?.limit || 25,
|
startIndex: pageParam,
|
||||||
|
limit: section.nextUp?.limit || pageSize,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
@@ -348,20 +337,31 @@ export const HomeIndex = () => {
|
|||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
}
|
}
|
||||||
if (section.latest) {
|
if (section.latest) {
|
||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
userId: user?.Id,
|
const allData =
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
(
|
||||||
limit: section.latest?.limit || 25,
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
isPlayed: section.latest?.isPlayed,
|
userId: user?.Id,
|
||||||
groupItems: section.latest?.groupItems,
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
});
|
limit: section.latest?.limit || 100, // Fetch larger set
|
||||||
return response.data || [];
|
isPlayed: section.latest?.isPlayed,
|
||||||
|
groupItems: section.latest?.groupItems,
|
||||||
|
})
|
||||||
|
).data || [];
|
||||||
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
}
|
}
|
||||||
if (section.custom) {
|
if (section.custom) {
|
||||||
const response = await api.get<BaseItemDtoQueryResult>(
|
const response = await api.get<BaseItemDtoQueryResult>(
|
||||||
section.custom.endpoint,
|
section.custom.endpoint,
|
||||||
{
|
{
|
||||||
params: { ...(section.custom.query || {}), userId: user?.Id },
|
params: {
|
||||||
|
...(section.custom.query || {}),
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
|
},
|
||||||
headers: section.custom.headers || {},
|
headers: section.custom.headers || {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -369,12 +369,13 @@ export const HomeIndex = () => {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: section?.orientation || "vertical",
|
orientation: section?.orientation || "vertical",
|
||||||
|
pageSize,
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
return ss;
|
return ss;
|
||||||
}, [api, user?.Id, settings?.home?.sections]);
|
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
@@ -383,15 +384,12 @@ export const HomeIndex = () => {
|
|||||||
let subtitle = "";
|
let subtitle = "";
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
// No network connection
|
|
||||||
title = t("home.no_internet");
|
title = t("home.no_internet");
|
||||||
subtitle = t("home.no_internet_message");
|
subtitle = t("home.no_internet_message");
|
||||||
} else if (serverConnected === null) {
|
} else if (serverConnected === null) {
|
||||||
// Network is up, but server is being checked
|
|
||||||
title = t("home.checking_server_connection");
|
title = t("home.checking_server_connection");
|
||||||
subtitle = t("home.checking_server_connection_message");
|
subtitle = t("home.checking_server_connection_message");
|
||||||
} else if (!serverConnected) {
|
} else if (!serverConnected) {
|
||||||
// Network is up, but server is unreachable
|
|
||||||
title = t("home.server_unreachable");
|
title = t("home.server_unreachable");
|
||||||
subtitle = t("home.server_unreachable_message");
|
subtitle = t("home.server_unreachable_message");
|
||||||
}
|
}
|
||||||
@@ -454,42 +452,41 @@ export const HomeIndex = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<Animated.ScrollView
|
||||||
scrollToOverflowEnabled={true}
|
scrollToOverflowEnabled={true}
|
||||||
ref={scrollViewRef}
|
ref={animatedScrollRef}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior='never'
|
contentInsetAdjustmentBehavior='never'
|
||||||
refreshControl={
|
scrollEventThrottle={16}
|
||||||
<RefreshControl
|
bounces={false}
|
||||||
refreshing={loading}
|
overScrollMode='never'
|
||||||
onRefresh={refetch}
|
style={{ marginTop: -headerOverlayOffset }}
|
||||||
tintColor='white' // For iOS
|
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
||||||
colors={["white"]} // For Android
|
onScroll={(event) => {
|
||||||
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
|
setScrollY(event.nativeEvent.contentOffset.y);
|
||||||
/>
|
}}
|
||||||
}
|
|
||||||
style={{ marginTop: Platform.isTV ? 0 : -100 }}
|
|
||||||
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
|
|
||||||
>
|
>
|
||||||
<AppleTVCarousel initialIndex={0} />
|
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
|
paddingTop: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col space-y-4'>
|
<View className='flex flex-col space-y-4'>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
if (section.type === "ScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
return (
|
return (
|
||||||
<ScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
key={index}
|
key={index}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
|
pageSize={section.pageSize}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -499,6 +496,8 @@ export const HomeIndex = () => {
|
|||||||
key={index}
|
key={index}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
|
scrollY={scrollY}
|
||||||
|
enableLazyLoading={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -507,33 +506,6 @@ export const HomeIndex = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='h-24' />
|
<View className='h-24' />
|
||||||
</ScrollView>
|
</Animated.ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to get suggestions
|
|
||||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
|
||||||
if (!userId) return [];
|
|
||||||
const response = await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Unknown"],
|
|
||||||
type: ["Series"],
|
|
||||||
});
|
|
||||||
return response.data.Items ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get the next up TV show for a series
|
|
||||||
async function getNextUp(
|
|
||||||
api: Api,
|
|
||||||
userId: string | undefined,
|
|
||||||
seriesId: string | undefined,
|
|
||||||
) {
|
|
||||||
if (!userId || !seriesId) return null;
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId,
|
|
||||||
seriesId,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
return response.data.Items?.[0] ?? null;
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type QueryKey,
|
type QueryKey,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -13,6 +14,7 @@ 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";
|
||||||
@@ -35,7 +37,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
pageSize = 20,
|
pageSize = 10,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
@@ -52,9 +54,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
return allPages.length * pageSize;
|
return allPages.length * pageSize;
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: true,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +65,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
// Flatten all pages into a single array
|
// Flatten all pages into a single array
|
||||||
const allItems = data?.pages.flat() || [];
|
const allItems = data?.pages.flat() || [];
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
|
||||||
|
return allItems.map((_, index) => index * itemWidth);
|
||||||
|
}, [allItems, orientation]);
|
||||||
|
|
||||||
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
@@ -125,6 +132,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
>
|
>
|
||||||
<View className='px-4 flex flex-row'>
|
<View className='px-4 flex flex-row'>
|
||||||
{allItems.map((item) => (
|
{allItems.map((item) => (
|
||||||
@@ -179,8 +188,13 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
))}
|
))}
|
||||||
{/* Loading indicator for next page */}
|
{/* Loading indicator for next page */}
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<View className='justify-center items-center w-16'>
|
<View
|
||||||
<ActivityIndicator size='small' color='#6366f1' />
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
marginTop: orientation === "horizontal" ? 37 : 70,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='small' color={Colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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";
|
||||||
@@ -21,6 +22,8 @@ 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> = ({
|
||||||
@@ -31,33 +34,44 @@ 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: 0,
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: true,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
|
enabled: enableLazyLoading ? isInView : true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (hideIfEmpty === true && data?.length === 0) return null;
|
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||||
|
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 {...props}>
|
<View ref={ref} onLayout={onLayout} {...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>
|
||||||
{isLoading === false && data?.length === 0 && (
|
{!shouldShowSkeleton && 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>
|
||||||
)}
|
)}
|
||||||
{isLoading ? (
|
{shouldShowSkeleton ? (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row gap-2 px-4
|
flex flex-row gap-2 px-4
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const CastSlide: React.FC<
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
data={details?.credits.cast}
|
data={details?.credits.cast}
|
||||||
ItemSeparatorComponent={() => <View className='w-2' />}
|
ItemSeparatorComponent={() => <View className='w-2' />}
|
||||||
estimatedItemSize={15}
|
|
||||||
keyExtractor={(item) => item?.id?.toString()}
|
keyExtractor={(item) => item?.id?.toString()}
|
||||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ 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,8 +48,22 @@ const RequestModal = forwardRef<
|
|||||||
userId: jellyseerrUser?.id,
|
userId: jellyseerrUser?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
|
||||||
|
const [rootFolderOpen, setRootFolderOpen] = useState(false);
|
||||||
|
const [tagsOpen, setTagsOpen] = useState(false);
|
||||||
|
const [usersOpen, setUsersOpen] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Reset all dropdown states when modal closes
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setQualityProfileOpen(false);
|
||||||
|
setRootFolderOpen(false);
|
||||||
|
setTagsOpen(false);
|
||||||
|
setUsersOpen(false);
|
||||||
|
onDismiss?.();
|
||||||
|
}, [onDismiss]);
|
||||||
|
|
||||||
const { data: serviceSettings } = useQuery({
|
const { data: serviceSettings } = useQuery({
|
||||||
queryKey: ["jellyseerr", "request", type, "service"],
|
queryKey: ["jellyseerr", "request", type, "service"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
@@ -130,14 +144,120 @@ const RequestModal = forwardRef<
|
|||||||
}, [defaultServiceDetails]);
|
}, [defaultServiceDetails]);
|
||||||
|
|
||||||
const seasonTitle = useMemo(() => {
|
const seasonTitle = useMemo(() => {
|
||||||
if (requestBody?.seasons && requestBody?.seasons?.length > 1) {
|
if (!requestBody?.seasons || requestBody.seasons.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (requestBody.seasons.length > 1) {
|
||||||
return t("jellyseerr.season_all");
|
return t("jellyseerr.season_all");
|
||||||
}
|
}
|
||||||
return t("jellyseerr.season_number", {
|
return t("jellyseerr.season_number", {
|
||||||
season_number: requestBody?.seasons,
|
season_number: requestBody.seasons[0],
|
||||||
});
|
});
|
||||||
}, [requestBody?.seasons]);
|
}, [requestBody?.seasons]);
|
||||||
|
|
||||||
|
const pathTitleExtractor = (item: RootFolder) =>
|
||||||
|
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||||
|
|
||||||
|
const qualityProfileOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
defaultServiceDetails?.profiles.map((profile) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: profile.name,
|
||||||
|
value: profile.id.toString(),
|
||||||
|
selected:
|
||||||
|
(requestOverrides.profileId || defaultProfile?.id) ===
|
||||||
|
profile.id,
|
||||||
|
onPress: () =>
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
profileId: profile.id,
|
||||||
|
})),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.profiles,
|
||||||
|
defaultProfile,
|
||||||
|
requestOverrides.profileId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootFolderOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: pathTitleExtractor(folder),
|
||||||
|
value: folder.id.toString(),
|
||||||
|
selected:
|
||||||
|
(requestOverrides.rootFolder || defaultFolder?.path) ===
|
||||||
|
folder.path,
|
||||||
|
onPress: () =>
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rootFolder: folder.path,
|
||||||
|
})),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.rootFolders,
|
||||||
|
defaultFolder,
|
||||||
|
requestOverrides.rootFolder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagsOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
defaultServiceDetails?.tags.map((tag) => ({
|
||||||
|
type: "toggle" as const,
|
||||||
|
label: tag.label,
|
||||||
|
value:
|
||||||
|
requestOverrides.tags?.includes(tag.id) ||
|
||||||
|
defaultTags.some((dt) => dt.id === tag.id),
|
||||||
|
onToggle: () =>
|
||||||
|
setRequestOverrides((prev) => {
|
||||||
|
const currentTags = prev.tags || defaultTags.map((t) => t.id);
|
||||||
|
const hasTag = currentTags.includes(tag.id);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
tags: hasTag
|
||||||
|
? currentTags.filter((id) => id !== tag.id)
|
||||||
|
: [...currentTags, tag.id],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags],
|
||||||
|
);
|
||||||
|
|
||||||
|
const usersOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
users?.map((user) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: user.displayName,
|
||||||
|
value: user.id.toString(),
|
||||||
|
selected:
|
||||||
|
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||||
|
onPress: () =>
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
userId: user.id,
|
||||||
|
})),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[users, jellyseerrUser, requestOverrides.userId],
|
||||||
|
);
|
||||||
|
|
||||||
const request = useCallback(() => {
|
const request = useCallback(() => {
|
||||||
const body = {
|
const body = {
|
||||||
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||||
@@ -163,15 +283,12 @@ const RequestModal = forwardRef<
|
|||||||
defaultTags,
|
defaultTags,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pathTitleExtractor = (item: RootFolder) =>
|
|
||||||
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={ref}
|
ref={ref}
|
||||||
enableDynamicSizing
|
enableDynamicSizing
|
||||||
enableDismissOnClose
|
enableDismissOnClose
|
||||||
onDismiss={onDismiss}
|
onDismiss={handleDismiss}
|
||||||
handleIndicatorStyle={{
|
handleIndicatorStyle={{
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
}}
|
}}
|
||||||
@@ -185,6 +302,7 @@ const RequestModal = forwardRef<
|
|||||||
appearsOnIndex={0}
|
appearsOnIndex={0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
stackBehavior='push'
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -199,70 +317,112 @@ const RequestModal = forwardRef<
|
|||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
{defaultService && defaultServiceDetails && users && (
|
{defaultService && defaultServiceDetails && users && (
|
||||||
<>
|
<>
|
||||||
<Dropdown
|
<View className='flex flex-col'>
|
||||||
data={defaultServiceDetails.profiles}
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
titleExtractor={(item) => item.name}
|
{t("jellyseerr.quality_profile")}
|
||||||
placeholderText={
|
</Text>
|
||||||
requestOverrides.profileName || defaultProfile.name
|
<PlatformDropdown
|
||||||
}
|
groups={qualityProfileOptions}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
trigger={
|
||||||
label={t("jellyseerr.quality_profile")}
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
onSelected={(item) =>
|
<Text numberOfLines={1}>
|
||||||
item &&
|
{defaultServiceDetails.profiles.find(
|
||||||
setRequestOverrides((prev) => ({
|
(p) =>
|
||||||
...prev,
|
p.id ===
|
||||||
profileId: item?.id,
|
(requestOverrides.profileId ||
|
||||||
}))
|
defaultProfile?.id),
|
||||||
}
|
)?.name || defaultProfile?.name}
|
||||||
title={t("jellyseerr.quality_profile")}
|
</Text>
|
||||||
/>
|
</View>
|
||||||
<Dropdown
|
}
|
||||||
data={defaultServiceDetails.rootFolders}
|
title={t("jellyseerr.quality_profile")}
|
||||||
titleExtractor={pathTitleExtractor}
|
open={qualityProfileOpen}
|
||||||
placeholderText={
|
onOpenChange={setQualityProfileOpen}
|
||||||
defaultFolder ? pathTitleExtractor(defaultFolder) : ""
|
/>
|
||||||
}
|
</View>
|
||||||
keyExtractor={(item) => item.id.toString()}
|
|
||||||
label={t("jellyseerr.root_folder")}
|
<View className='flex flex-col'>
|
||||||
onSelected={(item) =>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
item &&
|
{t("jellyseerr.root_folder")}
|
||||||
setRequestOverrides((prev) => ({
|
</Text>
|
||||||
...prev,
|
<PlatformDropdown
|
||||||
rootFolder: item.path,
|
groups={rootFolderOptions}
|
||||||
}))
|
trigger={
|
||||||
}
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
title={t("jellyseerr.root_folder")}
|
<Text numberOfLines={1}>
|
||||||
/>
|
{defaultServiceDetails.rootFolders.find(
|
||||||
<Dropdown
|
(f) =>
|
||||||
multiple
|
f.path ===
|
||||||
data={defaultServiceDetails.tags}
|
(requestOverrides.rootFolder ||
|
||||||
titleExtractor={(item) => item.label}
|
defaultFolder?.path),
|
||||||
placeholderText={defaultTags.map((t) => t.label).join(",")}
|
)
|
||||||
keyExtractor={(item) => item.id.toString()}
|
? pathTitleExtractor(
|
||||||
label={t("jellyseerr.tags")}
|
defaultServiceDetails.rootFolders.find(
|
||||||
onSelected={(...selected) =>
|
(f) =>
|
||||||
setRequestOverrides((prev) => ({
|
f.path ===
|
||||||
...prev,
|
(requestOverrides.rootFolder ||
|
||||||
tags: selected.map((i) => i.id),
|
defaultFolder?.path),
|
||||||
}))
|
)!,
|
||||||
}
|
)
|
||||||
title={t("jellyseerr.tags")}
|
: pathTitleExtractor(defaultFolder!)}
|
||||||
/>
|
</Text>
|
||||||
<Dropdown
|
</View>
|
||||||
data={users}
|
}
|
||||||
titleExtractor={(item) => item.displayName}
|
title={t("jellyseerr.root_folder")}
|
||||||
placeholderText={jellyseerrUser!.displayName}
|
open={rootFolderOpen}
|
||||||
keyExtractor={(item) => item.id.toString() || ""}
|
onOpenChange={setRootFolderOpen}
|
||||||
label={t("jellyseerr.request_as")}
|
/>
|
||||||
onSelected={(item) =>
|
</View>
|
||||||
item &&
|
|
||||||
setRequestOverrides((prev) => ({
|
<View className='flex flex-col'>
|
||||||
...prev,
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
userId: item?.id,
|
{t("jellyseerr.tags")}
|
||||||
}))
|
</Text>
|
||||||
}
|
<PlatformDropdown
|
||||||
title={t("jellyseerr.request_as")}
|
groups={tagsOptions}
|
||||||
/>
|
trigger={
|
||||||
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{requestOverrides.tags
|
||||||
|
? defaultServiceDetails.tags
|
||||||
|
.filter((t) =>
|
||||||
|
requestOverrides.tags!.includes(t.id),
|
||||||
|
)
|
||||||
|
.map((t) => t.label)
|
||||||
|
.join(", ") ||
|
||||||
|
defaultTags.map((t) => t.label).join(", ")
|
||||||
|
: defaultTags.map((t) => t.label).join(", ")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("jellyseerr.tags")}
|
||||||
|
open={tagsOpen}
|
||||||
|
onOpenChange={setTagsOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex flex-col'>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
|
{t("jellyseerr.request_as")}
|
||||||
|
</Text>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={usersOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{users.find(
|
||||||
|
(u) =>
|
||||||
|
u.id ===
|
||||||
|
(requestOverrides.userId || jellyseerrUser?.id),
|
||||||
|
)?.displayName || jellyseerrUser!.displayName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("jellyseerr.request_as")}
|
||||||
|
open={usersOpen}
|
||||||
|
onOpenChange={setUsersOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
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 } from "react-native";
|
import { View, type ViewProps, type ViewStyle } 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?: ContentStyle;
|
contentContainerStyle?: ViewStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props<T> extends SlideProps {
|
interface Props<T> extends SlideProps {
|
||||||
@@ -45,7 +44,6 @@ 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}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } 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";
|
||||||
@@ -21,20 +22,29 @@ 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: 0,
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
enabled: enableLazyLoading ? isInView : true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
@@ -57,10 +67,16 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
[api, user?.Id, collection?.Id],
|
[api, user?.Id, collection?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
const itemWidth = 120; // w-28 (112px) + mr-2 (8px)
|
||||||
|
// Generate offsets for a reasonable number of items
|
||||||
|
return Array.from({ length: 50 }, (_, index) => index * itemWidth);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View ref={ref} onLayout={onLayout} {...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>
|
||||||
@@ -82,6 +98,8 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
queryFn={fetchItems}
|
queryFn={fetchItems}
|
||||||
queryKey={["media-list", collection.Id!]}
|
queryKey={["media-list", collection.Id!]}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
115
components/search/DiscoverFilters.tsx
Normal file
115
components/search/DiscoverFilters.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
|
|
||||||
|
interface DiscoverFiltersProps {
|
||||||
|
searchFilterId: string;
|
||||||
|
orderFilterId: string;
|
||||||
|
jellyseerrOrderBy: JellyseerrSearchSort;
|
||||||
|
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
|
||||||
|
jellyseerrSortOrder: "asc" | "desc";
|
||||||
|
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||||
|
Number.isNaN(Number(v)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderOptions = ["asc", "desc"] as const;
|
||||||
|
|
||||||
|
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||||
|
searchFilterId,
|
||||||
|
orderFilterId,
|
||||||
|
jellyseerrOrderBy,
|
||||||
|
setJellyseerrOrderBy,
|
||||||
|
jellyseerrSortOrder,
|
||||||
|
setJellyseerrSortOrder,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
overflow: "visible",
|
||||||
|
height: 40,
|
||||||
|
width: 50,
|
||||||
|
marginLeft: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenu.Trigger>
|
||||||
|
<Button
|
||||||
|
variant='glass'
|
||||||
|
modifiers={[]}
|
||||||
|
systemImage='line.3.horizontal.decrease.circle'
|
||||||
|
></Button>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Items>
|
||||||
|
<Picker
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
options={sortOptions.map((item) =>
|
||||||
|
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
|
||||||
|
)}
|
||||||
|
variant='menu'
|
||||||
|
selectedIndex={sortOptions.indexOf(
|
||||||
|
jellyseerrOrderBy as unknown as string,
|
||||||
|
)}
|
||||||
|
onOptionSelected={(event: any) => {
|
||||||
|
const index = event.nativeEvent.index;
|
||||||
|
setJellyseerrOrderBy(
|
||||||
|
sortOptions[index] as unknown as JellyseerrSearchSort,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Picker
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
options={orderOptions.map((item) => t(`library.filters.${item}`))}
|
||||||
|
variant='menu'
|
||||||
|
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
|
||||||
|
onOptionSelected={(event: any) => {
|
||||||
|
const index = event.nativeEvent.index;
|
||||||
|
setJellyseerrSortOrder(orderOptions[index]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ContextMenu.Items>
|
||||||
|
</ContextMenu>
|
||||||
|
</Host>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android UI
|
||||||
|
return (
|
||||||
|
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||||
|
<FilterButton
|
||||||
|
id={searchFilterId}
|
||||||
|
queryKey='jellyseerr_search'
|
||||||
|
queryFn={async () =>
|
||||||
|
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||||
|
Number.isNaN(Number(v)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||||
|
values={[jellyseerrOrderBy]}
|
||||||
|
title={t("library.filters.sort_by")}
|
||||||
|
renderItemLabel={(item) =>
|
||||||
|
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||||
|
}
|
||||||
|
disableSearch={true}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
id={orderFilterId}
|
||||||
|
queryKey='jellysearr_search'
|
||||||
|
queryFn={async () => ["asc", "desc"]}
|
||||||
|
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||||
|
values={[jellyseerrSortOrder]}
|
||||||
|
title={t("library.filters.sort_order")}
|
||||||
|
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||||
|
disableSearch={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -34,7 +34,6 @@ 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}
|
||||||
|
|||||||
76
components/search/SearchTabButtons.tsx
Normal file
76
components/search/SearchTabButtons.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Button, Host } from "@expo/ui/swift-ui";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Tag } from "@/components/GenreTags";
|
||||||
|
|
||||||
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
|
interface SearchTabButtonsProps {
|
||||||
|
searchType: SearchType;
|
||||||
|
setSearchType: (type: SearchType) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
||||||
|
searchType,
|
||||||
|
setSearchType,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
width: 80,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant={searchType === "Library" ? "glassProminent" : "glass"}
|
||||||
|
onPress={() => setSearchType("Library")}
|
||||||
|
>
|
||||||
|
{t("search.library")}
|
||||||
|
</Button>
|
||||||
|
</Host>
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
width: 100,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant={searchType === "Discover" ? "glassProminent" : "glass"}
|
||||||
|
onPress={() => setSearchType("Discover")}
|
||||||
|
>
|
||||||
|
{t("search.discover")}
|
||||||
|
</Button>
|
||||||
|
</Host>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android UI
|
||||||
|
return (
|
||||||
|
<View className='flex flex-row gap-1 mr-1'>
|
||||||
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
|
<Tag
|
||||||
|
text={t("search.library")}
|
||||||
|
textClass='p-1'
|
||||||
|
className={searchType === "Library" ? "bg-purple-600" : undefined}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||||
|
<Tag
|
||||||
|
text={t("search.discover")}
|
||||||
|
textClass='p-1'
|
||||||
|
className={searchType === "Discover" ? "bg-purple-600" : undefined}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -47,7 +47,6 @@ 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) => (
|
||||||
@@ -284,7 +283,6 @@ 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
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
|||||||
<FlashList
|
<FlashList
|
||||||
contentContainerStyle={{ paddingLeft: 16 }}
|
contentContainerStyle={{ paddingLeft: 16 }}
|
||||||
horizontal
|
horizontal
|
||||||
estimatedItemSize={172}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
data={items}
|
data={items}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user