mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 16:18:09 +00:00
Compare commits
48 Commits
feature/ne
...
build-perf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d02007c213 | ||
|
|
3e20050b64 | ||
|
|
a5552db377 | ||
|
|
59e9913c78 | ||
|
|
cf203a7c28 | ||
|
|
2b2797005a | ||
|
|
c53acb16fc | ||
|
|
d7958296a5 | ||
|
|
53570a5ee5 | ||
|
|
e3b7dd8241 | ||
|
|
786d082706 | ||
|
|
164de0af0d | ||
|
|
820b30b7e2 | ||
|
|
5bc4c4a856 | ||
|
|
f7e0667416 | ||
|
|
8c68283c56 | ||
|
|
bb0149406c | ||
|
|
b1d5630025 | ||
|
|
a5f5531bb9 | ||
|
|
c2a3817fa8 | ||
|
|
700bb2dc79 | ||
|
|
d741ca3ecc | ||
|
|
ae9f6b1ce4 | ||
|
|
3f3f95571c | ||
|
|
cd3f1a8cee | ||
|
|
be745dc136 | ||
|
|
7b6fe0a6c0 | ||
|
|
fc44283f09 | ||
|
|
b2f6edc54e | ||
|
|
b42d033b87 | ||
|
|
1fb166bcd1 | ||
|
|
de6133581b | ||
|
|
9e26196fb3 | ||
|
|
e6f69e0c7b | ||
|
|
e8bf2b721e | ||
|
|
84d7ad72a6 | ||
|
|
edc9c8640d | ||
|
|
49ece8d34e | ||
|
|
98d571187e | ||
|
|
d1e55ca506 | ||
|
|
adec78832a | ||
|
|
19f604e986 | ||
|
|
4398810b6c | ||
|
|
0a8068e1b3 | ||
|
|
4b7986a125 | ||
|
|
3eaeaa3b4a | ||
|
|
9cd9861253 | ||
|
|
5e9755ea3c |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(find:*)",
|
||||
"Bash(bun install:*)",
|
||||
"Bash(bunx expo prebuild:*)",
|
||||
"Bash(bunx expo run:*)",
|
||||
"Bash(npx expo prebuild:*)",
|
||||
"Bash(npx expo run:*)",
|
||||
"Bash(xcodebuild:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
description: Don't write code directly in the ios folder.
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
We never write code directly in the ios folder. This code is generated by expo plugins.
|
||||
@@ -1 +1,15 @@
|
||||
EXPO_PUBLIC_WRITE_DEBUG=1
|
||||
# Streamyfin-specific debug flag
|
||||
EXPO_PUBLIC_WRITE_DEBUG=1
|
||||
|
||||
# Performance optimization (official Metro flag)
|
||||
EXPO_USE_METRO_REQUIRE=1
|
||||
|
||||
# TV development support (used in metro.config.js)
|
||||
# EXPO_TV=1
|
||||
# Uncomment the above line ONLY when working on TV features. Leave commented for mobile-only development to avoid issues.
|
||||
|
||||
# Fast resolver optimization (2025 feature)
|
||||
EXPO_USE_FAST_RESOLVER=1
|
||||
|
||||
# Bundle analysis for monitoring
|
||||
EXPO_ATLAS=1
|
||||
@@ -1 +1,26 @@
|
||||
EXPO_PUBLIC_WRITE_DEBUG=0
|
||||
# Streamyfin Production Configuration
|
||||
EXPO_PUBLIC_WRITE_DEBUG=0
|
||||
|
||||
# Production Performance Optimizations
|
||||
NODE_ENV=production
|
||||
EXPO_USE_METRO_REQUIRE=1
|
||||
EXPO_USE_FAST_RESOLVER=1
|
||||
|
||||
# Production Build Optimizations
|
||||
EXPO_OPTIMIZE_BUNDLE_SIZE=1
|
||||
EXPO_NO_CLIENT_ENV_VARS=1
|
||||
EXPO_LEGACY_BUNDLER=0
|
||||
|
||||
# Bundle Analysis (for monitoring)
|
||||
EXPO_ATLAS=0
|
||||
|
||||
# Production Cache Optimizations
|
||||
METRO_CACHE=1
|
||||
|
||||
# Security & Performance
|
||||
EXPO_NO_DOTENV=1
|
||||
FAST_REFRESH=0
|
||||
|
||||
# Production Bundle Features
|
||||
EXPO_USE_HERMES=1
|
||||
EXPO_MINIFY=1
|
||||
98
.github/copilot-instructions.md
vendored
98
.github/copilot-instructions.md
vendored
@@ -3,58 +3,94 @@
|
||||
## Project Overview
|
||||
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||
It supports mobile (iOS/Android) and TV platforms, and integrates with Jellyfin and Jellyseerr APIs.
|
||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
|
||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||
|
||||
## Main Technologies
|
||||
|
||||
- React Native (Expo)
|
||||
- TypeScript
|
||||
- React Query
|
||||
- Jotai (state management)
|
||||
- Jellyfin SDK (TypeScript)
|
||||
- BiomeJS (code formatting/linting)
|
||||
- EAS (Expo Application Services)
|
||||
- Shell scripting (for automation)
|
||||
- GitHub Actions (CI/CD)
|
||||
- **Runtime**: Bun (JavaScript/TypeScript execution)
|
||||
- **Framework**: React Native (Expo)
|
||||
- **Language**: TypeScript (strict mode)
|
||||
- **State Management**: Jotai (global state) + React Query (server state)
|
||||
- **API SDK**: Jellyfin SDK (TypeScript)
|
||||
- **Navigation**: Expo Router (file-based routing)
|
||||
- **Code Quality**: BiomeJS (formatting/linting)
|
||||
- **Build Platform**: EAS (Expo Application Services)
|
||||
- **CI/CD**: GitHub Actions with Bun
|
||||
|
||||
## Package Management
|
||||
|
||||
**CRITICAL: ALWAYS use `bun` for all package management operations**
|
||||
|
||||
- **NEVER use `npm`, `yarn` or `npx` commands**
|
||||
- Use `bun install` instead of `npm install` or `yarn install`
|
||||
- Use `bun add <package>` instead of `npm install <package>`
|
||||
- Use `bun remove <package>` instead of `npm uninstall <package>`
|
||||
- Use `bun run <script>` instead of `npm run <script>`
|
||||
- Use `bunx <command>` instead of `npx <command>`
|
||||
- For Expo: use `bunx create-expo-app` or `bunx @expo/cli`
|
||||
|
||||
## Code Structure
|
||||
|
||||
- `app/` – Main application code (screens, navigation, etc.)
|
||||
- `components/` – Reusable UI components
|
||||
- `providers/` – Context and API providers (e.g., JellyfinProvider.tsx)
|
||||
- `utils/` – Utility functions and atoms
|
||||
- `utils/` – Utility functions and Jotai atoms
|
||||
- `assets/` – Images and static assets
|
||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||
- `plugins/` – Expo/Metro plugins
|
||||
- `README.md` – Project documentation
|
||||
|
||||
## Coding Conventions
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for all new code.
|
||||
- Prefer functional React components.
|
||||
- Use hooks for state and side effects.
|
||||
- Use Jotai for global state.
|
||||
- Use React Query for data fetching/caching.
|
||||
- Use BiomeJS for formatting and linting.
|
||||
- Follow the established folder structure for screens/components.
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
- Use React Query for server state and caching
|
||||
- Follow BiomeJS formatting and linting rules
|
||||
- Use `const` over `let`, avoid `var` entirely
|
||||
- Implement proper error boundaries
|
||||
- Use React.memo() for performance optimization
|
||||
- Handle both mobile and TV navigation patterns
|
||||
|
||||
## API Usage
|
||||
## API Integration
|
||||
|
||||
- Use the Jellyfin SDK for all server interactions.
|
||||
- Use the `apiAtom` and `userAtom` from `JellyfinProvider` for authenticated API calls.
|
||||
- For navigation, use `expo-router`.
|
||||
- Use Jellyfin SDK for all server interactions
|
||||
- Access authenticated APIs via `apiAtom` and `userAtom` from JellyfinProvider
|
||||
- Implement proper loading states and error handling
|
||||
- Use React Query for caching and background updates
|
||||
- Handle offline scenarios gracefully
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
- Leverage Bun's superior runtime performance
|
||||
- Optimize FlatList components with proper props
|
||||
- Use lazy loading for non-critical components
|
||||
- Implement proper image caching strategies
|
||||
- Monitor bundle size and use tree-shaking effectively
|
||||
|
||||
## Testing
|
||||
|
||||
- Use Bun's built-in test runner when possible
|
||||
- Test files: `*.test.ts` or `*.test.tsx`
|
||||
- Run tests with: `bun test`
|
||||
- Mock external APIs in tests
|
||||
- Focus on testing business logic and custom hooks
|
||||
|
||||
## Commit Messages
|
||||
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (e.g., `feat:`, `fix:`, `chore:`).
|
||||
- Example: `feat(player): add Chromecast support`
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
Exemples:
|
||||
- `feat(player): add Chromecast support`
|
||||
- `fix(auth): handle expired JWT tokens`
|
||||
- `chore(deps): update Jellyfin SDK`
|
||||
|
||||
## Special Instructions
|
||||
|
||||
- When suggesting code, prefer using existing atoms, hooks, and utility functions.
|
||||
- When adding new features, ensure they are accessible via both mobile and TV navigation if relevant.
|
||||
- When updating dependencies or scripts, check for compatibility with Expo and EAS.
|
||||
|
||||
---
|
||||
- Prioritize cross-platform compatibility (mobile + TV)
|
||||
- Ensure accessibility for TV remote navigation
|
||||
- Use existing atoms, hooks, and utilities before creating new ones
|
||||
- Maintain compatibility with Expo and EAS workflows
|
||||
- Always verify Bun compatibility when suggesting new dependencies
|
||||
|
||||
**Copilot: Please use these instructions to provide context-aware suggestions and code completions for this repository.**
|
||||
12
.github/crowdin.yml
vendored
Normal file
12
.github/crowdin.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
"project_id_env": "CROWDIN_PROJECT_ID"
|
||||
"api_token_env": "CROWDIN_PERSONAL_TOKEN"
|
||||
"base_path": "."
|
||||
|
||||
"preserve_hierarchy": true
|
||||
|
||||
"files": [
|
||||
{
|
||||
"source": "translations/en.json",
|
||||
"translation": "translations/%two_letters_code%.json"
|
||||
}
|
||||
]
|
||||
2
.github/workflows/artifact-comment.yml
vendored
2
.github/workflows/artifact-comment.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 🔍 Get PR and Artifacts
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
// Check if we're running from a fork (more precise detection)
|
||||
|
||||
6
.github/workflows/ci-codeql.yml
vendored
6
.github/workflows/ci-codeql.yml
vendored
@@ -31,13 +31,13 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
|
||||
50
.github/workflows/crowdin.yml
vendored
50
.github/workflows/crowdin.yml
vendored
@@ -1,34 +1,50 @@
|
||||
name: Crowdin Action
|
||||
name: 🌐 Translation Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [develop]
|
||||
paths:
|
||||
- "translations/**"
|
||||
- "crowdin.yml"
|
||||
- "i18n.ts"
|
||||
- ".github/workflows/crowdin.yml"
|
||||
# Run weekly to pull new translations
|
||||
schedule:
|
||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
sync-translations:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: 📥 Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: l10n_crowdin_translations
|
||||
localization_branch_name: I10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: 'feat: New Crowdin Translations'
|
||||
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
||||
pull_request_base_branch_name: 'develop'
|
||||
pull_request_title: "feat: New Crowdin Translations"
|
||||
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||
pull_request_base_branch_name: "develop"
|
||||
pull_request_labels: "🌐 translation"
|
||||
# Quality control options
|
||||
skip_untranslated_strings: true
|
||||
export_only_approved: false
|
||||
# Commit customization
|
||||
commit_message: "feat(i18n): update translations from Crowdin"
|
||||
env:
|
||||
# A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository).
|
||||
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
25
.github/workflows/notification.yml
vendored
25
.github/workflows/notification.yml
vendored
@@ -1,13 +1,18 @@
|
||||
name: 🛎️ Discord Pull Request Notification
|
||||
name: 🛎️ Discord Notification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
branches: [develop]
|
||||
workflow_run:
|
||||
workflows: ["*"]
|
||||
types: [completed]
|
||||
branches: [develop]
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: 🛎️ Notify Discord
|
||||
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||
@@ -21,3 +26,21 @@ jobs:
|
||||
**By:** ${{ github.event.pull_request.user.login }}
|
||||
**Branch:** ${{ github.event.pull_request.head.ref }}
|
||||
🔗 ${{ github.event.pull_request.html_url }}
|
||||
|
||||
notify-on-failure:
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
|
||||
steps:
|
||||
- name: 🚨 Notify Discord on Failure
|
||||
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.WEBHOOK_FAILED_JOB_URL }}
|
||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||
with:
|
||||
args: |
|
||||
🚨 **Workflow Failed** in **${{ github.repository }}**
|
||||
**Workflow:** ${{ github.event.workflow_run.name }}
|
||||
**Branch:** ${{ github.event.workflow_run.head_branch }}
|
||||
**Triggered by:** ${{ github.event.workflow_run.triggering_actor.login }}
|
||||
**Commit:** ${{ github.event.workflow_run.head_commit.message }}
|
||||
🔗 ${{ github.event.workflow_run.html_url }}
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 🔄 Mark/Close Stale Issues
|
||||
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
# Global settings
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
76
.gitignore
vendored
76
.gitignore
vendored
@@ -1,27 +1,16 @@
|
||||
# Dependencies and Package Managers
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
modules/vlc-player/android/build
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
expo-env.d.ts
|
||||
|
||||
Streamyfin.app
|
||||
|
||||
*.mp4
|
||||
Streamyfin.app
|
||||
bun.lock
|
||||
bun.lockb
|
||||
package-lock.json
|
||||
|
||||
# Expo and React Native Build Artifacts
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
.tsbuildinfo
|
||||
|
||||
# Platform-specific Build Directories
|
||||
/ios
|
||||
/android
|
||||
/iostv
|
||||
@@ -29,21 +18,50 @@ package-lock.json
|
||||
/androidmobile
|
||||
/androidtv
|
||||
|
||||
# Module-specific Builds
|
||||
modules/vlc-player/android/build
|
||||
modules/player/android
|
||||
modules/hls-downloader/android/build
|
||||
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
# Generated Applications
|
||||
Streamyfin.app
|
||||
*.apk
|
||||
*.ipa
|
||||
.continuerc.json
|
||||
*.aab
|
||||
|
||||
# Certificates and Keys
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Debug and Temporary Files
|
||||
npm-debug.*
|
||||
*.orig.*
|
||||
*.mp4
|
||||
|
||||
# OS-specific Files
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDE and Editor Files
|
||||
.vscode/
|
||||
.idea/
|
||||
.ruby-lsp
|
||||
modules/hls-downloader/android/build
|
||||
streamyfin-4fec1-firebase-adminsdk.json
|
||||
.cursor/
|
||||
.claude/
|
||||
|
||||
# Environment and Configuration
|
||||
expo-env.d.ts
|
||||
.continuerc.json
|
||||
.env
|
||||
.env.local
|
||||
*.aab
|
||||
/version-backup-*
|
||||
bun.lockb
|
||||
|
||||
# Secrets and Credentials
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
streamyfin-4fec1-firebase-adminsdk.json
|
||||
|
||||
# Version and Backup Files
|
||||
/version-backup-*
|
||||
24
.vscode/extensions.json
vendored
Normal file
24
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
// ==========================================
|
||||
// Streamyfin - Recommended VS Code Extensions
|
||||
// ==========================================
|
||||
// Essential extensions for working with Streamyfin
|
||||
// See .github/copilot-instructions.md for coding standards
|
||||
|
||||
"recommendations": [
|
||||
// Code Quality & Formatting
|
||||
"biomejs.biome", // Fast formatter and linter for JavaScript/TypeScript - replaces ESLint + Prettier
|
||||
|
||||
// React Native & Expo
|
||||
"expo.vscode-expo-tools", // Official Expo extension - provides commands, debugging, and config IntelliSense
|
||||
"msjsdiag.vscode-react-native", // React Native debugging and IntelliSense - essential for RN development
|
||||
|
||||
// Developer Experience
|
||||
"bradlc.vscode-tailwindcss", // Tailwind CSS IntelliSense - autocomplete for NativeWind classes
|
||||
"yoavbls.pretty-ts-errors", // Makes TypeScript error messages human-readable with formatting and highlights
|
||||
"usernamehw.errorlens", // Shows errors and warnings inline in the editor - faster debugging
|
||||
|
||||
// Bun Support
|
||||
"oven.bun-vscode" // Official Bun extension - provides debugging and language support for Bun runtime
|
||||
]
|
||||
}
|
||||
176
.vscode/settings.json
vendored
176
.vscode/settings.json
vendored
@@ -1,24 +1,178 @@
|
||||
{
|
||||
// ==========================================
|
||||
// FORMATTING & LINTING
|
||||
// ==========================================
|
||||
|
||||
// Biome as default formatter
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.formatOnType": false,
|
||||
|
||||
// Language-specific formatters
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"prettier.printWidth": 120,
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "sswg.swift-lang"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[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"
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
# Global Modal System with Gorhom Bottom Sheet
|
||||
|
||||
This guide explains how to use the global modal system implemented in this project.
|
||||
|
||||
## Overview
|
||||
|
||||
The global modal system allows you to trigger a bottom sheet modal from anywhere in your app programmatically, and render any component inside it.
|
||||
|
||||
## Architecture
|
||||
|
||||
The system consists of three main parts:
|
||||
|
||||
1. **GlobalModalProvider** (`providers/GlobalModalProvider.tsx`) - Context provider that manages modal state
|
||||
2. **GlobalModal** (`components/GlobalModal.tsx`) - The actual modal component rendered at root level
|
||||
3. **useGlobalModal** hook - Hook to interact with the modal from anywhere
|
||||
|
||||
## Setup (Already Configured)
|
||||
|
||||
The system is already integrated into your app:
|
||||
|
||||
```tsx
|
||||
// In app/_layout.tsx
|
||||
<BottomSheetModalProvider>
|
||||
<GlobalModalProvider>
|
||||
{/* Your app content */}
|
||||
<GlobalModal />
|
||||
</GlobalModalProvider>
|
||||
</BottomSheetModalProvider>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { View, Text } from "react-native";
|
||||
|
||||
function MyComponent() {
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6'>
|
||||
<Text className='text-white text-2xl'>Hello from Modal!</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onPress={handleOpenModal} title="Open Modal" />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### With Custom Options
|
||||
|
||||
```tsx
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<YourCustomComponent />,
|
||||
{
|
||||
snapPoints: ["25%", "50%", "90%"], // Custom snap points
|
||||
enablePanDownToClose: true, // Allow swipe to close
|
||||
backgroundStyle: { // Custom background
|
||||
backgroundColor: "#000000",
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Programmatic Control
|
||||
|
||||
```tsx
|
||||
// Open modal
|
||||
showModal(<Content />);
|
||||
|
||||
// Close modal from within the modal content
|
||||
function ModalContent() {
|
||||
const { hideModal } = useGlobalModal();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Button onPress={hideModal} title="Close" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Close modal from outside
|
||||
hideModal();
|
||||
```
|
||||
|
||||
### In Event Handlers or Functions
|
||||
|
||||
```tsx
|
||||
function useApiCall() {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const result = await api.fetch();
|
||||
|
||||
// Show success modal
|
||||
showModal(
|
||||
<SuccessMessage data={result} />
|
||||
);
|
||||
} catch (error) {
|
||||
// Show error modal
|
||||
showModal(
|
||||
<ErrorMessage error={error} />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return fetchData;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `useGlobalModal()`
|
||||
|
||||
Returns an object with the following properties:
|
||||
|
||||
- **`showModal(content, options?)`** - Show the modal with given content
|
||||
- `content: ReactNode` - Any React component or element to render
|
||||
- `options?: ModalOptions` - Optional configuration object
|
||||
|
||||
- **`hideModal()`** - Programmatically hide the modal
|
||||
|
||||
- **`isVisible: boolean`** - Current visibility state of the modal
|
||||
|
||||
### `ModalOptions`
|
||||
|
||||
```typescript
|
||||
interface ModalOptions {
|
||||
enableDynamicSizing?: boolean; // Auto-size based on content (default: true)
|
||||
snapPoints?: (string | number)[]; // Fixed snap points (e.g., ["50%", "90%"])
|
||||
enablePanDownToClose?: boolean; // Allow swipe down to close (default: true)
|
||||
backgroundStyle?: object; // Custom background styles
|
||||
handleIndicatorStyle?: object; // Custom handle indicator styles
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||
- Simple content modal
|
||||
- Modal with custom snap points
|
||||
- Complex component in modal
|
||||
- Success/error modals triggered from functions
|
||||
|
||||
## Default Styling
|
||||
|
||||
The modal uses these default styles (can be overridden via options):
|
||||
|
||||
```typescript
|
||||
{
|
||||
enableDynamicSizing: true,
|
||||
enablePanDownToClose: true,
|
||||
backgroundStyle: {
|
||||
backgroundColor: "#171717", // Dark background
|
||||
},
|
||||
handleIndicatorStyle: {
|
||||
backgroundColor: "white",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep content in separate components** - Don't inline large JSX in `showModal()` calls
|
||||
2. **Use the hook in custom hooks** - Create specialized hooks like `useShowSuccessModal()` for reusable modal patterns
|
||||
3. **Handle cleanup** - The modal automatically clears content when closed
|
||||
4. **Avoid nesting** - Don't show modals from within modals
|
||||
5. **Consider UX** - Only use for important, contextual information that requires user attention
|
||||
|
||||
## Using with PlatformDropdown
|
||||
|
||||
When using `PlatformDropdown` with option groups, avoid setting a `title` on the `OptionGroup` if you're already passing a `title` prop to `PlatformDropdown`. This prevents nested menu behavior on iOS where users have to click through an extra layer.
|
||||
|
||||
```tsx
|
||||
// Good - No title in option group (title is on PlatformDropdown)
|
||||
const optionGroups: OptionGroup[] = [
|
||||
{
|
||||
options: items.map((item) => ({
|
||||
type: "radio",
|
||||
label: item.name,
|
||||
value: item,
|
||||
selected: item.id === selected?.id,
|
||||
onPress: () => onChange(item),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
title="Select Item" // Title here
|
||||
// ...
|
||||
/>
|
||||
|
||||
// Bad - Causes nested menu on iOS
|
||||
const optionGroups: OptionGroup[] = [
|
||||
{
|
||||
title: "Items", // This creates a nested Picker on iOS
|
||||
options: items.map((item) => ({
|
||||
type: "radio",
|
||||
label: item.name,
|
||||
value: item,
|
||||
selected: item.id === selected?.id,
|
||||
onPress: () => onChange(item),
|
||||
})),
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Modal doesn't appear
|
||||
- Ensure `GlobalModalProvider` is above the component calling `useGlobalModal()`
|
||||
- Check that `BottomSheetModalProvider` is present in the tree
|
||||
- Verify `GlobalModal` component is rendered
|
||||
|
||||
### Content is cut off
|
||||
- Use `enableDynamicSizing: true` for auto-sizing
|
||||
- Or specify appropriate `snapPoints`
|
||||
|
||||
### Modal won't close
|
||||
- Ensure `enablePanDownToClose` is `true`
|
||||
- Check that backdrop is clickable
|
||||
- Use `hideModal()` for programmatic closing
|
||||
@@ -1,5 +1,9 @@
|
||||
module.exports = ({ config }) => {
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
if (process.env.EXPO_TV === "1") {
|
||||
// Add TV-specific plugin for TV builds
|
||||
config.plugins.push("@react-native-tvos/config-tv");
|
||||
} else {
|
||||
// Add non-TV specific plugins for phone builds
|
||||
config.plugins.push("expo-background-task");
|
||||
|
||||
config.plugins.push([
|
||||
|
||||
6
app.json
6
app.json
@@ -8,7 +8,6 @@
|
||||
"scheme": "streamyfin",
|
||||
"userInterfaceStyle": "dark",
|
||||
"jsEngine": "hermes",
|
||||
"newArchEnabled": true,
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"requireFullScreen": true,
|
||||
@@ -54,7 +53,6 @@
|
||||
"googleServicesFile": "./google-services.json"
|
||||
},
|
||||
"plugins": [
|
||||
"@react-native-tvos/config-tv",
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
[
|
||||
@@ -78,7 +76,6 @@
|
||||
"useFrameworks": "static"
|
||||
},
|
||||
"android": {
|
||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||
"compileSdkVersion": 35,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
@@ -156,6 +153,7 @@
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
}
|
||||
},
|
||||
"newArchEnabled": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: Platform.OS !== "ios",
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.custom_links"),
|
||||
headerBlurEffect: "none",
|
||||
|
||||
@@ -22,11 +22,6 @@ export default function IndexLayout() {
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.home"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -48,88 +43,48 @@ export default function IndexLayout() {
|
||||
name='downloads/index'
|
||||
options={{
|
||||
title: t("home.downloads.downloads_title"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='downloads/[seriesId]'
|
||||
options={{
|
||||
title: t("home.downloads.tvseries"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='sessions/index'
|
||||
options={{
|
||||
title: t("home.sessions.title"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings'
|
||||
options={{
|
||||
title: t("home.settings.settings_title"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/marlin-search/page'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/jellyseerr/page'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/hide-libraries/page'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/logs/page'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -137,11 +92,6 @@ export default function IndexLayout() {
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
presentation: "modal",
|
||||
}}
|
||||
/>
|
||||
@@ -152,11 +102,6 @@ export default function IndexLayout() {
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function page() {
|
||||
title: series[0].item.SeriesName,
|
||||
});
|
||||
} else {
|
||||
storage.remove(seriesId);
|
||||
storage.delete(seriesId);
|
||||
router.back();
|
||||
}
|
||||
}, [series]);
|
||||
|
||||
@@ -62,10 +62,7 @@ export default function page() {
|
||||
);
|
||||
};
|
||||
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HomeIndex } from "@/components/home/HomeIndex";
|
||||
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||
|
||||
export default function page() {
|
||||
return <HomeIndex />;
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function settings() {
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<Text className='text-red-600 px-2'>
|
||||
<Text className='text-red-600'>
|
||||
{t("home.settings.log_out_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useNavigation } from "expo-router";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
|
||||
@@ -393,6 +393,7 @@ const page: React.FC = () => {
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
|
||||
@@ -19,29 +19,31 @@ import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import Cast from "@/components/jellyseerr/Cast";
|
||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import {
|
||||
type IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
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 insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
@@ -154,24 +156,6 @@ const Page: React.FC = () => {
|
||||
[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(() => {
|
||||
if (details) {
|
||||
navigation.setOptions({
|
||||
@@ -380,23 +364,50 @@ const Page: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col w-full'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
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")}
|
||||
<View className='flex flex-col'>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
</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>
|
||||
}
|
||||
title={t("jellyseerr.types")}
|
||||
/>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("jellyseerr.types")}
|
||||
</DropdownMenu.Label>
|
||||
{Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value], _idx) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
onSelect={() =>
|
||||
setIssueType(key as unknown as IssueType)
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{value}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||
|
||||
@@ -1,164 +1,85 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!settings?.libraryOptions) return null;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.library"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: () =>
|
||||
!pluginSettings?.libraryOptions?.locked &&
|
||||
!Platform.isTV && (
|
||||
<PlatformDropdown
|
||||
trigger={
|
||||
<>
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.library"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: () =>
|
||||
!pluginSettings?.libraryOptions?.locked &&
|
||||
!Platform.isTV && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setOptionsSheetOpen(true)}
|
||||
className='flex flex-row items-center justify-center w-9 h-9'
|
||||
>
|
||||
<Ionicons
|
||||
name='ellipsis-horizontal-outline'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
title={t("library.options.display")}
|
||||
groups={[
|
||||
{
|
||||
title: t("library.options.display"),
|
||||
options: [
|
||||
{
|
||||
type: "radio",
|
||||
label: t("library.options.row"),
|
||||
value: "row",
|
||||
selected: settings.libraryOptions.display === "row",
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "row",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "radio",
|
||||
label: t("library.options.list"),
|
||||
value: "list",
|
||||
selected: settings.libraryOptions.display === "list",
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "list",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("library.options.image_style"),
|
||||
options: [
|
||||
{
|
||||
type: "radio",
|
||||
label: t("library.options.poster"),
|
||||
value: "poster",
|
||||
selected:
|
||||
settings.libraryOptions.imageStyle === "poster",
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "poster",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "radio",
|
||||
label: t("library.options.cover"),
|
||||
value: "cover",
|
||||
selected:
|
||||
settings.libraryOptions.imageStyle === "cover",
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "cover",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Options",
|
||||
options: [
|
||||
{
|
||||
type: "toggle",
|
||||
label: t("library.options.show_titles"),
|
||||
value: settings.libraryOptions.showTitles,
|
||||
onToggle: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: !settings.libraryOptions.showTitles,
|
||||
},
|
||||
}),
|
||||
disabled:
|
||||
settings.libraryOptions.imageStyle === "poster",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
label: t("library.options.show_stats"),
|
||||
value: settings.libraryOptions.showStats,
|
||||
onToggle: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: !settings.libraryOptions.showStats,
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ export default function index() {
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||
paddingBottom: 150,
|
||||
paddingLeft: insets.left + 17,
|
||||
paddingRight: insets.right + 17,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
data={libraries}
|
||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||
@@ -105,6 +105,7 @@ export default function index() {
|
||||
<View className='h-4' />
|
||||
)
|
||||
}
|
||||
estimatedItemSize={200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import {
|
||||
JellyseerrSearchSort,
|
||||
@@ -31,10 +33,8 @@ import {
|
||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -282,29 +282,69 @@ export default function search() {
|
||||
maxLength={500}
|
||||
/>
|
||||
)}
|
||||
<View className='flex flex-col'>
|
||||
<View
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
}}
|
||||
>
|
||||
{jellyseerrApi && (
|
||||
<View className='pl-4 pr-4 pt-2 flex flex-row'>
|
||||
<SearchTabButtons
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
t={t}
|
||||
/>
|
||||
<ScrollView
|
||||
horizontal
|
||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||
>
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text={t("search.library")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Library" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text={t("search.discover")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{searchType === "Discover" &&
|
||||
!loading &&
|
||||
noResults &&
|
||||
debouncedSearch.length > 0 && (
|
||||
<DiscoverFilters
|
||||
searchFilterId={searchFilterId}
|
||||
orderFilterId={orderFilterId}
|
||||
jellyseerrOrderBy={jellyseerrOrderBy}
|
||||
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
||||
jellyseerrSortOrder={jellyseerrSortOrder}
|
||||
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
||||
t={t}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View className='mt-2'>
|
||||
|
||||
@@ -75,10 +75,7 @@ export default function page() {
|
||||
: require("react-native-volume-manager");
|
||||
|
||||
const downloadUtils = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => downloadUtils.getDownloadedItems(),
|
||||
[downloadUtils.getDownloadedItems],
|
||||
);
|
||||
const downloadedFiles = downloadUtils.getDownloadedItems();
|
||||
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
|
||||
109
app/_layout.tsx
109
app/_layout.tsx
@@ -2,10 +2,8 @@ import "@/augmentations";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||
import {
|
||||
apiAtom,
|
||||
getOrSetDeviceId,
|
||||
@@ -38,7 +36,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
|
||||
import * as Device from "expo-device";
|
||||
import { Paths } from "expo-file-system";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||
|
||||
@@ -145,7 +143,7 @@ if (!Platform.isTV) {
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = Paths.document.uri;
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
@@ -388,7 +386,7 @@ function Layout() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV || !BackGroundDownloader) {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -397,7 +395,7 @@ function Layout() {
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader?.checkForExistingDownloads().catch(
|
||||
BackGroundDownloader.checkForExistingDownloads().catch(
|
||||
(error: unknown) => {
|
||||
writeErrorLog("Failed to resume background downloads", error);
|
||||
},
|
||||
@@ -405,11 +403,9 @@ function Layout() {
|
||||
}
|
||||
});
|
||||
|
||||
BackGroundDownloader?.checkForExistingDownloads().catch(
|
||||
(error: unknown) => {
|
||||
writeErrorLog("Failed to resume background downloads", error);
|
||||
},
|
||||
);
|
||||
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
|
||||
writeErrorLog("Failed to resume background downloads", error);
|
||||
});
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
@@ -422,55 +418,52 @@ function Layout() {
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<GlobalModal />
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -81,10 +82,10 @@ const Login: React.FC = () => {
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
className='flex flex-row items-center pr-2 pl-1'
|
||||
className='flex flex-row items-center'
|
||||
>
|
||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||
<Text className=' ml-1 text-purple-600'>
|
||||
<Text className='ml-2 text-purple-600'>
|
||||
{t("login.change_server")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -115,4 +115,4 @@
|
||||
<path id="path259-2-6-4-6-7-0-1-0-5-9-4-7-1-5-7-6-2" class="cls-11" d="M46.97,39.46c5.94,0,10.75,4.81,10.75,10.75s-4.81,10.75-10.75,10.75-10.75-4.81-10.75-10.75c0-1.1.16-2.16.47-3.17.84,1.87,2.72,3.17,4.9,3.17,2.97,0,5.37-2.41,5.37-5.37,0-2.18-1.3-4.06-3.17-4.9,1-.31,2.06-.47,3.17-.47h.01Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -1,4 +1,4 @@
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
|
||||
declare module "react-native-mmkv" {
|
||||
interface MMKV {
|
||||
@@ -9,7 +9,7 @@ declare module "react-native-mmkv" {
|
||||
|
||||
// Add the augmentation methods directly to the MMKV prototype
|
||||
// This follows the recommended pattern while adding the helper methods your app uses
|
||||
(storage as any).get = function <T>(key: string): T | undefined {
|
||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
||||
try {
|
||||
const serializedItem = this.getString(key);
|
||||
if (!serializedItem) return undefined;
|
||||
@@ -20,10 +20,10 @@ declare module "react-native-mmkv" {
|
||||
}
|
||||
};
|
||||
|
||||
(storage as any).setAny = function (key: string, value: any | undefined): void {
|
||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||
try {
|
||||
if (value === undefined) {
|
||||
this.remove(key);
|
||||
this.delete(key);
|
||||
} else {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ module.exports = (api) => {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: ["nativewind/babel", "react-native-worklets/plugin"],
|
||||
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/*",
|
||||
|
||||
@@ -21,16 +21,15 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { ItemImage } from "../common/ItemImage";
|
||||
import { getItemNavigation } from "../common/TouchableItemRouter";
|
||||
import type { SelectedOptions } from "../ItemContent";
|
||||
import { PlayButton } from "../PlayButton";
|
||||
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
||||
import { ItemImage } from "./common/ItemImage";
|
||||
import { getItemNavigation } from "./common/TouchableItemRouter";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { PlayButton } from "./PlayButton";
|
||||
import { PlayedStatus } from "./PlayedStatus";
|
||||
|
||||
interface AppleTVCarouselProps {
|
||||
initialIndex?: number;
|
||||
@@ -46,11 +45,10 @@ const GRADIENT_HEIGHT_BOTTOM = 150;
|
||||
const LOGO_HEIGHT = 80;
|
||||
|
||||
// Position Constants
|
||||
const LOGO_BOTTOM_POSITION = 260;
|
||||
const GENRES_BOTTOM_POSITION = 220;
|
||||
const OVERVIEW_BOTTOM_POSITION = 165;
|
||||
const CONTROLS_BOTTOM_POSITION = 80;
|
||||
const DOTS_BOTTOM_POSITION = 40;
|
||||
const LOGO_BOTTOM_POSITION = 210;
|
||||
const GENRES_BOTTOM_POSITION = 170;
|
||||
const CONTROLS_BOTTOM_POSITION = 100;
|
||||
const DOTS_BOTTOM_POSITION = 60;
|
||||
|
||||
// Size Constants
|
||||
const DOT_HEIGHT = 6;
|
||||
@@ -60,15 +58,13 @@ const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
||||
const TEXT_SKELETON_HEIGHT = 20;
|
||||
const TEXT_SKELETON_WIDTH = 250;
|
||||
const OVERVIEW_SKELETON_HEIGHT = 16;
|
||||
const OVERVIEW_SKELETON_WIDTH = 400;
|
||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
||||
|
||||
// Spacing Constants
|
||||
const HORIZONTAL_PADDING = 40;
|
||||
const DOT_PADDING = 2;
|
||||
const DOT_GAP = 4;
|
||||
const CONTROLS_GAP = 10;
|
||||
const CONTROLS_GAP = 20;
|
||||
const _TEXT_MARGIN_TOP = 16;
|
||||
|
||||
// Border Radius Constants
|
||||
@@ -87,16 +83,13 @@ const VELOCITY_THRESHOLD = 400;
|
||||
|
||||
// Text Constants
|
||||
const GENRES_FONT_SIZE = 16;
|
||||
const OVERVIEW_FONT_SIZE = 14;
|
||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
||||
const TEXT_SHADOW_RADIUS = 2;
|
||||
const MAX_GENRES_COUNT = 2;
|
||||
const MAX_BUTTON_WIDTH = 300;
|
||||
const OVERVIEW_MAX_LINES = 2;
|
||||
const OVERVIEW_MAX_WIDTH = "80%";
|
||||
|
||||
// Opacity Constants
|
||||
const OVERLAY_OPACITY = 0.3;
|
||||
const OVERLAY_OPACITY = 0.4;
|
||||
const DOT_INACTIVE_OPACITY = 0.6;
|
||||
const TEXT_OPACITY = 0.9;
|
||||
|
||||
@@ -175,7 +168,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres", "Overview"],
|
||||
fields: ["Genres"],
|
||||
limit: 2,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
@@ -190,7 +183,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user.Id,
|
||||
fields: ["MediaSourceCount", "Genres", "Overview"],
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
limit: 2,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
@@ -209,7 +202,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user.Id,
|
||||
limit: 2,
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
});
|
||||
@@ -355,8 +348,6 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
};
|
||||
});
|
||||
|
||||
const togglePlayedStatus = useMarkAsPlayed(items);
|
||||
|
||||
const renderDots = () => {
|
||||
if (!hasItems || items.length <= 1) return null;
|
||||
|
||||
@@ -482,36 +473,6 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
/>
|
||||
</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 */}
|
||||
<View
|
||||
style={{
|
||||
@@ -728,39 +689,6 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
</TouchableOpacity>
|
||||
</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 */}
|
||||
<View
|
||||
style={{
|
||||
@@ -791,10 +719,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
</View>
|
||||
|
||||
{/* Mark as Played */}
|
||||
<MarkAsPlayedLargeButton
|
||||
isPlayed={item.UserData?.Played ?? false}
|
||||
onToggle={togglePlayedStatus}
|
||||
/>
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
@@ -18,8 +20,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const audioStreams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
@@ -31,58 +31,55 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
[audioStreams, selected],
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
title={t("item_card.audio")}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
expoUIConfig={{
|
||||
hostStyle: { flex: 1 },
|
||||
<View
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.audio")}
|
||||
</Text>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text className='' numberOfLines={1}>
|
||||
{selectedAudioSteam?.DisplayTitle}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side='bottom'
|
||||
align='start'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
|
||||
{audioStreams?.map((audio, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (audio.Index !== null && audio.Index !== undefined)
|
||||
onChange(audio.Index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{audio.DisplayTitle}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
@@ -59,8 +61,6 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
@@ -76,59 +76,53 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
);
|
||||
}, [inverted]);
|
||||
|
||||
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>
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
title={t("item_card.quality")}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
expoUIConfig={{
|
||||
hostStyle: { flex: 1 },
|
||||
<View
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.quality")}
|
||||
</Text>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text style={{}} className='' numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||
{sorted.map((b) => (
|
||||
<DropdownMenu.Item
|
||||
key={b.key}
|
||||
onSelect={() => {
|
||||
onChange(b);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -66,10 +66,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
|
||||
const { processes, startBackgroundDownload, getDownloadedItems } =
|
||||
useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
@@ -362,18 +359,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 w-full'>
|
||||
<View className='items-start'>
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.bitrate}
|
||||
/>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 w-full items-start'>
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.bitrate}
|
||||
/>
|
||||
{itemsNotDownloaded.length > 1 && (
|
||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
||||
@@ -385,23 +380,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
)}
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<View>
|
||||
<View className='items-start'>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.mediaSource}
|
||||
/>
|
||||
</View>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.mediaSource}
|
||||
/>
|
||||
{selectedOptions?.mediaSource && (
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<AudioTrackSelector
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
@@ -434,7 +427,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button onPress={acceptDownloadOptions} color='purple'>
|
||||
<Button
|
||||
className='mt-auto'
|
||||
onPress={acceptDownloadOptions}
|
||||
color='purple'
|
||||
>
|
||||
{t("item_card.download.download_button")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* Example Usage of Global Modal
|
||||
*
|
||||
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||
* You can delete this file after understanding how it works.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
/**
|
||||
* Example 1: Simple Content Modal
|
||||
*/
|
||||
export const SimpleModalExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6'>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||
<Text className='text-white mb-4'>
|
||||
This is a simple modal with just some text content.
|
||||
</Text>
|
||||
<Text className='text-neutral-400'>
|
||||
Swipe down or tap outside to close.
|
||||
</Text>
|
||||
</View>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 2: Modal with Custom Snap Points
|
||||
*/
|
||||
export const CustomSnapPointsExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6' style={{ minHeight: 400 }}>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||
Custom Snap Points
|
||||
</Text>
|
||||
<Text className='text-white mb-4'>
|
||||
This modal has custom snap points (25%, 50%, 90%).
|
||||
</Text>
|
||||
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||
<Text className='text-white'>
|
||||
Try dragging the modal to different heights!
|
||||
</Text>
|
||||
</View>
|
||||
</View>,
|
||||
{
|
||||
snapPoints: ["25%", "50%", "90%"],
|
||||
enableDynamicSizing: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 3: Complex Component in Modal
|
||||
*/
|
||||
const SettingsModalContent = () => {
|
||||
const { hideModal } = useGlobalModal();
|
||||
|
||||
const settings = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Notifications",
|
||||
icon: "notifications-outline" as const,
|
||||
enabled: true,
|
||||
},
|
||||
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||
{
|
||||
id: 3,
|
||||
title: "Auto-play",
|
||||
icon: "play-outline" as const,
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View className='p-6'>
|
||||
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||
|
||||
{settings.map((setting, index) => (
|
||||
<View
|
||||
key={setting.id}
|
||||
className={`flex-row items-center justify-between py-4 ${
|
||||
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||
}`}
|
||||
>
|
||||
<View className='flex-row items-center gap-3'>
|
||||
<Ionicons name={setting.icon} size={24} color='white' />
|
||||
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`w-12 h-7 rounded-full ${
|
||||
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={hideModal}
|
||||
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||
>
|
||||
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComplexModalExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(<SettingsModalContent />);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||
*/
|
||||
export const useShowSuccessModal = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
return (message: string) => {
|
||||
showModal(
|
||||
<View className='p-6 items-center'>
|
||||
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||
<Ionicons name='checkmark' size={48} color='white' />
|
||||
</View>
|
||||
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||
<Text className='text-white text-center'>{message}</Text>
|
||||
</View>,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Main Demo Component
|
||||
*/
|
||||
export const GlobalModalDemo = () => {
|
||||
const showSuccess = useShowSuccessModal();
|
||||
|
||||
return (
|
||||
<View className='p-6 gap-4'>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||
Global Modal Examples
|
||||
</Text>
|
||||
|
||||
<SimpleModalExample />
|
||||
<CustomSnapPointsExample />
|
||||
<ComplexModalExample />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => showSuccess("Operation completed successfully!")}
|
||||
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useCallback } from "react";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
/**
|
||||
* GlobalModal Component
|
||||
*
|
||||
* This component renders a global bottom sheet modal that can be controlled
|
||||
* from anywhere in the app using the useGlobalModal hook.
|
||||
*
|
||||
* Place this component at the root level of your app (in _layout.tsx)
|
||||
* after BottomSheetModalProvider.
|
||||
*/
|
||||
export const GlobalModal = () => {
|
||||
const { hideModal, modalState, modalRef } = useGlobalModal();
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
hideModal();
|
||||
}
|
||||
},
|
||||
[hideModal],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const defaultOptions = {
|
||||
enableDynamicSizing: true,
|
||||
enablePanDownToClose: true,
|
||||
backgroundStyle: {
|
||||
backgroundColor: "#171717",
|
||||
},
|
||||
handleIndicatorStyle: {
|
||||
backgroundColor: "white",
|
||||
},
|
||||
};
|
||||
|
||||
// Merge default options with provided options
|
||||
const modalOptions = { ...defaultOptions, ...modalState.options };
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={modalRef}
|
||||
{...(modalOptions.snapPoints
|
||||
? { snapPoints: modalOptions.snapPoints }
|
||||
: { enableDynamicSizing: modalOptions.enableDynamicSizing })}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={modalOptions.handleIndicatorStyle}
|
||||
backgroundStyle={modalOptions.backgroundStyle}
|
||||
enablePanDownToClose={modalOptions.enablePanDownToClose}
|
||||
enableDismissOnClose
|
||||
>
|
||||
{modalState.content}
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
@@ -2,11 +2,13 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
@@ -21,7 +23,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||
@@ -44,60 +46,50 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
return getDisplayName(selected);
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
title={t("item_card.video")}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
expoUIConfig={{
|
||||
hostStyle: { flex: 1 },
|
||||
<View
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.video")}
|
||||
</Text>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
|
||||
<Text numberOfLines={1}>{selectedName}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side='bottom'
|
||||
align='start'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
||||
{item.MediaSources?.map((source, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
onChange(source);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{getDisplayName(source)}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
// Option types
|
||||
export type RadioOption<T = any> = {
|
||||
type: "radio";
|
||||
label: string;
|
||||
value: T;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type ToggleOption = {
|
||||
type: "toggle";
|
||||
label: string;
|
||||
value: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type Option = RadioOption | ToggleOption;
|
||||
|
||||
// Option group structure
|
||||
export type OptionGroup = {
|
||||
title?: string;
|
||||
options: Option[];
|
||||
};
|
||||
|
||||
interface PlatformDropdownProps {
|
||||
trigger?: React.ReactNode;
|
||||
title?: string;
|
||||
groups: OptionGroup[];
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
expoUIConfig?: {
|
||||
hostStyle?: any;
|
||||
};
|
||||
bottomSheetConfig?: {
|
||||
enableDynamicSizing?: boolean;
|
||||
enablePanDownToClose?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
||||
<View
|
||||
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
|
||||
value ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
option,
|
||||
isLast,
|
||||
}) => {
|
||||
const isToggle = option.type === "toggle";
|
||||
const handlePress = isToggle ? option.onToggle : option.onPress;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
disabled={option.disabled}
|
||||
className={`px-4 py-3 flex flex-row items-center justify-between ${
|
||||
option.disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||
{isToggle ? (
|
||||
<ToggleSwitch value={option.value} />
|
||||
) : option.selected ? (
|
||||
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
||||
) : (
|
||||
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{!isLast && (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-700 mx-4'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
||||
<View className='mb-6'>
|
||||
{group.title && (
|
||||
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
|
||||
{group.title}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className='bg-neutral-800 rounded-xl overflow-hidden'
|
||||
>
|
||||
{group.options.map((option, index) => (
|
||||
<OptionItem
|
||||
key={index}
|
||||
option={option}
|
||||
isLast={index === group.options.length - 1}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const BottomSheetContent: React.FC<{
|
||||
title?: string;
|
||||
groups: OptionGroup[];
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
}> = ({ title, groups, onOptionSelect }) => {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
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,
|
||||
onOpenChange,
|
||||
onOptionSelect,
|
||||
expoUIConfig,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
const handlePress = () => {
|
||||
if (Platform.OS === "android") {
|
||||
onOpenChange?.(true);
|
||||
showModal(
|
||||
<BottomSheetContent
|
||||
title={title}
|
||||
groups={groups}
|
||||
onOptionSelect={onOptionSelect}
|
||||
/>,
|
||||
{
|
||||
snapPoints: ["90%"],
|
||||
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Close modal when open prop changes to false
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && open === false) {
|
||||
hideModal();
|
||||
}
|
||||
}, [open, hideModal]);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<Host style={expoUIConfig?.hostStyle}>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<View className=''>
|
||||
{trigger || <Button variant='bordered'>Show Menu</Button>}
|
||||
</View>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
{groups.flatMap((group, groupIndex) => {
|
||||
// Check if this group has radio options
|
||||
const radioOptions = group.options.filter(
|
||||
(opt) => opt.type === "radio",
|
||||
) as RadioOption[];
|
||||
const toggleOptions = group.options.filter(
|
||||
(opt) => opt.type === "toggle",
|
||||
) as ToggleOption[];
|
||||
|
||||
const items = [];
|
||||
|
||||
// Add Picker for radio options ONLY if there's a group title
|
||||
// Otherwise render as individual buttons
|
||||
if (radioOptions.length > 0) {
|
||||
if (group.title) {
|
||||
// Use Picker for grouped options
|
||||
items.push(
|
||||
<Picker
|
||||
key={`picker-${groupIndex}`}
|
||||
label={group.title}
|
||||
options={radioOptions.map((opt) => opt.label)}
|
||||
variant='menu'
|
||||
selectedIndex={radioOptions.findIndex(
|
||||
(opt) => opt.selected,
|
||||
)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
const selectedOption = radioOptions[index];
|
||||
selectedOption?.onPress();
|
||||
onOptionSelect?.(selectedOption?.value);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
// Render radio options as direct buttons
|
||||
radioOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
<Button
|
||||
key={`radio-${groupIndex}-${optionIndex}`}
|
||||
systemImage={
|
||||
option.selected ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</Button>,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add Buttons for toggle options
|
||||
toggleOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
<Button
|
||||
key={`toggle-${groupIndex}-${optionIndex}`}
|
||||
systemImage={
|
||||
option.value ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
onPress={() => {
|
||||
option.onToggle();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</Button>,
|
||||
);
|
||||
});
|
||||
|
||||
return items;
|
||||
})}
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
// Android: Trigger button for bottom modal
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
{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,12 +1,11 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Button, Host } from "@expo/ui/swift-ui";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
PlayServicesState,
|
||||
@@ -34,9 +33,10 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import type { Button } from "./Button";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
isOffline?: boolean;
|
||||
@@ -364,46 +364,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
* *********************
|
||||
*/
|
||||
|
||||
if (Platform.OS === "ios")
|
||||
return (
|
||||
<Host
|
||||
style={{
|
||||
height: 50,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='glassProminent'
|
||||
onPress={onPress}
|
||||
color={effectiveColors.primary}
|
||||
>
|
||||
<View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name='cast' size={22} />
|
||||
<CastButton tintColor='transparent' />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</Button>
|
||||
</Host>
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
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 { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
@@ -19,8 +21,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const subtitleStreams = useMemo(() => {
|
||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||
}, [source]);
|
||||
@@ -30,83 +30,64 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
[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;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
title={t("item_card.subtitles")}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
expoUIConfig={{
|
||||
hostStyle: { flex: 1 },
|
||||
<View
|
||||
className='flex col shrink justify-start place-self-start items-start'
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col ' {...props}>
|
||||
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.subtitles")}
|
||||
</Text>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text className=' '>
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||
: t("item_card.none")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side='bottom'
|
||||
align='start'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"-1"}
|
||||
onSelect={() => {
|
||||
onChange(-1);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (subtitle.Index !== undefined && subtitle.Index !== null)
|
||||
onChange(subtitle.Index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{subtitle.DisplayTitle}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const TitleHeader: React.FC<Props> = ({ ...props }) => {
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Button, Host } from "@expo/ui/swift-ui";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Platform, View } from "react-native";
|
||||
import { RoundButton } from "../RoundButton";
|
||||
|
||||
interface MarkAsPlayedLargeButtonProps {
|
||||
isPlayed: boolean;
|
||||
onToggle: (isPlayed: boolean) => void;
|
||||
}
|
||||
|
||||
export const MarkAsPlayedLargeButton: React.FC<
|
||||
MarkAsPlayedLargeButtonProps
|
||||
> = ({ isPlayed, onToggle }) => {
|
||||
if (Platform.OS === "ios")
|
||||
return (
|
||||
<Host
|
||||
style={{
|
||||
flex: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
}}
|
||||
>
|
||||
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
|
||||
<View>
|
||||
<Ionicons
|
||||
name='checkmark'
|
||||
size={24}
|
||||
color='white'
|
||||
style={{
|
||||
marginTop: 6,
|
||||
marginLeft: 1,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Button>
|
||||
</Host>
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={() => onToggle(isPlayed)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
125
components/common/Dropdown.tsx
Normal file
125
components/common/Dropdown.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
|
||||
interface Props<T> {
|
||||
data: T[];
|
||||
disabled?: boolean;
|
||||
placeholderText?: string;
|
||||
keyExtractor: (item: T) => string;
|
||||
titleExtractor: (item: T) => string | undefined;
|
||||
title: string | ReactNode;
|
||||
label: string;
|
||||
onSelected: (...item: T[]) => void;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const Dropdown = <T,>({
|
||||
data,
|
||||
disabled,
|
||||
placeholderText,
|
||||
keyExtractor,
|
||||
titleExtractor,
|
||||
title,
|
||||
label,
|
||||
onSelected,
|
||||
multiple = false,
|
||||
...props
|
||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const [selected, setSelected] = useState<T[]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (selected !== undefined) {
|
||||
onSelected(...selected);
|
||||
}
|
||||
}, [selected, onSelected]);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{typeof title === "string" ? (
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text style={{}} className='' numberOfLines={1}>
|
||||
{selected?.length !== undefined
|
||||
? selected.map(titleExtractor).join(",")
|
||||
: placeholderText}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
||||
{data.map((item, _idx) =>
|
||||
multiple ? (
|
||||
<DropdownMenu.CheckboxItem
|
||||
value={
|
||||
selected?.some((s) => keyExtractor(s) === keyExtractor(item))
|
||||
? "on"
|
||||
: "off"
|
||||
}
|
||||
key={keyExtractor(item)}
|
||||
onValueChange={(
|
||||
next: "on" | "off",
|
||||
_previous: "on" | "off",
|
||||
) => {
|
||||
setSelected((p) => {
|
||||
const prev = p || [];
|
||||
if (next === "on") {
|
||||
return [...prev, item];
|
||||
}
|
||||
return [
|
||||
...prev.filter(
|
||||
(p) => keyExtractor(p) !== keyExtractor(item),
|
||||
),
|
||||
];
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{titleExtractor(item)}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
) : (
|
||||
<DropdownMenu.Item
|
||||
key={keyExtractor(item)}
|
||||
onSelect={() => setSelected([item])}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{titleExtractor(item)}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
),
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</DisabledSetting>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import { type PropsWithChildren, useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
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 { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
import type {
|
||||
@@ -32,33 +38,90 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
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)")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!result) return;
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!result) return;
|
||||
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
// @ts-expect-error
|
||||
params: {
|
||||
...result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest: canRequest.toString(),
|
||||
posterSrc,
|
||||
mediaType,
|
||||
},
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
// @ts-expect-error
|
||||
params: {
|
||||
...result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest: canRequest.toString(),
|
||||
posterSrc,
|
||||
mediaType,
|
||||
},
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</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;
|
||||
};
|
||||
|
||||
@@ -14,10 +14,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const { getDownloadedItemSize, getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const [size, setSize] = useState<string | undefined>();
|
||||
|
||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||
|
||||
@@ -8,10 +8,10 @@ import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescrip
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { forwardRef, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import Dropdown from "@/components/common/Dropdown";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type {
|
||||
QualityProfile,
|
||||
@@ -138,115 +138,6 @@ const RequestModal = forwardRef<
|
||||
});
|
||||
}, [requestBody?.seasons]);
|
||||
|
||||
const pathTitleExtractor = (item: RootFolder) =>
|
||||
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||
|
||||
const qualityProfileOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.quality_profile"),
|
||||
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,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const rootFolderOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.root_folder"),
|
||||
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,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const tagsOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.tags"),
|
||||
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, t],
|
||||
);
|
||||
|
||||
const usersOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.request_as"),
|
||||
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, t],
|
||||
);
|
||||
|
||||
const request = useCallback(() => {
|
||||
const body = {
|
||||
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||
@@ -272,6 +163,9 @@ const RequestModal = forwardRef<
|
||||
defaultTags,
|
||||
]);
|
||||
|
||||
const pathTitleExtractor = (item: RootFolder) =>
|
||||
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={ref}
|
||||
@@ -305,104 +199,70 @@ const RequestModal = forwardRef<
|
||||
<View className='flex flex-col space-y-2'>
|
||||
{defaultService && defaultServiceDetails && users && (
|
||||
<>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.quality_profile")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={qualityProfileOptions}
|
||||
trigger={
|
||||
<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 numberOfLines={1}>
|
||||
{defaultServiceDetails.profiles.find(
|
||||
(p) =>
|
||||
p.id ===
|
||||
(requestOverrides.profileId ||
|
||||
defaultProfile?.id),
|
||||
)?.name || defaultProfile?.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.root_folder")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={rootFolderOptions}
|
||||
trigger={
|
||||
<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 numberOfLines={1}>
|
||||
{defaultServiceDetails.rootFolders.find(
|
||||
(f) =>
|
||||
f.path ===
|
||||
(requestOverrides.rootFolder ||
|
||||
defaultFolder?.path),
|
||||
)
|
||||
? pathTitleExtractor(
|
||||
defaultServiceDetails.rootFolders.find(
|
||||
(f) =>
|
||||
f.path ===
|
||||
(requestOverrides.rootFolder ||
|
||||
defaultFolder?.path),
|
||||
)!,
|
||||
)
|
||||
: pathTitleExtractor(defaultFolder!)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.tags")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={tagsOptions}
|
||||
trigger={
|
||||
<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 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>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("jellyseerr.tags")}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.request_as")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={usersOptions}
|
||||
trigger={
|
||||
<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 numberOfLines={1}>
|
||||
{users.find(
|
||||
(u) =>
|
||||
u.id ===
|
||||
(requestOverrides.userId || jellyseerrUser?.id),
|
||||
)?.displayName || jellyseerrUser!.displayName}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("jellyseerr.request_as")}
|
||||
/>
|
||||
</View>
|
||||
<Dropdown
|
||||
data={defaultServiceDetails.profiles}
|
||||
titleExtractor={(item) => item.name}
|
||||
placeholderText={
|
||||
requestOverrides.profileName || defaultProfile.name
|
||||
}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.quality_profile")}
|
||||
onSelected={(item) =>
|
||||
item &&
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
profileId: item?.id,
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
/>
|
||||
<Dropdown
|
||||
data={defaultServiceDetails.rootFolders}
|
||||
titleExtractor={pathTitleExtractor}
|
||||
placeholderText={
|
||||
defaultFolder ? pathTitleExtractor(defaultFolder) : ""
|
||||
}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.root_folder")}
|
||||
onSelected={(item) =>
|
||||
item &&
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
rootFolder: item.path,
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
/>
|
||||
<Dropdown
|
||||
multiple
|
||||
data={defaultServiceDetails.tags}
|
||||
titleExtractor={(item) => item.label}
|
||||
placeholderText={defaultTags.map((t) => t.label).join(",")}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.tags")}
|
||||
onSelected={(...selected) =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
tags: selected.map((i) => i.id),
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.tags")}
|
||||
/>
|
||||
<Dropdown
|
||||
data={users}
|
||||
titleExtractor={(item) => item.displayName}
|
||||
placeholderText={jellyseerrUser!.displayName}
|
||||
keyExtractor={(item) => item.id.toString() || ""}
|
||||
label={t("jellyseerr.request_as")}
|
||||
onSelected={(item) =>
|
||||
item &&
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
userId: item?.id,
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.request_as")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||
import { Platform, View } from "react-native";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
|
||||
interface DiscoverFiltersProps {
|
||||
searchFilterId: string;
|
||||
orderFilterId: string;
|
||||
jellyseerrOrderBy: JellyseerrSearchSort;
|
||||
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
|
||||
jellyseerrSortOrder: "asc" | "desc";
|
||||
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
Number.isNaN(Number(v)),
|
||||
);
|
||||
|
||||
const orderOptions = ["asc", "desc"] as const;
|
||||
|
||||
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
searchFilterId,
|
||||
orderFilterId,
|
||||
jellyseerrOrderBy,
|
||||
setJellyseerrOrderBy,
|
||||
jellyseerrSortOrder,
|
||||
setJellyseerrSortOrder,
|
||||
t,
|
||||
}) => {
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<Host
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
overflow: "visible",
|
||||
height: 40,
|
||||
width: 50,
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<Button
|
||||
variant='glass'
|
||||
modifiers={[]}
|
||||
systemImage='line.3.horizontal.decrease.circle'
|
||||
></Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
<Picker
|
||||
label={t("library.filters.sort_by")}
|
||||
options={sortOptions.map((item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
|
||||
)}
|
||||
variant='menu'
|
||||
selectedIndex={sortOptions.indexOf(
|
||||
jellyseerrOrderBy as unknown as string,
|
||||
)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
setJellyseerrOrderBy(
|
||||
sortOptions[index] as unknown as JellyseerrSearchSort,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Picker
|
||||
label={t("library.filters.sort_order")}
|
||||
options={orderOptions.map((item) => t(`library.filters.${item}`))}
|
||||
variant='menu'
|
||||
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
setJellyseerrSortOrder(orderOptions[index]);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
// Android UI
|
||||
return (
|
||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||
<FilterButton
|
||||
id={searchFilterId}
|
||||
queryKey='jellyseerr_search'
|
||||
queryFn={async () =>
|
||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
Number.isNaN(Number(v)),
|
||||
)
|
||||
}
|
||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||
values={[jellyseerrOrderBy]}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||
}
|
||||
disableSearch={true}
|
||||
/>
|
||||
<FilterButton
|
||||
id={orderFilterId}
|
||||
queryKey='jellysearr_search'
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||
values={[jellyseerrSortOrder]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||
disableSearch={true}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Button, Host } from "@expo/ui/swift-ui";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
interface SearchTabButtonsProps {
|
||||
searchType: SearchType;
|
||||
setSearchType: (type: SearchType) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
||||
searchType,
|
||||
setSearchType,
|
||||
t,
|
||||
}) => {
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<>
|
||||
<Host
|
||||
style={{
|
||||
height: 40,
|
||||
width: 80,
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant={searchType === "Library" ? "glassProminent" : "glass"}
|
||||
onPress={() => setSearchType("Library")}
|
||||
>
|
||||
{t("search.library")}
|
||||
</Button>
|
||||
</Host>
|
||||
<Host
|
||||
style={{
|
||||
height: 40,
|
||||
width: 100,
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant={searchType === "Discover" ? "glassProminent" : "glass"}
|
||||
onPress={() => setSearchType("Discover")}
|
||||
>
|
||||
{t("search.discover")}
|
||||
</Button>
|
||||
</Host>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Android UI
|
||||
return (
|
||||
<View className='flex flex-row gap-1 mr-1'>
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text={t("search.library")}
|
||||
textClass='p-1'
|
||||
className={searchType === "Library" ? "bg-purple-600" : undefined}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text={t("search.discover")}
|
||||
textClass='p-1'
|
||||
className={searchType === "Discover" ? "bg-purple-600" : undefined}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { t } from "i18next";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { t } from "i18next";
|
||||
import { Text } from "../common/Text";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -53,32 +55,6 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
[state, item, keys],
|
||||
);
|
||||
|
||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||
Number(a[keys.index]) - Number(b[keys.index]);
|
||||
|
||||
const optionGroups = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("item_card.seasons"),
|
||||
options:
|
||||
seasons?.sort(sortByIndex).map((season: any) => {
|
||||
const title =
|
||||
season[keys.title] ||
|
||||
season.Name ||
|
||||
`Season ${season.IndexNumber}`;
|
||||
return {
|
||||
type: "radio" as const,
|
||||
label: title,
|
||||
value: season.Id || season.IndexNumber,
|
||||
selected: Number(season[keys.index]) === Number(seasonIndex),
|
||||
onPress: () => onSelect(season),
|
||||
};
|
||||
}) || [],
|
||||
},
|
||||
],
|
||||
[seasons, keys, seasonIndex, onSelect],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTv) return;
|
||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||
@@ -120,19 +96,45 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
keys,
|
||||
]);
|
||||
|
||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||
Number(a[keys.index]) - Number(b[keys.index]);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text>
|
||||
{t("item_card.season")} {seasonIndex}
|
||||
</Text>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-row'>
|
||||
<TouchableOpacity className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text>
|
||||
{t("item_card.season")} {seasonIndex}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
title={t("item_card.seasons")}
|
||||
/>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side='bottom'
|
||||
align='start'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>{t("item_card.seasons")}</DropdownMenu.Label>
|
||||
{seasons?.sort(sortByIndex).map((season: any) => {
|
||||
const title =
|
||||
season[keys.title] || season.Name || `Season ${season.IndexNumber}`;
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
key={season.Id || season.IndexNumber}
|
||||
onSelect={() => onSelect(season)}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{title}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,10 +29,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMemo } from "react";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { APP_LANGUAGES } from "@/i18n";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -15,31 +15,6 @@ export const AppLanguageSelector: React.FC<Props> = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const optionGroups = useMemo(() => {
|
||||
const options = [
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.languages.system"),
|
||||
value: "system",
|
||||
selected: !settings?.preferedLanguage,
|
||||
onPress: () => updateSettings({ preferedLanguage: undefined }),
|
||||
},
|
||||
...APP_LANGUAGES.map((lang) => ({
|
||||
type: "radio" as const,
|
||||
label: lang.label,
|
||||
value: lang.value,
|
||||
selected: lang.value === settings?.preferedLanguage,
|
||||
onPress: () => updateSettings({ preferedLanguage: lang.value }),
|
||||
})),
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
options,
|
||||
},
|
||||
];
|
||||
}, [settings?.preferedLanguage, t, updateSettings]);
|
||||
|
||||
if (isTv) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
@@ -47,19 +22,54 @@ export const AppLanguageSelector: React.FC<Props> = () => {
|
||||
<View>
|
||||
<ListGroup title={t("home.settings.languages.title")}>
|
||||
<ListItem title={t("home.settings.languages.app_language")}>
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={
|
||||
<View className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text>
|
||||
{APP_LANGUAGES.find(
|
||||
(l) => l.value === settings?.preferedLanguage,
|
||||
)?.label || t("home.settings.languages.system")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.languages.title")}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side='bottom'
|
||||
align='start'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("home.settings.languages.title")}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"unknown"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
preferedLanguage: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{t("home.settings.languages.system")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{APP_LANGUAGES?.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l?.value ?? "unknown"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
preferedLanguage: l.value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
import { useMedia } from "./MediaContext";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
@@ -21,39 +22,6 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const cultures = media.cultures;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const optionGroups = useMemo(() => {
|
||||
const options = [
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.audio.none"),
|
||||
value: "none",
|
||||
selected: !settings?.defaultAudioLanguage,
|
||||
onPress: () => updateSettings({ defaultAudioLanguage: null }),
|
||||
},
|
||||
...(cultures?.map((culture) => ({
|
||||
type: "radio" as const,
|
||||
label:
|
||||
culture.DisplayName ||
|
||||
culture.ThreeLetterISOLanguageName ||
|
||||
"Unknown",
|
||||
value:
|
||||
culture.ThreeLetterISOLanguageName ||
|
||||
culture.DisplayName ||
|
||||
"unknown",
|
||||
selected:
|
||||
culture.ThreeLetterISOLanguageName ===
|
||||
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName,
|
||||
onPress: () => updateSettings({ defaultAudioLanguage: culture }),
|
||||
})) || []),
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
options,
|
||||
},
|
||||
];
|
||||
}, [cultures, settings?.defaultAudioLanguage, t, updateSettings]);
|
||||
|
||||
if (isTv) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
@@ -80,10 +48,9 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.audio.audio_language")}>
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3 '>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{settings?.defaultAudioLanguage?.DisplayName ||
|
||||
t("home.settings.audio.none")}
|
||||
@@ -93,10 +60,48 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.audio.language")}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side='bottom'
|
||||
align='start'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("home.settings.audio.language")}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"none-audio"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{t("home.settings.audio.none")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{cultures?.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{l.DisplayName}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
@@ -37,7 +38,7 @@ import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
||||
import { AppleTVCarousel } from "../AppleTVCarousel";
|
||||
|
||||
type ScrollingCollectionListSection = {
|
||||
type: "ScrollingCollectionList";
|
||||
@@ -90,11 +91,6 @@ export const HomeIndex = () => {
|
||||
prevIsConnected.current = isConnected;
|
||||
}, [isConnected, invalidateCache]);
|
||||
|
||||
const hasDownloads = useMemo(() => {
|
||||
if (Platform.isTV) return false;
|
||||
return getDownloadedItems().length > 0;
|
||||
}, [getDownloadedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
@@ -102,6 +98,7 @@ export const HomeIndex = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const hasDownloads = getDownloadedItems().length > 0;
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
@@ -118,7 +115,7 @@ export const HomeIndex = () => {
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, router, hasDownloads]);
|
||||
}, [navigation, router]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanCacheDirectory().catch((_e) =>
|
||||
@@ -360,6 +357,16 @@ export const HomeIndex = () => {
|
||||
});
|
||||
return response.data || [];
|
||||
}
|
||||
if (section.custom) {
|
||||
const response = await api.get<BaseItemDtoQueryResult>(
|
||||
section.custom.endpoint,
|
||||
{
|
||||
params: { ...(section.custom.query || {}), userId: user?.Id },
|
||||
headers: section.custom.headers || {},
|
||||
},
|
||||
);
|
||||
return response.data.Items || [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
@@ -5,10 +5,10 @@ import { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Platform, Switch, View } from "react-native";
|
||||
import { Linking, Platform, Switch, TouchableOpacity } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import Dropdown from "@/components/common/Dropdown";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
@@ -89,52 +89,6 @@ export const OtherSettings: React.FC = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
const orientationOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: orientations.map((orientation) => ({
|
||||
type: "radio" as const,
|
||||
label: t(ScreenOrientationEnum[orientation]),
|
||||
value: String(orientation),
|
||||
selected: orientation === settings?.defaultVideoOrientation,
|
||||
onPress: () =>
|
||||
updateSettings({ defaultVideoOrientation: orientation }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[orientations, settings?.defaultVideoOrientation, t, updateSettings],
|
||||
);
|
||||
|
||||
const bitrateOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: BITRATES.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
label: bitrate.key,
|
||||
value: bitrate.key,
|
||||
selected: bitrate.key === settings?.defaultBitrate?.key,
|
||||
onPress: () => updateSettings({ defaultBitrate: bitrate }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.defaultBitrate?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
const autoPlayEpisodeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: AUTOPLAY_EPISODES_COUNT(t).map((item) => ({
|
||||
type: "radio" as const,
|
||||
label: item.key,
|
||||
value: item.key,
|
||||
selected: item.key === settings?.maxAutoPlayEpisodeCount?.key,
|
||||
onPress: () => updateSettings({ maxAutoPlayEpisodeCount: item }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
@@ -160,10 +114,16 @@ export const OtherSettings: React.FC = () => {
|
||||
settings.followDeviceOrientation
|
||||
}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={orientationOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Dropdown
|
||||
data={orientations}
|
||||
disabled={
|
||||
pluginSettings?.defaultVideoOrientation?.locked ||
|
||||
settings.followDeviceOrientation
|
||||
}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => t(ScreenOrientationEnum[item])}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
orientationTranslations[
|
||||
@@ -176,9 +136,12 @@ export const OtherSettings: React.FC = () => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.other.orientation")}
|
||||
onSelected={(defaultVideoOrientation) =>
|
||||
updateSettings({ defaultVideoOrientation })
|
||||
}
|
||||
title={t("home.settings.other.orientation")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -251,10 +214,13 @@ export const OtherSettings: React.FC = () => {
|
||||
title={t("home.settings.other.default_quality")}
|
||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={bitrateOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Dropdown
|
||||
data={BITRATES}
|
||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||
keyExtractor={(item) => item.key}
|
||||
titleExtractor={(item) => item.key}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{settings.defaultBitrate?.key}
|
||||
</Text>
|
||||
@@ -263,9 +229,10 @@ export const OtherSettings: React.FC = () => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("home.settings.other.default_quality")}
|
||||
label={t("home.settings.other.default_quality")}
|
||||
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
@@ -281,10 +248,12 @@ export const OtherSettings: React.FC = () => {
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Dropdown
|
||||
data={AUTOPLAY_EPISODES_COUNT(t)}
|
||||
keyExtractor={(item) => item.key}
|
||||
titleExtractor={(item) => item.key}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||
</Text>
|
||||
@@ -293,9 +262,12 @@ export const OtherSettings: React.FC = () => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.other.max_auto_play_episode_count")}
|
||||
onSelected={(maxAutoPlayEpisodeCount) =>
|
||||
updateSettings({ maxAutoPlayEpisodeCount })
|
||||
}
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
|
||||
const _DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import Dropdown from "@/components/common/Dropdown";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import {
|
||||
OUTLINE_THICKNESS,
|
||||
type OutlineThickness,
|
||||
VLC_COLORS,
|
||||
type VLCColor,
|
||||
} from "@/constants/SubtitleConstants";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
import { useMedia } from "./MediaContext";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
import { OUTLINE_THICKNESS, VLC_COLORS } from "@/constants/SubtitleConstants";
|
||||
|
||||
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
@@ -29,6 +27,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const cultures = media.cultures;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get VLC subtitle settings from the settings system
|
||||
const textColor = settings?.vlcTextColor ?? "White";
|
||||
const backgroundColor = settings?.vlcBackgroundColor ?? "Black";
|
||||
const outlineColor = settings?.vlcOutlineColor ?? "Black";
|
||||
const outlineThickness = settings?.vlcOutlineThickness ?? "Normal";
|
||||
const backgroundOpacity = settings?.vlcBackgroundOpacity ?? 128;
|
||||
const outlineOpacity = settings?.vlcOutlineOpacity ?? 255;
|
||||
const isBold = settings?.vlcIsBold ?? false;
|
||||
|
||||
if (isTv) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
const subtitleModes = [
|
||||
SubtitlePlaybackMode.Default,
|
||||
SubtitlePlaybackMode.Smart,
|
||||
@@ -46,133 +56,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
|
||||
};
|
||||
|
||||
const subtitleLanguageOptionGroups = useMemo(() => {
|
||||
const options = [
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.subtitles.none"),
|
||||
value: "none",
|
||||
selected: !settings?.defaultSubtitleLanguage,
|
||||
onPress: () => updateSettings({ defaultSubtitleLanguage: null }),
|
||||
},
|
||||
...(cultures?.map((culture) => ({
|
||||
type: "radio" as const,
|
||||
label: culture.DisplayName || "Unknown",
|
||||
value:
|
||||
culture.ThreeLetterISOLanguageName ||
|
||||
culture.DisplayName ||
|
||||
"unknown",
|
||||
selected:
|
||||
culture.ThreeLetterISOLanguageName ===
|
||||
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName,
|
||||
onPress: () => updateSettings({ defaultSubtitleLanguage: culture }),
|
||||
})) || []),
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
options,
|
||||
},
|
||||
];
|
||||
}, [cultures, settings?.defaultSubtitleLanguage, t, updateSettings]);
|
||||
|
||||
const subtitleModeOptionGroups = useMemo(() => {
|
||||
const options = subtitleModes.map((mode) => ({
|
||||
type: "radio" as const,
|
||||
label: t(subtitleModeKeys[mode]) || String(mode),
|
||||
value: String(mode),
|
||||
selected: mode === settings?.subtitleMode,
|
||||
onPress: () => updateSettings({ subtitleMode: mode }),
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
options,
|
||||
},
|
||||
];
|
||||
}, [settings?.subtitleMode, t, updateSettings]);
|
||||
|
||||
const textColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcTextColor || "White") === color,
|
||||
onPress: () => updateSettings({ vlcTextColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcTextColor, t, updateSettings]);
|
||||
|
||||
const backgroundColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcBackgroundColor || "Black") === color,
|
||||
onPress: () => updateSettings({ vlcBackgroundColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcBackgroundColor, t, updateSettings]);
|
||||
|
||||
const outlineColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcOutlineColor || "Black") === color,
|
||||
onPress: () => updateSettings({ vlcOutlineColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineColor, t, updateSettings]);
|
||||
|
||||
const outlineThicknessOptionGroups = useMemo(() => {
|
||||
const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
|
||||
const options = thicknesses.map((thickness) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.thickness.${thickness}`),
|
||||
value: thickness,
|
||||
selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
|
||||
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineThickness, t, updateSettings]);
|
||||
|
||||
const backgroundOpacityOptionGroups = useMemo(() => {
|
||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
const options = opacities.map((opacity) => ({
|
||||
type: "radio" as const,
|
||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
||||
value: opacity,
|
||||
selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
|
||||
onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcBackgroundOpacity, updateSettings]);
|
||||
|
||||
const outlineOpacityOptionGroups = useMemo(() => {
|
||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
const options = opacities.map((opacity) => ({
|
||||
type: "radio" as const,
|
||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
||||
value: opacity,
|
||||
selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
|
||||
onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineOpacity, updateSettings]);
|
||||
|
||||
if (isTv) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup
|
||||
@@ -184,10 +67,20 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
}
|
||||
>
|
||||
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
||||
<PlatformDropdown
|
||||
groups={subtitleLanguageOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Dropdown
|
||||
data={[
|
||||
{
|
||||
DisplayName: t("home.settings.subtitles.none"),
|
||||
ThreeLetterISOLanguageName: "none-subs",
|
||||
},
|
||||
...(cultures ?? []),
|
||||
]}
|
||||
keyExtractor={(item) =>
|
||||
item?.ThreeLetterISOLanguageName ?? "unknown"
|
||||
}
|
||||
titleExtractor={(item) => item?.DisplayName}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{settings?.defaultSubtitleLanguage?.DisplayName ||
|
||||
t("home.settings.subtitles.none")}
|
||||
@@ -197,9 +90,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.subtitles.language")}
|
||||
onSelected={(defaultSubtitleLanguage) =>
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage:
|
||||
defaultSubtitleLanguage.DisplayName ===
|
||||
t("home.settings.subtitles.none")
|
||||
? null
|
||||
: defaultSubtitleLanguage,
|
||||
})
|
||||
}
|
||||
title={t("home.settings.subtitles.language")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -207,10 +109,13 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
title={t("home.settings.subtitles.subtitle_mode")}
|
||||
disabled={pluginSettings?.subtitleMode?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={subtitleModeOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Dropdown
|
||||
data={subtitleModes}
|
||||
disabled={pluginSettings?.subtitleMode?.locked}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => t(subtitleModeKeys[item]) || String(item)}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(subtitleModeKeys[settings?.subtitleMode]) ||
|
||||
t("home.settings.subtitles.loading")}
|
||||
@@ -220,9 +125,10 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("home.settings.subtitles.subtitle_mode")}
|
||||
label={t("home.settings.subtitles.subtitle_mode")}
|
||||
onSelected={(subtitleMode) => updateSettings({ subtitleMode })}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -253,120 +159,144 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.text_color")}>
|
||||
<PlatformDropdown
|
||||
groups={textColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Dropdown
|
||||
data={Object.keys(VLC_COLORS)}
|
||||
keyExtractor={(item) => item}
|
||||
titleExtractor={(item) =>
|
||||
t(`home.settings.subtitles.colors.${item}`)
|
||||
}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
|
||||
)}
|
||||
{t(`home.settings.subtitles.colors.${textColor}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("home.settings.subtitles.text_color")}
|
||||
label={t("home.settings.subtitles.text_color")}
|
||||
onSelected={(value) => updateSettings({ vlcTextColor: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.background_color")}>
|
||||
<PlatformDropdown
|
||||
groups={backgroundColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Dropdown
|
||||
data={Object.keys(VLC_COLORS)}
|
||||
keyExtractor={(item) => item}
|
||||
titleExtractor={(item) =>
|
||||
t(`home.settings.subtitles.colors.${item}`)
|
||||
}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
|
||||
)}
|
||||
{t(`home.settings.subtitles.colors.${backgroundColor}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.subtitles.background_color")}
|
||||
onSelected={(value) =>
|
||||
updateSettings({ vlcBackgroundColor: value })
|
||||
}
|
||||
title={t("home.settings.subtitles.background_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_color")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Dropdown
|
||||
data={Object.keys(VLC_COLORS)}
|
||||
keyExtractor={(item) => item}
|
||||
titleExtractor={(item) =>
|
||||
t(`home.settings.subtitles.colors.${item}`)
|
||||
}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
|
||||
)}
|
||||
{t(`home.settings.subtitles.colors.${outlineColor}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_color")}
|
||||
label={t("home.settings.subtitles.outline_color")}
|
||||
onSelected={(value) => updateSettings({ vlcOutlineColor: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineThicknessOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Dropdown
|
||||
data={Object.keys(OUTLINE_THICKNESS)}
|
||||
keyExtractor={(item) => item}
|
||||
titleExtractor={(item) =>
|
||||
t(`home.settings.subtitles.thickness.${item}`)
|
||||
}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
|
||||
)}
|
||||
{t(`home.settings.subtitles.thickness.${outlineThickness}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.subtitles.outline_thickness")}
|
||||
onSelected={(value) =>
|
||||
updateSettings({ vlcOutlineThickness: value })
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_thickness")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||
<PlatformDropdown
|
||||
groups={backgroundOpacityOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
|
||||
<Dropdown
|
||||
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((backgroundOpacity / 255) * 100)}%`}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.subtitles.background_opacity")}
|
||||
onSelected={(value) =>
|
||||
updateSettings({ vlcBackgroundOpacity: value })
|
||||
}
|
||||
title={t("home.settings.subtitles.background_opacity")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineOpacityOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
|
||||
<Dropdown
|
||||
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((outlineOpacity / 255) * 100)}%`}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_opacity")}
|
||||
label={t("home.settings.subtitles.outline_opacity")}
|
||||
onSelected={(value) => updateSettings({ vlcOutlineOpacity: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.bold_text")}>
|
||||
<Switch
|
||||
value={settings?.vlcIsBold ?? false}
|
||||
value={isBold}
|
||||
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect: Platform.OS === "ios" ? "none" : undefined,
|
||||
headerBlurEffect: "none",
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
};
|
||||
|
||||
|
||||
@@ -56,10 +56,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}, []);
|
||||
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={"flex flex-row w-full pt-2"}
|
||||
>
|
||||
<View className='mr-auto' pointerEvents='box-none'>
|
||||
<View className='mr-auto'>
|
||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
@@ -120,9 +120,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
<View pointerEvents='auto'>
|
||||
<DropdownView />
|
||||
</View>
|
||||
<DropdownView />
|
||||
</VideoProvider>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useMemo } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import React, { useState } from "react";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterSheet } from "@/components/filters/FilterSheet";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
export type ScaleFactor =
|
||||
@@ -96,51 +94,56 @@ export const ScaleFactorSelector: React.FC<ScaleFactorSelectorProps> = ({
|
||||
disabled = false,
|
||||
}) => {
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
const handleScaleSelect = (scale: ScaleFactor) => {
|
||||
onScaleChange(scale);
|
||||
lightHapticFeedback();
|
||||
};
|
||||
|
||||
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||
return [
|
||||
{
|
||||
options: SCALE_FACTOR_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
selected: option.id === currentScale,
|
||||
onPress: () => handleScaleSelect(option.id),
|
||||
disabled,
|
||||
})),
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentScale, disabled]);
|
||||
|
||||
const trigger = useMemo(
|
||||
() => (
|
||||
<View
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||
>
|
||||
<Ionicons name='search-outline' size={24} color='white' />
|
||||
</View>
|
||||
),
|
||||
[disabled],
|
||||
const currentOption = SCALE_FACTOR_OPTIONS.find(
|
||||
(option) => option.id === currentScale,
|
||||
);
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title='Scale Factor'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<TouchableOpacity
|
||||
disabled={disabled}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Ionicons name='search-outline' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title='Scale Factor'
|
||||
data={SCALE_FACTOR_OPTIONS}
|
||||
values={currentOption ? [currentOption] : []}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const option = item as ScaleFactorOption;
|
||||
return (
|
||||
option.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
option.description.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderItemLabel={(item) => {
|
||||
const option = item as ScaleFactorOption;
|
||||
return <Text>{option.label}</Text>;
|
||||
}}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as ScaleFactorOption | undefined;
|
||||
if (chosen) {
|
||||
handleScaleSelect(chosen.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useMemo } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import React, { useState } from "react";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterSheet } from "@/components/filters/FilterSheet";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
|
||||
@@ -55,51 +53,56 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
disabled = false,
|
||||
}) => {
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||
onRatioChange(ratio);
|
||||
lightHapticFeedback();
|
||||
};
|
||||
|
||||
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||
return [
|
||||
{
|
||||
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
selected: option.id === currentRatio,
|
||||
onPress: () => handleRatioSelect(option.id),
|
||||
disabled,
|
||||
})),
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentRatio, disabled]);
|
||||
|
||||
const trigger = useMemo(
|
||||
() => (
|
||||
<View
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||
>
|
||||
<Ionicons name='crop-outline' size={24} color='white' />
|
||||
</View>
|
||||
),
|
||||
[disabled],
|
||||
const currentOption = ASPECT_RATIO_OPTIONS.find(
|
||||
(option) => option.id === currentRatio,
|
||||
);
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title='Aspect Ratio'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<TouchableOpacity
|
||||
disabled={disabled}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Ionicons name='crop-outline' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title='Aspect Ratio'
|
||||
data={ASPECT_RATIO_OPTIONS}
|
||||
values={currentOption ? [currentOption] : []}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const option = item as AspectRatioOption;
|
||||
return (
|
||||
option.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
option.description.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderItemLabel={(item) => {
|
||||
const option = item as AspectRatioOption;
|
||||
return <Text>{option.label}</Text>;
|
||||
}}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as AspectRatioOption | undefined;
|
||||
if (chosen) {
|
||||
handleRatioSelect(chosen.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
|
||||
@@ -19,6 +23,10 @@ const DropdownView = () => {
|
||||
ControlContext?.mediaSource,
|
||||
];
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [open, setOpen] = useState(false);
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["75%"], []);
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||
useLocalSearchParams<{
|
||||
@@ -31,127 +39,248 @@ const DropdownView = () => {
|
||||
offline: string;
|
||||
}>();
|
||||
|
||||
// Use ref to track playbackPosition without causing re-renders
|
||||
const playbackPositionRef = useRef(playbackPosition);
|
||||
playbackPositionRef.current = playbackPosition;
|
||||
|
||||
const isOffline = offline === "true";
|
||||
|
||||
// Stabilize IDs to prevent unnecessary recalculations
|
||||
const itemIdRef = useRef(item.Id);
|
||||
const mediaSourceIdRef = useRef(mediaSource?.Id);
|
||||
itemIdRef.current = item.Id;
|
||||
mediaSourceIdRef.current = mediaSource?.Id;
|
||||
|
||||
const changeBitrate = useCallback(
|
||||
(bitrate: string) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemIdRef.current ?? "",
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSourceIdRef.current ?? "",
|
||||
subtitleIndex: subtitleIndex.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrate.toString(),
|
||||
playbackPosition: playbackPositionRef.current,
|
||||
playbackPosition: playbackPosition,
|
||||
}).toString();
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
},
|
||||
[audioIndex, subtitleIndex, router],
|
||||
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
|
||||
);
|
||||
|
||||
// Create stable identifiers for tracks
|
||||
const subtitleTracksKey = useMemo(
|
||||
() => subtitleTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
|
||||
[subtitleTracks],
|
||||
);
|
||||
|
||||
const audioTracksKey = useMemo(
|
||||
() => audioTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
|
||||
[audioTracks],
|
||||
);
|
||||
|
||||
// Transform sections into OptionGroup format
|
||||
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||
const groups: OptionGroup[] = [];
|
||||
|
||||
// Quality Section
|
||||
if (!isOffline) {
|
||||
groups.push({
|
||||
title: "Quality",
|
||||
options:
|
||||
BITRATES?.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
label: bitrate.key,
|
||||
value: bitrate.value?.toString() ?? "",
|
||||
selected: bitrateValue === (bitrate.value?.toString() ?? ""),
|
||||
onPress: () => changeBitrate(bitrate.value?.toString() ?? ""),
|
||||
})) || [],
|
||||
});
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Subtitle Section
|
||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||
groups.push({
|
||||
title: "Subtitles",
|
||||
options: subtitleTracks.map((sub) => ({
|
||||
type: "radio" as const,
|
||||
label: sub.name,
|
||||
value: sub.index.toString(),
|
||||
selected: subtitleIndex === sub.index.toString(),
|
||||
onPress: () => sub.setTrack(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Audio Section
|
||||
if (audioTracks && audioTracks.length > 0) {
|
||||
groups.push({
|
||||
title: "Audio",
|
||||
options: audioTracks.map((track) => ({
|
||||
type: "radio" as const,
|
||||
label: track.name,
|
||||
value: track.index.toString(),
|
||||
selected: audioIndex === track.index.toString(),
|
||||
onPress: () => track.setTrack(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isOffline,
|
||||
bitrateValue,
|
||||
changeBitrate,
|
||||
subtitleTracksKey,
|
||||
audioTracksKey,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
// Note: subtitleTracks and audioTracks are intentionally excluded
|
||||
// because we use subtitleTracksKey and audioTracksKey for stability
|
||||
]);
|
||||
|
||||
// Memoize the trigger to prevent re-renders
|
||||
const trigger = useMemo(
|
||||
() => (
|
||||
<View className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'>
|
||||
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
|
||||
</View>
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true);
|
||||
bottomSheetModalRef.current?.present();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) bottomSheetModalRef.current?.present();
|
||||
else bottomSheetModalRef.current?.dismiss();
|
||||
}, [open]);
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title='Playback Options'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<TouchableOpacity
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
onPress={handleOpen}
|
||||
>
|
||||
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='mt-2 mb-8'
|
||||
style={{
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
}}
|
||||
>
|
||||
<Text className='font-bold text-2xl mb-6'>Playback Options</Text>
|
||||
|
||||
{/* Quality Section */}
|
||||
{!isOffline && (
|
||||
<View className='mb-6'>
|
||||
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
|
||||
Quality
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className='flex flex-col rounded-xl overflow-hidden'
|
||||
>
|
||||
{BITRATES?.map((bitrate, idx: number) => (
|
||||
<View key={`quality-item-${idx}`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
changeBitrate(bitrate.value?.toString() ?? "");
|
||||
setTimeout(() => handleClose(), 250);
|
||||
}}
|
||||
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||
>
|
||||
<Text className='flex shrink'>{bitrate.key}</Text>
|
||||
{bitrateValue === (bitrate.value?.toString() ?? "") ? (
|
||||
<Ionicons
|
||||
name='radio-button-on'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
) : (
|
||||
<Ionicons
|
||||
name='radio-button-off'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{idx < BITRATES.length - 1 && (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-700'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Subtitle Section */}
|
||||
<View className='mb-6'>
|
||||
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
|
||||
Subtitles
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className='flex flex-col rounded-xl overflow-hidden'
|
||||
>
|
||||
{subtitleTracks?.map((sub, idx: number) => (
|
||||
<View key={`subtitle-item-${idx}`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
sub.setTrack();
|
||||
setTimeout(() => handleClose(), 250);
|
||||
}}
|
||||
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||
>
|
||||
<Text className='flex shrink'>{sub.name}</Text>
|
||||
{subtitleIndex === sub.index.toString() ? (
|
||||
<Ionicons
|
||||
name='radio-button-on'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
) : (
|
||||
<Ionicons
|
||||
name='radio-button-off'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{idx < (subtitleTracks?.length ?? 0) - 1 && (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-700'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Audio Section */}
|
||||
{(audioTracks?.length ?? 0) > 0 && (
|
||||
<View className='mb-6'>
|
||||
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
|
||||
Audio
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className='flex flex-col rounded-xl overflow-hidden'
|
||||
>
|
||||
{audioTracks?.map((track, idx: number) => (
|
||||
<View key={`audio-item-${idx}`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
track.setTrack();
|
||||
setTimeout(() => handleClose(), 250);
|
||||
}}
|
||||
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||
>
|
||||
<Text className='flex shrink'>{track.name}</Text>
|
||||
{audioIndex === track.index.toString() ? (
|
||||
<Ionicons
|
||||
name='radio-button-on'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
) : (
|
||||
<Ionicons
|
||||
name='radio-button-off'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{idx < (audioTracks?.length ?? 0) - 1 && (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-700'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useControlsTimeout = ({
|
||||
isSliding,
|
||||
episodeView,
|
||||
onHideControls,
|
||||
timeout = 10000,
|
||||
timeout = 4000,
|
||||
}: UseControlsTimeoutProps) => {
|
||||
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
||||
64
eas.json
64
eas.json
@@ -4,6 +4,17 @@
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"resourceClass": "medium",
|
||||
"cache": {
|
||||
"key": "dev-{{ checksum \"bun.lock\" \"app.config.js\" }}",
|
||||
"paths": [
|
||||
"~/.bun/install/cache",
|
||||
"node_modules",
|
||||
".expo",
|
||||
"android/.gradle",
|
||||
"ios/Pods"
|
||||
]
|
||||
},
|
||||
"environment": "development",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
@@ -15,6 +26,11 @@
|
||||
}
|
||||
},
|
||||
"development_tv": {
|
||||
"resourceClass": "medium",
|
||||
"cache": {
|
||||
"key": "development-tv-{{ checksum \"bun.lock\" \"package.json\" }}",
|
||||
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
|
||||
},
|
||||
"environment": "development",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
@@ -27,6 +43,11 @@
|
||||
}
|
||||
},
|
||||
"development-simulator": {
|
||||
"resourceClass": "medium",
|
||||
"cache": {
|
||||
"key": "development-simulator-{{ checksum \"bun.lock\" \"package.json\" }}",
|
||||
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
|
||||
},
|
||||
"environment": "development",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
@@ -38,19 +59,57 @@
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"resourceClass": "large",
|
||||
"cache": {
|
||||
"key": "preview-{{ checksum \"bun.lock\" \"app.config.js\" }}",
|
||||
"paths": [
|
||||
"~/.bun/install/cache",
|
||||
"node_modules",
|
||||
".expo",
|
||||
"android/.gradle",
|
||||
"ios/Pods",
|
||||
".next"
|
||||
]
|
||||
},
|
||||
"distribution": "internal",
|
||||
"env": {
|
||||
"EXPO_OPTIMIZE_BUNDLE_SIZE": "1",
|
||||
"NODE_ENV": "production",
|
||||
"EXPO_PUBLIC_WRITE_DEBUG": "1"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"resourceClass": "large",
|
||||
"cache": {
|
||||
"key": "production-{{ checksum \"bun.lock\" \"app.config.js\" }}",
|
||||
"paths": [
|
||||
"~/.bun/install/cache",
|
||||
"node_modules",
|
||||
".expo",
|
||||
"android/.gradle",
|
||||
"ios/Pods"
|
||||
]
|
||||
},
|
||||
"environment": "production",
|
||||
"channel": "0.39.0",
|
||||
"android": {
|
||||
"buildType": "app-bundle",
|
||||
"image": "latest"
|
||||
},
|
||||
"ios": {
|
||||
"image": "latest"
|
||||
},
|
||||
"env": {
|
||||
"EXPO_OPTIMIZE_BUNDLE_SIZE": "1",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"resourceClass": "large",
|
||||
"cache": {
|
||||
"key": "production-apk-{{ checksum \"bun.lock\" \"package.json\" }}",
|
||||
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
|
||||
},
|
||||
"environment": "production",
|
||||
"channel": "0.39.0",
|
||||
"android": {
|
||||
@@ -59,6 +118,11 @@
|
||||
}
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"resourceClass": "large",
|
||||
"cache": {
|
||||
"key": "production-apk-tv-{{ checksum \"bun.lock\" \"package.json\" }}",
|
||||
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
|
||||
},
|
||||
"environment": "production",
|
||||
"channel": "0.39.0",
|
||||
"android": {
|
||||
|
||||
@@ -66,8 +66,8 @@ const JELLYSEERR_USER = "JELLYSEERR_USER";
|
||||
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
|
||||
|
||||
export const clearJellyseerrStorageData = () => {
|
||||
storage.remove(JELLYSEERR_USER);
|
||||
storage.remove(JELLYSEERR_COOKIES);
|
||||
storage.delete(JELLYSEERR_USER);
|
||||
storage.delete(JELLYSEERR_COOKIES);
|
||||
};
|
||||
|
||||
export enum Endpoints {
|
||||
|
||||
6
i18n.ts
6
i18n.ts
@@ -1,6 +1,7 @@
|
||||
import { getLocales } from "expo-localization";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import ar from "./translations/ar.json";
|
||||
import ca from "./translations/ca.json";
|
||||
import da from "./translations/da.json";
|
||||
import de from "./translations/de.json";
|
||||
@@ -9,6 +10,7 @@ import eo from "./translations/eo.json";
|
||||
import es from "./translations/es.json";
|
||||
import fi from "./translations/fi.json";
|
||||
import fr from "./translations/fr.json";
|
||||
import hu from "./translations/hu.json";
|
||||
import it from "./translations/it.json";
|
||||
import ja from "./translations/ja.json";
|
||||
import nb from "./translations/nb.json";
|
||||
@@ -29,6 +31,7 @@ import zhTW from "./translations/zh-TW.json";
|
||||
|
||||
export const APP_LANGUAGES = [
|
||||
{ label: "Catalan", value: "ca" },
|
||||
{ label: "العربية", value: "ar" },
|
||||
{ label: "Dansk", value: "da" },
|
||||
{ label: "Deutsch", value: "de" },
|
||||
{ label: "English", value: "en" },
|
||||
@@ -39,6 +42,7 @@ export const APP_LANGUAGES = [
|
||||
{ label: "日本語", value: "ja" },
|
||||
{ label: "Klingon", value: "tlh" },
|
||||
{ label: "Türkçe", value: "tr" },
|
||||
{ label: "Magyar", value: "hu" },
|
||||
{ label: "Nederlands", value: "nl" },
|
||||
{ label: "Polski", value: "pl" },
|
||||
{ label: "Português (Brasil)", value: "pt-BR" },
|
||||
@@ -59,12 +63,14 @@ i18n.use(initReactI18next).init({
|
||||
compatibilityJSON: "v4",
|
||||
resources: {
|
||||
ca: { translation: ca },
|
||||
ar: { translation: ar },
|
||||
da: { translation: da },
|
||||
de: { translation: de },
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
eo: { translation: eo },
|
||||
fr: { translation: fr },
|
||||
hu: { translation: hu },
|
||||
it: { translation: it },
|
||||
ja: { translation: ja },
|
||||
nl: { translation: nl },
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# login.yaml
|
||||
|
||||
appId: your.app.id
|
||||
---
|
||||
- launchApp
|
||||
- tapOn: "Text on the screen"
|
||||
235
metro.config.js
235
metro.config.js
@@ -1,28 +1,243 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const path = require("node:path");
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Add Hermes parser
|
||||
// =======================================================
|
||||
// STREAMYFIN METRO CONFIG - PERFORMANCE OPTIMIZED 🚀
|
||||
// =======================================================
|
||||
// Advanced configuration for multi-platform Jellyfin client
|
||||
// Optimized for media streaming, TV support, and Bun
|
||||
// =======================================================
|
||||
|
||||
// HERMES + ADVANCED PERFORMANCE
|
||||
// ==============================
|
||||
config.transformer.hermesParser = true;
|
||||
|
||||
// When enabled, the optional code below will allow Metro to resolve
|
||||
// and bundle source files with TV-specific extensions
|
||||
// (e.g., *.ios.tv.tsx, *.android.tv.tsx, *.tv.tsx)
|
||||
//
|
||||
// Metro will still resolve source files with standard extensions
|
||||
// as usual if TV-specific files are not found for a module.
|
||||
//
|
||||
// CPU optimization (your existing setting)
|
||||
const os = require("node:os");
|
||||
config.maxWorkers = Math.max(1, os.cpus().length - 1);
|
||||
|
||||
// JAVASCRIPT OPTIMIZATION (Safe & Stable)
|
||||
// ========================================
|
||||
config.transformer = {
|
||||
...config.transformer,
|
||||
hermesParser: true,
|
||||
|
||||
// NEW: Inline requires for 15-30% startup improvement
|
||||
inlineRequires: true,
|
||||
|
||||
// ADVANCED: Hermes-optimized minification for streaming apps
|
||||
minifierConfig: {
|
||||
mangle: {
|
||||
keep_fnames: process.env.NODE_ENV === "development",
|
||||
},
|
||||
output: {
|
||||
ascii_only: true,
|
||||
beautify: false,
|
||||
semicolons: false,
|
||||
},
|
||||
compress: {
|
||||
// Production-only optimizations
|
||||
drop_console: process.env.NODE_ENV === "production",
|
||||
dead_code: true,
|
||||
drop_debugger: true,
|
||||
conditionals: true,
|
||||
evaluate: true,
|
||||
unused: true,
|
||||
reduce_vars: true,
|
||||
// Keep class names for error reporting
|
||||
keep_classnames: true,
|
||||
// Preserve function names for performance profiling in dev
|
||||
keep_fnames: process.env.NODE_ENV === "development",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// RESOLVER OPTIMIZATIONS ENHANCED
|
||||
// ===============================
|
||||
config.resolver = {
|
||||
...config.resolver,
|
||||
|
||||
// NEW: Package exports (stable in recent Metro)
|
||||
unstable_enablePackageExports: true,
|
||||
|
||||
// ENHANCED: Extensions optimized for Streamyfin
|
||||
assetExts: [
|
||||
...config.resolver.assetExts,
|
||||
// Video formats (enhanced for Jellyfin)
|
||||
"mkv",
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"m4v",
|
||||
"mpg",
|
||||
"mpeg",
|
||||
// Audio formats
|
||||
"mp3",
|
||||
"wav",
|
||||
"flac",
|
||||
"aac",
|
||||
"m4a",
|
||||
"ogg",
|
||||
"wma",
|
||||
"opus",
|
||||
// Subtitle files (complete for media)
|
||||
"srt",
|
||||
"vtt",
|
||||
"ass",
|
||||
"ssa",
|
||||
"sub",
|
||||
"idx",
|
||||
"sbv",
|
||||
"ttml",
|
||||
// Database/Cache
|
||||
"db",
|
||||
"sqlite",
|
||||
"realm",
|
||||
"json5",
|
||||
// Fonts (TV optimized)
|
||||
"woff2",
|
||||
"woff",
|
||||
"eot",
|
||||
"otf",
|
||||
// Images (enhanced for thumbnails)
|
||||
"avif",
|
||||
"heic",
|
||||
"heif", // Modern formats
|
||||
],
|
||||
|
||||
sourceExts: [
|
||||
...config.resolver.sourceExts,
|
||||
"mjs",
|
||||
"cjs", // Modern JS support
|
||||
],
|
||||
|
||||
// NEW: Platform prioritization for performance
|
||||
platforms: ["ios", "android", "native", "web", "tv"],
|
||||
};
|
||||
|
||||
// SERIALIZER OPTIMIZATIONS (Production)
|
||||
// ====================================
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
config.serializer = {
|
||||
...config.serializer,
|
||||
|
||||
// NEW: Module IDs optimized for caching
|
||||
createModuleIdFactory: () => {
|
||||
return (path) => {
|
||||
// Shorter module IDs for smaller bundles
|
||||
return require("node:crypto")
|
||||
.createHash("sha1")
|
||||
.update(path)
|
||||
.digest("hex")
|
||||
.substring(0, 8);
|
||||
};
|
||||
},
|
||||
|
||||
// NEW: Bundle pre-loading for streaming apps
|
||||
getModulesRunBeforeMainModule: (_entryFilePath) => [
|
||||
// Pre-load critical modules for faster TTI
|
||||
require.resolve("react-native/Libraries/Core/InitializeCore"),
|
||||
],
|
||||
|
||||
// Web bundle splitting (if applicable)
|
||||
...(process.env.EXPO_PLATFORM === "web" && {
|
||||
// Experimental code splitting for web
|
||||
experimentalSerializerHook: () => {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// TV PLATFORM ENHANCEMENTS
|
||||
// ========================
|
||||
if (process.env?.EXPO_TV === "1") {
|
||||
const originalSourceExts = config.resolver.sourceExts;
|
||||
const tvSourceExts = [
|
||||
...originalSourceExts.map((e) => `tv.${e}`),
|
||||
...originalSourceExts.map((ext) => `tv.${ext}`),
|
||||
...originalSourceExts,
|
||||
];
|
||||
config.resolver.sourceExts = tvSourceExts;
|
||||
|
||||
// NEW: TV-specific optimizations
|
||||
config.transformer = {
|
||||
...config.transformer,
|
||||
// Optimize transforms for TV hardware
|
||||
experimentalImportSupport: false, // Reduce complexity on TV
|
||||
// Use legacy transformer for better TV compatibility
|
||||
allowOptionalDependencies: true,
|
||||
};
|
||||
|
||||
console.log("📺 TV platform optimized for Streamyfin");
|
||||
console.log(`📁 TV extensions: ${tvSourceExts.slice(0, 6).join(", ")}...`);
|
||||
}
|
||||
|
||||
// config.resolver.unstable_enablePackageExports = false;
|
||||
// DEVELOPMENT ENHANCEMENTS
|
||||
// ========================
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// NEW: Enhanced error reporting
|
||||
config.reporter = {
|
||||
update: (event) => {
|
||||
if (event.type === "bundle_build_failed") {
|
||||
console.error(
|
||||
"🔴 Streamyfin Bundle Build Failed:",
|
||||
event.error.message,
|
||||
);
|
||||
} else if (event.type === "bundle_build_done") {
|
||||
console.log(
|
||||
"✅ Streamyfin Bundle Ready:",
|
||||
event.bundleDetails?.bundleSize || "Unknown size",
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
console.log("🚀 Streamyfin Metro Config - OPTIMIZED VERSION");
|
||||
console.log(`📦 Workers: ${config.maxWorkers}`);
|
||||
console.log(`🎯 Hermes: ${config.transformer.hermesParser}`);
|
||||
console.log(`⚡ Inline requires: ${config.transformer.inlineRequires}`);
|
||||
console.log(
|
||||
`📺 TV support: ${process.env.EXPO_TV === "1" ? "ENABLED" : "DISABLED"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// STREAMING APP SPECIFIC OPTIMIZATIONS
|
||||
// ===================================
|
||||
|
||||
// NEW: Cache hints for better performance
|
||||
if (typeof config.cacheStores === "undefined") {
|
||||
// Only add if not causing issues
|
||||
try {
|
||||
const MetroCache = require("metro-cache");
|
||||
config.cacheStores = [
|
||||
new MetroCache.FileStore({
|
||||
root: path.join(os.tmpdir(), "streamyfin-metro-cache"),
|
||||
}),
|
||||
];
|
||||
} catch (_e) {
|
||||
// Fallback: use default cache
|
||||
console.log("ℹ️ Using default Metro cache (custom cache not available)");
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Network request optimizations for streaming
|
||||
config.server = {
|
||||
...config.server,
|
||||
// Enhanced request handling for media assets
|
||||
enhanceMiddleware: (middleware, _server) => {
|
||||
// Add caching headers for static assets
|
||||
return (req, res, next) => {
|
||||
if (req.url?.match(/\.(mp4|mkv|jpg|jpeg|png|webp)$/)) {
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000");
|
||||
}
|
||||
return middleware(req, res, next);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import type {
|
||||
ChapterInfo,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
@@ -12,16 +12,20 @@ import {
|
||||
} from "./VlcPlayer.types";
|
||||
import VlcPlayerView from "./VlcPlayerView";
|
||||
|
||||
export {
|
||||
VlcPlayerView,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
// Component
|
||||
export { VlcPlayerView };
|
||||
|
||||
// Component Types
|
||||
export type { VlcPlayerViewProps, VlcPlayerViewRef };
|
||||
|
||||
// Media Types
|
||||
export type { ChapterInfo, TrackInfo, VlcPlayerSource };
|
||||
|
||||
// Playback Events (alphabetically sorted)
|
||||
export type {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VideoLoadStartPayload,
|
||||
VideoStateChangePayload,
|
||||
VideoProgressPayload,
|
||||
VlcPlayerSource,
|
||||
TrackInfo,
|
||||
ChapterInfo,
|
||||
VideoStateChangePayload,
|
||||
};
|
||||
|
||||
134
package.json
134
package.json
@@ -22,108 +22,108 @@
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "^0.12.2",
|
||||
"@expo/metro-runtime": "~6.1.1",
|
||||
"@bottom-tabs/react-navigation": "^0.11.2",
|
||||
"@expo/metro-runtime": "~5.0.5",
|
||||
"@expo/react-native-action-sheet": "^4.1.1",
|
||||
"@expo/ui": "^0.2.0-beta.4",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@gorhom/bottom-sheet": "^5.1.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "github:fredrikburmester/react-native-background-downloader#d78699b60866062f6d95887412cee3649a548bf2",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.6",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-menu/menu": "1.2.3",
|
||||
"@react-navigation/material-top-tabs": "^7.2.14",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/flash-list": "^1.8.3",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "^54.0.10",
|
||||
"expo-application": "~7.0.5",
|
||||
"expo-asset": "~12.0.6",
|
||||
"expo-background-task": "~1.0.5",
|
||||
"expo-blur": "~15.0.5",
|
||||
"expo-brightness": "~14.0.5",
|
||||
"expo-build-properties": "~1.0.6",
|
||||
"expo-constants": "~18.0.6",
|
||||
"expo-dev-client": "~6.0.7",
|
||||
"expo-device": "~8.0.5",
|
||||
"expo-font": "~14.0.6",
|
||||
"expo-haptics": "~15.0.5",
|
||||
"expo-image": "~3.0.5",
|
||||
"expo-linear-gradient": "~15.0.5",
|
||||
"expo-linking": "~8.0.6",
|
||||
"expo-localization": "~17.0.5",
|
||||
"expo-notifications": "~0.32.7",
|
||||
"expo-router": "~6.0.0-preview.12",
|
||||
"expo-screen-orientation": "~9.0.5",
|
||||
"expo-sensors": "~15.0.5",
|
||||
"expo-sharing": "~14.0.5",
|
||||
"expo-splash-screen": "~31.0.7",
|
||||
"expo-status-bar": "~3.0.6",
|
||||
"expo-system-ui": "~6.0.5",
|
||||
"expo-task-manager": "~14.0.5",
|
||||
"expo-web-browser": "~15.0.5",
|
||||
"expo": "^53.0.23",
|
||||
"expo-application": "~6.1.4",
|
||||
"expo-asset": "~11.1.7",
|
||||
"expo-atlas": "^0.4.0",
|
||||
"expo-background-task": "~0.2.8",
|
||||
"expo-blur": "~14.1.4",
|
||||
"expo-brightness": "~13.1.4",
|
||||
"expo-build-properties": "~0.14.6",
|
||||
"expo-constants": "~17.1.5",
|
||||
"expo-device": "~7.1.4",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-haptics": "~14.1.4",
|
||||
"expo-image": "~2.4.0",
|
||||
"expo-linear-gradient": "~14.1.4",
|
||||
"expo-linking": "~7.1.4",
|
||||
"expo-localization": "~16.1.5",
|
||||
"expo-notifications": "~0.31.2",
|
||||
"expo-router": "~5.1.7",
|
||||
"expo-screen-orientation": "~8.1.6",
|
||||
"expo-sensors": "~14.1.4",
|
||||
"expo-sharing": "~13.1.5",
|
||||
"expo-splash-screen": "~0.30.8",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-system-ui": "~5.0.11",
|
||||
"expo-task-manager": "~13.1.6",
|
||||
"expo-web-browser": "~14.2.0",
|
||||
"i18next": "^25.0.0",
|
||||
"jotai": "^2.12.5",
|
||||
"lodash": "^4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-native": "npm:react-native-tvos@0.81.4-0",
|
||||
"react-native": "npm:react-native-tvos@0.79.5-0",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "^0.12.2",
|
||||
"react-native-bottom-tabs": "^0.11.2",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
"react-native-collapsible": "^1.6.2",
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-google-cast": "^4.9.0",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^3.2.1",
|
||||
"react-native-ios-utilities": "5.2.0",
|
||||
"react-native-mmkv": "4.0.0-beta.12",
|
||||
"react-native-nitro-modules": "^0.29.1",
|
||||
"react-native-ios-context-menu": "^3.1.0",
|
||||
"react-native-ios-utilities": "5.1.8",
|
||||
"react-native-mmkv": "2.12.2",
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-reanimated": "~4.1.0",
|
||||
"react-native-reanimated": "~3.19.1",
|
||||
"react-native-reanimated-carousel": "4.0.2",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-svg": "15.11.2",
|
||||
"react-native-udp": "^4.1.7",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-video": "6.16.1",
|
||||
"react-native-video": "6.14.1",
|
||||
"react-native-volume-manager": "^2.0.8",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"react-native-web": "^0.20.0",
|
||||
"sonner-native": "^0.21.0",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zeego": "^3.0.6",
|
||||
"zod": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@biomejs/biome": "^2.2.4",
|
||||
"@react-native-community/cli": "^20.0.0",
|
||||
"@react-native-tvos/config-tv": "^0.1.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"expo-doctor": "^1.17.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.5",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"@babel/core": "7.28.4",
|
||||
"@biomejs/biome": "2.2.5",
|
||||
"@react-native-community/cli": "20.0.2",
|
||||
"@react-native-tvos/config-tv": "0.1.4",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/react": "~19.0.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-dev-client": "5.2.4",
|
||||
"expo-doctor": "1.17.9",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.2.3",
|
||||
"react-test-renderer": "19.1.1",
|
||||
"typescript": "~5.9.2"
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native"
|
||||
"react-native",
|
||||
"@shopify/flash-list",
|
||||
"react-native-reanimated",
|
||||
"react-native-pager-view"
|
||||
]
|
||||
},
|
||||
"doctor": {
|
||||
@@ -140,9 +140,6 @@
|
||||
}
|
||||
},
|
||||
"private": true,
|
||||
"disabledDependencies": {
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.6"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"biome check --write --unsafe --no-errors-on-unmatched"
|
||||
@@ -152,7 +149,6 @@
|
||||
]
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"postinstall-postinstall",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
diff --git a/node_modules/@react-native-menu/menu/android/.DS_Store b/node_modules/@react-native-menu/menu/android/.DS_Store
|
||||
new file mode 100644
|
||||
index 0000000..5008ddf
|
||||
Binary files /dev/null and b/node_modules/@react-native-menu/menu/android/.DS_Store differ
|
||||
diff --git a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
|
||||
index 17ed7c6..c45f5cc 100644
|
||||
--- a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
|
||||
+++ b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
|
||||
@@ -24,6 +24,11 @@ class MenuView(private val mContext: ReactContext) : ReactViewGroup(mContext) {
|
||||
private var mIsOnLongPress = false
|
||||
private var mGestureDetector: GestureDetector
|
||||
private var mHitSlopRect: Rect? = null
|
||||
+ set(value) {
|
||||
+ super.hitSlopRect = value
|
||||
+ mHitSlopRect = value
|
||||
+ updateTouchDelegate()
|
||||
+ }
|
||||
|
||||
init {
|
||||
mGestureDetector = GestureDetector(mContext, object : GestureDetector.SimpleOnGestureListener() {
|
||||
@@ -47,12 +52,6 @@ class MenuView(private val mContext: ReactContext) : ReactViewGroup(mContext) {
|
||||
prepareMenu()
|
||||
}
|
||||
|
||||
- override fun setHitSlopRect(rect: Rect?) {
|
||||
- super.setHitSlopRect(rect)
|
||||
- mHitSlopRect = rect
|
||||
- updateTouchDelegate()
|
||||
- }
|
||||
-
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
return true
|
||||
}
|
||||
diff --git a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
|
||||
index 4731e1a..e4d2743 100644
|
||||
--- a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
|
||||
+++ b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
|
||||
@@ -123,9 +123,9 @@ abstract class MenuViewManagerBase : ReactClippingViewManager<MenuView>() {
|
||||
fun setHitSlop(view: ReactViewGroup, @Nullable hitSlop: ReadableMap?) {
|
||||
if (hitSlop == null) {
|
||||
// We should keep using setters as `Val cannot be reassigned`
|
||||
- view.setHitSlopRect(null)
|
||||
+ view.hitSlopRect = null
|
||||
} else {
|
||||
- view.setHitSlopRect(
|
||||
+ view.hitSlopRect = (
|
||||
Rect(
|
||||
if (hitSlop.hasKey("left"))
|
||||
PixelUtil.toPixelFromDIP(hitSlop.getDouble("left")).toInt()
|
||||
@@ -206,7 +206,7 @@ abstract class MenuViewManagerBase : ReactClippingViewManager<MenuView>() {
|
||||
|
||||
@ReactProp(name = ViewProps.OVERFLOW)
|
||||
fun setOverflow(view: ReactViewGroup, overflow: String?) {
|
||||
- view.setOverflow(overflow)
|
||||
+ view.overflow = overflow
|
||||
}
|
||||
|
||||
@ReactProp(name = "backfaceVisibility")
|
||||
@@ -3,11 +3,12 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Application from "expo-application";
|
||||
import { Directory, File, Paths } from "expo-file-system";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { router } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import {
|
||||
import { throttle } from "lodash";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeviceEventEmitter, Platform } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import useImageStorage from "@/hooks/useImageStorage";
|
||||
@@ -113,20 +114,6 @@ function useDownloadProvider() {
|
||||
const { settings } = useSettings();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
// Set up global download complete listener for debugging
|
||||
useEffect(() => {
|
||||
const listener = DeviceEventEmitter.addListener(
|
||||
"downloadComplete",
|
||||
(data) => {
|
||||
console.log("🔥 GLOBAL TEST LISTENER received downloadComplete:", data);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
listener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Generate notification content based on item type
|
||||
const getNotificationContent = useCallback(
|
||||
(item: BaseItemDto, isSuccess: boolean) => {
|
||||
@@ -193,12 +180,9 @@ function useDownloadProvider() {
|
||||
/// Cant use the background downloader callback. As its not triggered if size is unknown.
|
||||
const updateProgress = async () => {
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
if (!tasks || tasks.length === 0) {
|
||||
if (!tasks) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[UPDATE_PROGRESS] Checking ${tasks.length} active tasks`);
|
||||
|
||||
// check if processes are missing
|
||||
setProcesses((processes) => {
|
||||
const missingProcesses = tasks
|
||||
@@ -217,41 +201,10 @@ function useDownloadProvider() {
|
||||
|
||||
// Find task for this process
|
||||
const task = tasks.find((s: any) => s.id === p.id);
|
||||
|
||||
if (!task) {
|
||||
// ORPHANED DOWNLOAD CHECK: Task disappeared, but was it because it completed?
|
||||
// This handles the race condition where download finishes between polling intervals
|
||||
if (p.progress >= 90) {
|
||||
// Lower threshold to catch more cases
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Orphaned download detected for ${p.item.Name} at ${p.progress.toFixed(1)}%, checking file...`,
|
||||
);
|
||||
const filename = generateFilename(p.item);
|
||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||
|
||||
if (videoFile.exists && videoFile.size > 0) {
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Orphaned download complete! File size: ${videoFile.size}, marking as complete`,
|
||||
);
|
||||
return {
|
||||
...p,
|
||||
progress: 100,
|
||||
speed: 0,
|
||||
bytesDownloaded: videoFile.size,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
estimatedTotalSizeBytes: videoFile.size,
|
||||
lastSessionBytes: videoFile.size,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
status: "completed" as const,
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
`[UPDATE_PROGRESS] Orphaned download at ${p.progress.toFixed(1)}% but file not found. Keeping current state.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return p; // No task found, keep current state
|
||||
}
|
||||
|
||||
/*
|
||||
// TODO: Uncomment this block to re-enable iOS zombie task detection
|
||||
// iOS: Extra validation to prevent zombie task interference
|
||||
@@ -319,52 +272,6 @@ function useDownloadProvider() {
|
||||
progress = MAX_PROGRESS_BEFORE_COMPLETION;
|
||||
}
|
||||
const speed = calculateSpeed(p, task.bytesDownloaded);
|
||||
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Task ${p.item.Name}: ${progress.toFixed(1)}% (${task.bytesDownloaded}/${estimatedSize} bytes), state: ${task.state}`,
|
||||
);
|
||||
|
||||
// WORKAROUND: Check if download is actually complete by checking file existence
|
||||
// This handles cases where the .done() callback doesn't fire (unknown content length, simulator issues, etc.)
|
||||
if (progress >= 90 && task.state === "DONE") {
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Task appears complete (state=DONE), checking file...`,
|
||||
);
|
||||
const filename = generateFilename(p.item);
|
||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Looking for file at: ${videoFile.uri}`,
|
||||
);
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Paths.document.uri: ${Paths.document.uri}`,
|
||||
);
|
||||
console.log(`[UPDATE_PROGRESS] File exists: ${videoFile.exists}`);
|
||||
console.log(`[UPDATE_PROGRESS] File size: ${videoFile.size}`);
|
||||
|
||||
if (videoFile.exists && videoFile.size > 0) {
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] File exists with size ${videoFile.size}, marking as complete!`,
|
||||
);
|
||||
// Mark as complete by setting status - this will trigger removal from processes
|
||||
return {
|
||||
...p,
|
||||
progress: 100,
|
||||
speed: 0,
|
||||
bytesDownloaded: videoFile.size,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
estimatedTotalSizeBytes: videoFile.size,
|
||||
lastSessionBytes: videoFile.size,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
status: "completed" as const,
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
`[UPDATE_PROGRESS] File not found or empty! Task state=${task.state}, progress=${progress}%`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...p,
|
||||
progress,
|
||||
@@ -384,14 +291,13 @@ function useDownloadProvider() {
|
||||
});
|
||||
};
|
||||
|
||||
useInterval(updateProgress, 1000);
|
||||
useInterval(updateProgress, 2000);
|
||||
|
||||
const getDownloadedItemById = (id: string): DownloadedItem | undefined => {
|
||||
const db = getDownloadsDatabase();
|
||||
|
||||
// Check movies first
|
||||
if (db.movies[id]) {
|
||||
console.log(`[DB] Found movie with ID: ${id}`);
|
||||
return db.movies[id];
|
||||
}
|
||||
|
||||
@@ -400,16 +306,14 @@ function useDownloadProvider() {
|
||||
for (const season of Object.values(series.seasons)) {
|
||||
for (const episode of Object.values(season.episodes)) {
|
||||
if (episode.item.Id === id) {
|
||||
console.log(`[DB] Found episode with ID: ${id}`);
|
||||
return episode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[DB] No item found with ID: ${id}`);
|
||||
// Check other media types
|
||||
if (db.other?.[id]) {
|
||||
if (db.other[id]) {
|
||||
return db.other[id];
|
||||
}
|
||||
|
||||
@@ -442,41 +346,34 @@ function useDownloadProvider() {
|
||||
return api?.accessToken;
|
||||
}, [api]);
|
||||
|
||||
const APP_CACHE_DOWNLOAD_DIRECTORY = new Directory(
|
||||
Paths.cache,
|
||||
`${Application.applicationId}/Downloads/`,
|
||||
);
|
||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
|
||||
|
||||
const getDownloadsDatabase = (): DownloadsDatabase => {
|
||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||
if (file) {
|
||||
const db = JSON.parse(file) as DownloadsDatabase;
|
||||
return db;
|
||||
return JSON.parse(file) as DownloadsDatabase;
|
||||
}
|
||||
return { movies: {}, series: {}, other: {} }; // Initialize other media types storage
|
||||
};
|
||||
|
||||
const getDownloadedItems = useCallback(() => {
|
||||
const getDownloadedItems = () => {
|
||||
const db = getDownloadsDatabase();
|
||||
const movies = Object.values(db.movies);
|
||||
const episodes = Object.values(db.series).flatMap((series) =>
|
||||
Object.values(series.seasons).flatMap((season) =>
|
||||
Object.values(season.episodes),
|
||||
const allItems = [
|
||||
...Object.values(db.movies),
|
||||
...Object.values(db.series).flatMap((series) =>
|
||||
Object.values(series.seasons).flatMap((season) =>
|
||||
Object.values(season.episodes),
|
||||
),
|
||||
),
|
||||
);
|
||||
const otherItems = Object.values(db.other || {});
|
||||
const allItems = [...movies, ...episodes, ...otherItems];
|
||||
...Object.values(db.other), // Include other media types in results
|
||||
];
|
||||
return allItems;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const downloadedItems = getDownloadedItems();
|
||||
|
||||
const saveDownloadsDatabase = (db: DownloadsDatabase) => {
|
||||
const movieCount = Object.keys(db.movies).length;
|
||||
const seriesCount = Object.keys(db.series).length;
|
||||
console.log(
|
||||
`[DB] Saving database: ${movieCount} movies, ${seriesCount} series`,
|
||||
);
|
||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||
console.log(`[DB] Database saved successfully to MMKV`);
|
||||
};
|
||||
|
||||
/** Generates a filename for a given item */
|
||||
@@ -515,17 +412,20 @@ function useDownloadProvider() {
|
||||
}
|
||||
|
||||
const filename = generateFilename(item);
|
||||
const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
|
||||
trickplayDir.create({ intermediates: true });
|
||||
const trickplayDir = `${FileSystem.documentDirectory}${filename}_trickplay/`;
|
||||
await FileSystem.makeDirectoryAsync(trickplayDir, { intermediates: true });
|
||||
let totalSize = 0;
|
||||
|
||||
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
|
||||
const url = generateTrickplayUrl(item, index);
|
||||
if (!url) continue;
|
||||
const destination = new File(trickplayDir, `${index}.jpg`);
|
||||
const destination = `${trickplayDir}${index}.jpg`;
|
||||
try {
|
||||
await File.downloadFileAsync(url, destination);
|
||||
totalSize += destination.size;
|
||||
await FileSystem.downloadAsync(url, destination);
|
||||
const fileInfo = await FileSystem.getInfoAsync(destination);
|
||||
if (fileInfo.exists) {
|
||||
totalSize += fileInfo.size;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to download trickplay image ${index} for item ${item.Id}`,
|
||||
@@ -534,7 +434,7 @@ function useDownloadProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
return { path: trickplayDir.uri, size: totalSize };
|
||||
return { path: trickplayDir, size: totalSize };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -554,12 +454,9 @@ function useDownloadProvider() {
|
||||
externalSubtitles.map(async (subtitle) => {
|
||||
const url = api.basePath + subtitle.DeliveryUrl;
|
||||
const filename = generateFilename(item);
|
||||
const destination = new File(
|
||||
Paths.document,
|
||||
`${filename}_subtitle_${subtitle.Index}`,
|
||||
);
|
||||
await File.downloadFileAsync(url, destination);
|
||||
subtitle.DeliveryUrl = destination.uri;
|
||||
const destination = `${FileSystem.documentDirectory}${filename}_subtitle_${subtitle.Index}`;
|
||||
await FileSystem.downloadAsync(url, destination);
|
||||
subtitle.DeliveryUrl = destination;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -645,86 +542,86 @@ function useDownloadProvider() {
|
||||
progress: process.progress || 0, // Preserve existing progress for resume
|
||||
});
|
||||
|
||||
if (!BackGroundDownloader) {
|
||||
throw new Error("Background downloader not available");
|
||||
}
|
||||
|
||||
BackGroundDownloader.setConfig({
|
||||
isLogsEnabled: true, // Enable logs to debug
|
||||
BackGroundDownloader?.setConfig({
|
||||
isLogsEnabled: false,
|
||||
progressInterval: 500,
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
const filename = generateFilename(process.item);
|
||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||
const videoFilePath = videoFile.uri;
|
||||
console.log(`[DOWNLOAD] Starting download for ${filename}`);
|
||||
console.log(`[DOWNLOAD] Destination path: ${videoFilePath}`);
|
||||
|
||||
BackGroundDownloader.download({
|
||||
const videoFilePath = `${FileSystem.documentDirectory}${filename}.mp4`;
|
||||
BackGroundDownloader?.download({
|
||||
id: process.id,
|
||||
url: process.inputUrl,
|
||||
destination: videoFilePath,
|
||||
metadata: process,
|
||||
});
|
||||
},
|
||||
[authHeader, sendDownloadNotification, getNotificationContent],
|
||||
);
|
||||
})
|
||||
.begin(() => {
|
||||
updateProcess(process.id, {
|
||||
status: "downloading",
|
||||
progress: process.progress || 0,
|
||||
bytesDownloaded: process.bytesDownloaded || 0,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
lastSessionBytes: process.lastSessionBytes || 0,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
});
|
||||
})
|
||||
.progress(
|
||||
throttle((data) => {
|
||||
updateProcess(process.id, (currentProcess) => {
|
||||
// If this is a resumed download, add the paused bytes to current session bytes
|
||||
const resumedBytes = currentProcess.pausedBytes || 0;
|
||||
const totalBytes = data.bytesDownloaded + resumedBytes;
|
||||
|
||||
const manageDownloadQueue = useCallback(() => {
|
||||
// Handle completed downloads (workaround for when .done() callback doesn't fire)
|
||||
const completedDownloads = processes.filter(
|
||||
(p) => p.status === "completed",
|
||||
);
|
||||
for (const completedProcess of completedDownloads) {
|
||||
console.log(
|
||||
`[QUEUE] Processing completed download: ${completedProcess.item.Name}`,
|
||||
);
|
||||
// Calculate progress based on total bytes if we have resumed bytes
|
||||
let percent: number;
|
||||
if (resumedBytes > 0 && data.bytesTotal > 0) {
|
||||
// For resumed downloads, calculate based on estimated total size
|
||||
const estimatedTotal =
|
||||
currentProcess.estimatedTotalSizeBytes ||
|
||||
data.bytesTotal + resumedBytes;
|
||||
percent = (totalBytes / estimatedTotal) * 100;
|
||||
} else {
|
||||
// For fresh downloads, use normal calculation
|
||||
percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||
}
|
||||
|
||||
// Save to database
|
||||
(async () => {
|
||||
try {
|
||||
const filename = generateFilename(completedProcess.item);
|
||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||
const videoFilePath = videoFile.uri;
|
||||
const videoFileSize = videoFile.size;
|
||||
|
||||
console.log(`[QUEUE] Saving completed download to database`);
|
||||
console.log(`[QUEUE] Video file path: ${videoFilePath}`);
|
||||
console.log(`[QUEUE] Video file size: ${videoFileSize}`);
|
||||
console.log(`[QUEUE] Video file exists: ${videoFile.exists}`);
|
||||
|
||||
if (!videoFile.exists) {
|
||||
console.error(
|
||||
`[QUEUE] Cannot save - video file does not exist at ${videoFilePath}`,
|
||||
);
|
||||
removeProcess(completedProcess.id);
|
||||
return;
|
||||
return {
|
||||
speed: calculateSpeed(currentProcess, totalBytes),
|
||||
status: "downloading",
|
||||
progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION),
|
||||
bytesDownloaded: totalBytes,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
// update session-only counters - use current session bytes only for speed calc
|
||||
lastSessionBytes: data.bytesDownloaded,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
};
|
||||
});
|
||||
}, 500),
|
||||
)
|
||||
.done(async () => {
|
||||
const trickPlayData = await downloadTrickplayImages(process.item);
|
||||
const videoFileInfo = await FileSystem.getInfoAsync(videoFilePath);
|
||||
if (!videoFileInfo.exists) {
|
||||
throw new Error("Downloaded file does not exist");
|
||||
}
|
||||
|
||||
const trickPlayData = await downloadTrickplayImages(
|
||||
completedProcess.item,
|
||||
);
|
||||
const videoFileSize = videoFileInfo.size;
|
||||
const db = getDownloadsDatabase();
|
||||
const { item, mediaSource } = completedProcess;
|
||||
|
||||
const { item, mediaSource } = process;
|
||||
// Only download external subtitles for non-transcoded streams.
|
||||
if (!mediaSource.TranscodingUrl) {
|
||||
await downloadAndLinkSubtitles(mediaSource, item);
|
||||
}
|
||||
|
||||
const { introSegments, creditSegments } = await fetchAndParseSegments(
|
||||
item.Id!,
|
||||
api!,
|
||||
);
|
||||
|
||||
const downloadedItem: DownloadedItem = {
|
||||
item,
|
||||
mediaSource,
|
||||
videoFilePath,
|
||||
videoFileSize,
|
||||
videoFileName: `${filename}.mp4`,
|
||||
trickPlayData,
|
||||
userData: {
|
||||
audioStreamIndex: 0,
|
||||
@@ -769,29 +666,63 @@ function useDownloadProvider() {
|
||||
] = downloadedItem;
|
||||
} else if (item.Id) {
|
||||
// Handle other media types
|
||||
if (!db.other) db.other = {};
|
||||
db.other[item.Id] = downloadedItem;
|
||||
}
|
||||
|
||||
await saveDownloadsDatabase(db);
|
||||
|
||||
// Send native notification for successful download
|
||||
const successNotification = getNotificationContent(
|
||||
process.item,
|
||||
true,
|
||||
);
|
||||
await sendDownloadNotification(
|
||||
successNotification.title,
|
||||
successNotification.body,
|
||||
{
|
||||
itemId: process.item.Id,
|
||||
itemName: process.item.Name,
|
||||
type: "download_completed",
|
||||
},
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("home.downloads.toasts.download_completed_for_item", {
|
||||
item: item.Name,
|
||||
item: process.item.Name,
|
||||
}),
|
||||
);
|
||||
removeProcess(process.id);
|
||||
})
|
||||
.error(async (error: any) => {
|
||||
console.error("Download error:", error);
|
||||
|
||||
console.log(
|
||||
`[QUEUE] Removing completed process: ${completedProcess.id}`,
|
||||
// Send native notification for failed download
|
||||
const failureNotification = getNotificationContent(
|
||||
process.item,
|
||||
false,
|
||||
);
|
||||
await sendDownloadNotification(
|
||||
failureNotification.title,
|
||||
failureNotification.body,
|
||||
{
|
||||
itemId: process.item.Id,
|
||||
itemName: process.item.Name,
|
||||
type: "download_failed",
|
||||
error: error?.message || "Unknown error",
|
||||
},
|
||||
);
|
||||
removeProcess(completedProcess.id);
|
||||
} catch (error) {
|
||||
console.error(`[QUEUE] Error processing completed download:`, error);
|
||||
removeProcess(completedProcess.id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
toast.error(
|
||||
t("home.downloads.toasts.download_failed_for_item", {
|
||||
item: process.item.Name,
|
||||
}),
|
||||
);
|
||||
removeProcess(process.id);
|
||||
});
|
||||
},
|
||||
[authHeader, sendDownloadNotification, getNotificationContent],
|
||||
);
|
||||
|
||||
const manageDownloadQueue = useCallback(() => {
|
||||
const activeDownloads = processes.filter(
|
||||
(p) => p.status === "downloading",
|
||||
).length;
|
||||
@@ -812,7 +743,7 @@ function useDownloadProvider() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [processes, settings?.remuxConcurrentLimit, startDownload, api, t]);
|
||||
}, [processes, settings?.remuxConcurrentLimit, startDownload]);
|
||||
|
||||
const removeProcess = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -865,13 +796,12 @@ function useDownloadProvider() {
|
||||
*/
|
||||
const cleanCacheDirectory = async (): Promise<void> => {
|
||||
try {
|
||||
if (APP_CACHE_DOWNLOAD_DIRECTORY.exists) {
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY.delete();
|
||||
}
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY.create({
|
||||
intermediates: true,
|
||||
await FileSystem.deleteAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
||||
idempotent: true,
|
||||
});
|
||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
||||
intermediates: true,
|
||||
});
|
||||
} catch (_error) {
|
||||
toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory"));
|
||||
}
|
||||
@@ -884,14 +814,8 @@ function useDownloadProvider() {
|
||||
mediaSource: MediaSourceInfo,
|
||||
maxBitrate: Bitrate,
|
||||
) => {
|
||||
if (!api || !item.Id || !authHeader) {
|
||||
console.warn("startBackgroundDownload ~ Missing required params", {
|
||||
api,
|
||||
item,
|
||||
authHeader,
|
||||
});
|
||||
if (!api || !item.Id || !authHeader)
|
||||
throw new Error("startBackgroundDownload ~ Missing required params");
|
||||
}
|
||||
try {
|
||||
const deviceId = getOrSetDeviceId();
|
||||
await saveSeriesPrimaryImage(item);
|
||||
@@ -982,109 +906,35 @@ function useDownloadProvider() {
|
||||
}
|
||||
} else {
|
||||
// Handle other media types
|
||||
if (db.other) {
|
||||
downloadedItem = db.other[id];
|
||||
if (downloadedItem) {
|
||||
delete db.other[id];
|
||||
}
|
||||
downloadedItem = db.other[id];
|
||||
if (downloadedItem) {
|
||||
delete db.other[id];
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadedItem?.videoFilePath) {
|
||||
try {
|
||||
console.log(
|
||||
`[DELETE] Attempting to delete video file: ${downloadedItem.videoFilePath}`,
|
||||
);
|
||||
|
||||
// Properly reconstruct File object using Paths.document and filename
|
||||
let videoFile: File;
|
||||
if (downloadedItem.videoFileName) {
|
||||
// New approach: use stored filename with Paths.document
|
||||
videoFile = new File(Paths.document, downloadedItem.videoFileName);
|
||||
console.log(
|
||||
`[DELETE] Reconstructed file from stored filename: ${downloadedItem.videoFileName}`,
|
||||
);
|
||||
} else {
|
||||
// Fallback for old downloads: extract filename from URI
|
||||
const filename = downloadedItem.videoFilePath.split("/").pop();
|
||||
if (!filename) {
|
||||
throw new Error("Could not extract filename from path");
|
||||
}
|
||||
videoFile = new File(Paths.document, filename);
|
||||
console.log(
|
||||
`[DELETE] Reconstructed file from URI (legacy): ${filename}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[DELETE] File URI: ${videoFile.uri}`);
|
||||
console.log(
|
||||
`[DELETE] File exists before deletion: ${videoFile.exists}`,
|
||||
);
|
||||
|
||||
if (videoFile.exists) {
|
||||
videoFile.delete();
|
||||
console.log(`[DELETE] Video file deleted successfully`);
|
||||
} else {
|
||||
console.warn(`[DELETE] File does not exist, skipping deletion`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[DELETE] Failed to delete video file:`, err);
|
||||
// File might not exist, continue anyway
|
||||
}
|
||||
await FileSystem.deleteAsync(downloadedItem.videoFilePath, {
|
||||
idempotent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadedItem?.mediaSource?.MediaStreams) {
|
||||
for (const stream of downloadedItem.mediaSource.MediaStreams) {
|
||||
if (
|
||||
stream.Type === "Subtitle" &&
|
||||
stream.DeliveryMethod === "External" &&
|
||||
stream.DeliveryUrl
|
||||
stream.DeliveryMethod === "External"
|
||||
) {
|
||||
try {
|
||||
console.log(
|
||||
`[DELETE] Deleting subtitle file: ${stream.DeliveryUrl}`,
|
||||
);
|
||||
// Extract filename from the subtitle URI
|
||||
const subtitleFilename = stream.DeliveryUrl.split("/").pop();
|
||||
if (subtitleFilename) {
|
||||
const subtitleFile = new File(Paths.document, subtitleFilename);
|
||||
if (subtitleFile.exists) {
|
||||
subtitleFile.delete();
|
||||
console.log(
|
||||
`[DELETE] Subtitle file deleted: ${subtitleFilename}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[DELETE] Failed to delete subtitle:`, err);
|
||||
// File might not exist, ignore
|
||||
}
|
||||
await FileSystem.deleteAsync(stream.DeliveryUrl!, {
|
||||
idempotent: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadedItem?.trickPlayData?.path) {
|
||||
try {
|
||||
console.log(
|
||||
`[DELETE] Deleting trickplay directory: ${downloadedItem.trickPlayData.path}`,
|
||||
);
|
||||
// Extract directory name from URI
|
||||
const trickplayDirName = downloadedItem.trickPlayData.path
|
||||
.split("/")
|
||||
.pop();
|
||||
if (trickplayDirName) {
|
||||
const trickplayDir = new Directory(Paths.document, trickplayDirName);
|
||||
if (trickplayDir.exists) {
|
||||
trickplayDir.delete();
|
||||
console.log(
|
||||
`[DELETE] Trickplay directory deleted: ${trickplayDirName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[DELETE] Failed to delete trickplay directory:`, err);
|
||||
// Directory might not exist, ignore
|
||||
}
|
||||
await FileSystem.deleteAsync(downloadedItem.trickPlayData.path, {
|
||||
idempotent: true,
|
||||
});
|
||||
}
|
||||
|
||||
await saveDownloadsDatabase(db);
|
||||
@@ -1112,7 +962,6 @@ function useDownloadProvider() {
|
||||
|
||||
/** Deletes all files of a given type. */
|
||||
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
|
||||
const downloadedItems = getDownloadedItems();
|
||||
const itemsToDelete = downloadedItems?.filter(
|
||||
(file) => file.item.Type === type,
|
||||
);
|
||||
@@ -1136,7 +985,7 @@ function useDownloadProvider() {
|
||||
const db = getDownloadsDatabase();
|
||||
if (db.movies[itemId]) {
|
||||
db.movies[itemId] = updatedItem;
|
||||
} else if (db.other?.[itemId]) {
|
||||
} else if (db.other[itemId]) {
|
||||
db.other[itemId] = updatedItem;
|
||||
} else {
|
||||
for (const series of Object.values(db.series)) {
|
||||
@@ -1157,41 +1006,22 @@ function useDownloadProvider() {
|
||||
* @returns The size of the app and the remaining space on the device.
|
||||
*/
|
||||
const appSizeUsage = async () => {
|
||||
const total = Paths.totalDiskSpace;
|
||||
const remaining = Paths.availableDiskSpace;
|
||||
const [total, remaining] = await Promise.all([
|
||||
FileSystem.getTotalDiskCapacityAsync(),
|
||||
FileSystem.getFreeDiskStorageAsync(),
|
||||
]);
|
||||
|
||||
let appSize = 0;
|
||||
try {
|
||||
// Paths.document is a Directory object in the new API
|
||||
const documentDir = Paths.document;
|
||||
console.log(`[STORAGE] Listing contents of: ${documentDir.uri}`);
|
||||
console.log(`[STORAGE] Document dir exists: ${documentDir.exists}`);
|
||||
|
||||
if (!documentDir.exists) {
|
||||
console.warn(`[STORAGE] Document directory does not exist`);
|
||||
return { total, remaining, appSize: 0 };
|
||||
}
|
||||
|
||||
const contents = documentDir.list();
|
||||
console.log(
|
||||
`[STORAGE] Found ${contents.length} items in document directory`,
|
||||
const downloadedFiles = await FileSystem.readDirectoryAsync(
|
||||
`${FileSystem.documentDirectory!}`,
|
||||
);
|
||||
for (const file of downloadedFiles) {
|
||||
const fileInfo = await FileSystem.getInfoAsync(
|
||||
`${FileSystem.documentDirectory!}${file}`,
|
||||
);
|
||||
|
||||
for (const item of contents) {
|
||||
if (item instanceof File) {
|
||||
console.log(`[STORAGE] File: ${item.name}, size: ${item.size} bytes`);
|
||||
appSize += item.size;
|
||||
} else if (item instanceof Directory) {
|
||||
const dirSize = item.size || 0;
|
||||
console.log(
|
||||
`[STORAGE] Directory: ${item.name}, size: ${dirSize} bytes`,
|
||||
);
|
||||
appSize += dirSize;
|
||||
}
|
||||
if (fileInfo.exists) {
|
||||
appSize += fileInfo.size;
|
||||
}
|
||||
console.log(`[STORAGE] Total app size: ${appSize} bytes`);
|
||||
} catch (error) {
|
||||
console.error(`[STORAGE] Error calculating app size:`, error);
|
||||
}
|
||||
return { total, remaining, appSize: appSize };
|
||||
};
|
||||
@@ -1395,7 +1225,7 @@ function useDownloadProvider() {
|
||||
deleteFileByType,
|
||||
getDownloadedItemSize,
|
||||
getDownloadedItemById,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY: APP_CACHE_DOWNLOAD_DIRECTORY.uri,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
cleanCacheDirectory,
|
||||
updateDownloadedItem,
|
||||
appSizeUsage,
|
||||
|
||||
@@ -46,8 +46,6 @@ export interface DownloadedItem {
|
||||
videoFilePath: string;
|
||||
/** The size of the video file in bytes. */
|
||||
videoFileSize: number;
|
||||
/** The video filename (for easy File object reconstruction). Optional for backwards compatibility. */
|
||||
videoFileName?: string;
|
||||
/** The local file path of the downloaded trickplay images. */
|
||||
trickPlayData?: TrickPlayData;
|
||||
/** The intro segments for the item. */
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface ModalOptions {
|
||||
enableDynamicSizing?: boolean;
|
||||
snapPoints?: (string | number)[];
|
||||
enablePanDownToClose?: boolean;
|
||||
backgroundStyle?: object;
|
||||
handleIndicatorStyle?: object;
|
||||
}
|
||||
|
||||
interface GlobalModalState {
|
||||
content: ReactNode | null;
|
||||
options?: ModalOptions;
|
||||
}
|
||||
|
||||
interface GlobalModalContextType {
|
||||
showModal: (content: ReactNode, options?: ModalOptions) => void;
|
||||
hideModal: () => void;
|
||||
isVisible: boolean;
|
||||
modalState: GlobalModalState;
|
||||
modalRef: React.RefObject<BottomSheetModal>;
|
||||
}
|
||||
|
||||
const GlobalModalContext = createContext<GlobalModalContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useGlobalModal = () => {
|
||||
const context = useContext(GlobalModalContext);
|
||||
if (!context) {
|
||||
throw new Error("useGlobalModal must be used within GlobalModalProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface GlobalModalProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [modalState, setModalState] = useState<GlobalModalState>({
|
||||
content: null,
|
||||
options: undefined,
|
||||
});
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const modalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const showModal = useCallback(
|
||||
(content: ReactNode, options?: ModalOptions) => {
|
||||
setModalState({ content, options });
|
||||
setIsVisible(true);
|
||||
// Small delay to ensure state is updated before presenting
|
||||
setTimeout(() => {
|
||||
modalRef.current?.present();
|
||||
}, 100);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const hideModal = useCallback(() => {
|
||||
modalRef.current?.dismiss();
|
||||
setIsVisible(false);
|
||||
// Clear content after animation completes
|
||||
setTimeout(() => {
|
||||
setModalState({ content: null, options: undefined });
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
showModal,
|
||||
hideModal,
|
||||
isVisible,
|
||||
modalState,
|
||||
modalRef,
|
||||
};
|
||||
|
||||
return (
|
||||
<GlobalModalContext.Provider value={value}>
|
||||
{children}
|
||||
</GlobalModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type { GlobalModalContextType, ModalOptions };
|
||||
@@ -203,7 +203,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const removeServerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
storage.remove("serverUrl");
|
||||
storage.delete("serverUrl");
|
||||
setApi(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -286,7 +286,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
writeErrorLog("Failed to delete expo push token for device"),
|
||||
);
|
||||
|
||||
storage.remove("token");
|
||||
storage.delete("token");
|
||||
setUser(null);
|
||||
setApi(null);
|
||||
setPluginSettings(undefined);
|
||||
|
||||
500
translations/ar.json
Normal file
500
translations/ar.json
Normal file
@@ -0,0 +1,500 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "اسم المستخدم مطلوب",
|
||||
"error_title": "خطأ",
|
||||
"login_title": "تسجيل الدخول",
|
||||
"login_to_title": "تسجيل الدخول إلى",
|
||||
"username_placeholder": "اسم المستخدم",
|
||||
"password_placeholder": "كلمة المرور",
|
||||
"login_button": "تسجيل الدخول",
|
||||
"quick_connect": "اتصال سريع",
|
||||
"enter_code_to_login": "أدخل الرمز {{code}} لتسجيل الدخول",
|
||||
"failed_to_initiate_quick_connect": "فشل في بدء الاتصال السريع",
|
||||
"got_it": "حسنًا",
|
||||
"connection_failed": "فشل الاتصال",
|
||||
"could_not_connect_to_server": "تعذر الاتصال بالخادم. يرجى التحقق من الرابط واتصال الشبكة.",
|
||||
"an_unexpected_error_occured": "حدث خطأ غير متوقع",
|
||||
"change_server": "تغيير الخادم",
|
||||
"invalid_username_or_password": "اسم المستخدم أو كلمة المرور غير صالحة",
|
||||
"user_does_not_have_permission_to_log_in": "ليس لدى المستخدم صلاحية تسجيل الدخول",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "يستغرق الخادم وقتًا طويلاً للرد، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"server_received_too_many_requests_try_again_later": "تلقى الخادم عددًا كبيرًا جدًا من الطلبات، يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"there_is_a_server_error": "هناك خطأ في الخادم",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
|
||||
"too_old_server_text": "تم اكتشاف خادم jellyfin غير مدعوم",
|
||||
"too_old_server_description": "يرجى تحديث jellyfin إلى أحدث إصدار"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "أدخل رابط خادم Jellyfin الخاص بك",
|
||||
"server_url_placeholder": "http(s)://your-server.com",
|
||||
"connect_button": "اتصال",
|
||||
"previous_servers": "الخوادم السابقة",
|
||||
"clear_button": "مسح",
|
||||
"search_for_local_servers": "البحث عن الخوادم المحلية",
|
||||
"searching": "يبحث...",
|
||||
"servers": "الخوادم"
|
||||
},
|
||||
"home": {
|
||||
"no_internet": "لا يوجد اتصال بالإنترنت",
|
||||
"no_items": "لا توجد عناصر",
|
||||
"no_internet_message": "لا تقلق، لا يزال بإمكانك مشاهدة المحتوى الذي تم تنزيله.",
|
||||
"go_to_downloads": "الذهاب إلى التنزيلات",
|
||||
"oops": "عفوًا!",
|
||||
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
|
||||
"continue_watching": "متابعة المشاهدة",
|
||||
"next_up": "التالي",
|
||||
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
||||
"suggested_movies": "أفلام مقترحة",
|
||||
"suggested_episodes": "حلقات مقترحة",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "مرحبًا بك في Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "عميل مجاني ومفتوح المصدر لـ Jellyfin.",
|
||||
"features_title": "الميزات",
|
||||
"features_description": "يحتوي Streamyfin على مجموعة من الميزات ويتكامل مع مجموعة واسعة من البرامج التي يمكنك العثور عليها في قائمة الإعدادات، وتشمل:",
|
||||
"jellyseerr_feature_description": "اتصل بمثيل Jellyseerr الخاص بك واطلب الأفلام مباشرة في التطبيق.",
|
||||
"downloads_feature_title": "التنزيلات",
|
||||
"downloads_feature_description": "قم بتنزيل الأفلام والمسلسلات التلفزيونية لمشاهدتها في وضع عدم الاتصال. استخدم إما الطريقة الافتراضية أو قم بتثبيت الخادم المحسن لتنزيل الملفات في الخلفية.",
|
||||
"chromecast_feature_description": "قم ببث الأفلام والبرامج التلفزيونية على أجهزة Chromecast الخاصة بك.",
|
||||
"centralised_settings_plugin_title": "إضافة الإعدادات المركزية",
|
||||
"centralised_settings_plugin_description": "قم بتكوين الإعدادات من موقع مركزي على خادم Jellyfin الخاص بك. ستتم مزامنة جميع إعدادات العميل لجميع المستخدمين تلقائيًا.",
|
||||
"done_button": "تم",
|
||||
"go_to_settings_button": "الذهاب إلى الإعدادات",
|
||||
"read_more": "اقرأ المزيد"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "الإعدادات",
|
||||
"log_out_button": "تسجيل الخروج",
|
||||
"user_info": {
|
||||
"user_info_title": "معلومات المستخدم",
|
||||
"user": "المستخدم",
|
||||
"server": "الخادم",
|
||||
"token": "الرمز",
|
||||
"app_version": "إصدار التطبيق"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "اتصال سريع",
|
||||
"authorize_button": "تفويض الاتصال السريع",
|
||||
"enter_the_quick_connect_code": "أدخل رمز الاتصال السريع...",
|
||||
"success": "نجاح",
|
||||
"quick_connect_autorized": "تم تفويض الاتصال السريع",
|
||||
"error": "خطأ",
|
||||
"invalid_code": "رمز غير صالح",
|
||||
"authorize": "تفويض"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "عناصر التحكم بالوسائط",
|
||||
"forward_skip_length": "مدة التقديم السريع",
|
||||
"rewind_length": "مدة الترجيع",
|
||||
"seconds_unit": "ث"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "التحكم بالإيماءات",
|
||||
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
|
||||
"horizontal_swipe_skip_description": "اسحب لليسار/لليمين عندما تكون عناصر التحكم مخفية للتخطي",
|
||||
"left_side_brightness": "التحكم في السطوع من الجانب الأيسر",
|
||||
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
|
||||
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
|
||||
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "الصوت",
|
||||
"set_audio_track": "تعيين مسار الصوت من العنصر السابق",
|
||||
"audio_language": "لغة الصوت",
|
||||
"audio_hint": "اختر لغة صوت افتراضية.",
|
||||
"none": "لا شيء",
|
||||
"language": "اللغة"
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "الترجمة",
|
||||
"subtitle_language": "لغة الترجمة",
|
||||
"subtitle_mode": "وضع الترجمة",
|
||||
"set_subtitle_track": "تعيين مسار الترجمة من العنصر السابق",
|
||||
"subtitle_size": "حجم الترجمة",
|
||||
"subtitle_hint": "تكوين تفضيلات الترجمة.",
|
||||
"none": "لا شيء",
|
||||
"language": "اللغة",
|
||||
"loading": "جار التحميل",
|
||||
"modes": {
|
||||
"Default": "افتراضي",
|
||||
"Smart": "ذكي",
|
||||
"Always": "دائماً",
|
||||
"None": "لا شيء",
|
||||
"OnlyForced": "فقط الإجبارية"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"other_title": "أخرى",
|
||||
"follow_device_orientation": "تدوير تلقائي",
|
||||
"video_orientation": "اتجاه الفيديو",
|
||||
"orientation": "الاتجاه",
|
||||
"orientations": {
|
||||
"DEFAULT": "افتراضي",
|
||||
"ALL": "الكل",
|
||||
"PORTRAIT": "عمودي",
|
||||
"PORTRAIT_UP": "عمودي لأعلى",
|
||||
"PORTRAIT_DOWN": "عمودي لأسفل",
|
||||
"LANDSCAPE": "أفقي",
|
||||
"LANDSCAPE_LEFT": "أفقي لليسار",
|
||||
"LANDSCAPE_RIGHT": "أفقي لليمين",
|
||||
"OTHER": "أخرى",
|
||||
"UNKNOWN": "غير معروف"
|
||||
},
|
||||
"safe_area_in_controls": "مساحة آمنة في عناصر التحكم",
|
||||
"video_player": "مشغل الفيديو",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (تجريبي + صورة داخل صورة)"
|
||||
},
|
||||
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
||||
"hide_libraries": "إخفاء المكتبات",
|
||||
"select_liraries_you_want_to_hide": "حدد المكتبات التي تريد إخفاءها من علامة تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
||||
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
||||
"default_quality": "الجودة الافتراضية",
|
||||
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
||||
"disabled": "معطل"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "التنزيلات",
|
||||
"download_method": "طريقة التنزيل",
|
||||
"remux_max_download": "الحد الأقصى لتنزيل الريمكس",
|
||||
"auto_download": "تنزيل تلقائي",
|
||||
"optimized_versions_server": "خادم الإصدارات المحسّنة",
|
||||
"save_button": "حفظ",
|
||||
"optimized_server": "الخادم المحسن",
|
||||
"optimized": "محسن",
|
||||
"default": "افتراضي",
|
||||
"optimized_version_hint": "أدخل رابط الخادم المحسن. يجب أن يتضمن الرابط http أو https ويمكن أن يتضمن المنفذ اختياريًا.",
|
||||
"read_more_about_optimized_server": "اقرأ المزيد عن الخادم المحسن.",
|
||||
"url": "الرابط",
|
||||
"server_url_placeholder": "http(s)://domain.org:port"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "الإضافات",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "هذا التكامل في مراحله الأولى. توقع أن تتغير الأمور.",
|
||||
"server_url": "رابط الخادم",
|
||||
"server_url_hint": "مثال: http(s)://your-host.url\n(أضف المنفذ إذا لزم الأمر)",
|
||||
"server_url_placeholder": "رابط Jellyseerr...",
|
||||
"password": "كلمة المرور",
|
||||
"password_placeholder": "أدخل كلمة المرور لمستخدم Jellyfin {{username}}",
|
||||
"save_button": "حفظ",
|
||||
"clear_button": "مسح",
|
||||
"login_button": "تسجيل الدخول",
|
||||
"total_media_requests": "إجمالي طلبات الوسائط",
|
||||
"movie_quota_limit": "حد حصة الأفلام",
|
||||
"movie_quota_days": "أيام حصة الأفلام",
|
||||
"tv_quota_limit": "حد حصة المسلسلات",
|
||||
"tv_quota_days": "أيام حصة المسلسلات",
|
||||
"reset_jellyseerr_config_button": "إعادة تعيين تكوين Jellyseerr",
|
||||
"unlimited": "غير محدود",
|
||||
"plus_n_more": "+{{n}} المزيد",
|
||||
"order_by": {
|
||||
"DEFAULT": "افتراضي",
|
||||
"VOTE_COUNT_AND_AVERAGE": "عدد الأصوات والمعدل",
|
||||
"POPULARITY": "الشعبية"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "تمكين بحث مارلن",
|
||||
"url": "الرابط",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "أدخل رابط خادم مارلن. يجب أن يتضمن الرابط http أو https ويمكن أن يتضمن المنفذ اختياريًا.",
|
||||
"read_more_about_marlin": "اقرأ المزيد عن مارلن.",
|
||||
"save_button": "حفظ",
|
||||
"toasts": {
|
||||
"saved": "تم الحفظ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "التخزين",
|
||||
"app_usage": "التطبيق {{usedSpace}}%",
|
||||
"device_usage": "الجهاز {{availableSpace}}%",
|
||||
"size_used": "تم استخدام {{used}} من {{total}}",
|
||||
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها"
|
||||
},
|
||||
"intro": {
|
||||
"show_intro": "إظهار المقدمة",
|
||||
"reset_intro": "إعادة تعيين المقدمة"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "السجلات",
|
||||
"export_logs": "تصدير السجلات",
|
||||
"click_for_more_info": "انقر لمزيد من المعلومات",
|
||||
"level": "المستوى",
|
||||
"no_logs_available": "لا توجد سجلات متاحة",
|
||||
"delete_all_logs": "حذف جميع السجلات"
|
||||
},
|
||||
"languages": {
|
||||
"title": "اللغات",
|
||||
"app_language": "لغة التطبيق",
|
||||
"app_language_description": "حدد لغة التطبيق.",
|
||||
"system": "النظام"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "خطأ في حذف الملفات",
|
||||
"background_downloads_enabled": "تمكين التنزيلات في الخلفية",
|
||||
"background_downloads_disabled": "تعطيل التنزيلات في الخلفية",
|
||||
"connected": "متصل",
|
||||
"could_not_connect": "تعذر الاتصال",
|
||||
"invalid_url": "رابط غير صالح"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "الجلسات",
|
||||
"no_active_sessions": "لا توجد جلسات نشطة"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "التنزيلات",
|
||||
"tvseries": "مسلسلات",
|
||||
"movies": "أفلام",
|
||||
"queue": "قائمة الانتظار",
|
||||
"queue_hint": "ستفقد قائمة الانتظار والتنزيلات عند إعادة تشغيل التطبيق",
|
||||
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
||||
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
||||
"delete_all_movies_button": "حذف جميع الأفلام",
|
||||
"delete_all_tvseries_button": "حذف جميع المسلسلات",
|
||||
"delete_all_button": "حذف الكل",
|
||||
"active_download": "تنزيل نشط",
|
||||
"no_active_downloads": "لا توجد تنزيلات نشطة",
|
||||
"active_downloads": "تنزيلات نشطة",
|
||||
"new_app_version_requires_re_download": "يتطلب إصدار التطبيق الجديد إعادة التنزيل",
|
||||
"new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.",
|
||||
"back": "رجوع",
|
||||
"delete": "حذف",
|
||||
"something_went_wrong": "حدث خطأ ما",
|
||||
"could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin",
|
||||
"eta": "الوقت المتبقي {{eta}}",
|
||||
"methods": "الطرق",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "غير مسموح لك بتنزيل الملفات.",
|
||||
"deleted_all_movies_successfully": "تم حذف جميع الأفلام بنجاح!",
|
||||
"failed_to_delete_all_movies": "فشل حذف جميع الأفلام",
|
||||
"deleted_all_tvseries_successfully": "تم حذف جميع المسلسلات بنجاح!",
|
||||
"failed_to_delete_all_tvseries": "فشل حذف جميع المسلسلات",
|
||||
"download_deleted": "تم حذف التنزيل",
|
||||
"could_not_delete_download": "تعذر حذف التنزيل",
|
||||
"download_paused": "تم إيقاف التنزيل مؤقتًا",
|
||||
"could_not_pause_download": "تعذر إيقاف التنزيل مؤقتًا",
|
||||
"download_resumed": "تم استئناف التنزيل",
|
||||
"could_not_resume_download": "تعذر استئناف التنزيل",
|
||||
"download_completed": "اكتمل التنزيل",
|
||||
"download_started_for": "بدأ تنزيل {{item}}",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} جاهز للتنزيل",
|
||||
"download_stated_for_item": "بدأ تنزيل {{item}}",
|
||||
"download_failed_for_item": "فشل تنزيل {{item}} - {{error}}",
|
||||
"download_completed_for_item": "اكتمل تنزيل {{item}}",
|
||||
"queued_item_for_optimization": "تمت إضافة {{item}} إلى قائمة الانتظار للتحسين",
|
||||
"failed_to_start_download_for_item": "فشل بدء تنزيل {{item}}: {{message}}",
|
||||
"server_responded_with_status_code": "استجاب الخادم بالحالة {{statusCode}}",
|
||||
"no_response_received_from_server": "لم يتم تلقي أي رد من الخادم",
|
||||
"error_setting_up_the_request": "خطأ في إعداد الطلب",
|
||||
"failed_to_start_download_for_item_unexpected_error": "فشل بدء تنزيل {{item}}: خطأ غير متوقع",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "تم حذف جميع الملفات والمجلدات والمهام بنجاح",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "حدث خطأ أثناء حذف الملفات والمهام",
|
||||
"go_to_downloads": "الذهاب إلى التنزيلات"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"search_here": "ابحث هنا...",
|
||||
"search": "بحث...",
|
||||
"x_items": "{{count}} عناصر",
|
||||
"library": "المكتبة",
|
||||
"discover": "اكتشف",
|
||||
"no_results": "لا توجد نتائج",
|
||||
"no_results_found_for": "لم يتم العثور على نتائج لـ",
|
||||
"movies": "أفلام",
|
||||
"series": "مسلسلات",
|
||||
"episodes": "حلقات",
|
||||
"collections": "مجموعات",
|
||||
"actors": "ممثلون",
|
||||
"request_movies": "طلب أفلام",
|
||||
"request_series": "طلب مسلسلات",
|
||||
"recently_added": "أضيف مؤخراً",
|
||||
"recent_requests": "الطلبات الأخيرة",
|
||||
"plex_watchlist": "قائمة مشاهدة Plex",
|
||||
"trending": "شائع",
|
||||
"popular_movies": "أفلام شائعة",
|
||||
"movie_genres": "أنواع الأفلام",
|
||||
"upcoming_movies": "أفلام قادمة",
|
||||
"studios": "استوديوهات",
|
||||
"popular_tv": "مسلسلات شائعة",
|
||||
"tv_genres": "أنواع المسلسلات",
|
||||
"upcoming_tv": "مسلسلات قادمة",
|
||||
"networks": "شبكات",
|
||||
"tmdb_movie_keyword": "كلمة مفتاحية لفيلم TMDB",
|
||||
"tmdb_movie_genre": "نوع فيلم TMDB",
|
||||
"tmdb_tv_keyword": "كلمة مفتاحية لمسلسل TMDB",
|
||||
"tmdb_tv_genre": "نوع مسلسل TMDB",
|
||||
"tmdb_search": "بحث TMDB",
|
||||
"tmdb_studio": "استوديو TMDB",
|
||||
"tmdb_network": "شبكة TMDB",
|
||||
"tmdb_movie_streaming_services": "خدمات بث الأفلام TMDB",
|
||||
"tmdb_tv_streaming_services": "خدمات بث المسلسلات TMDB"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "لم يتم العثور على عناصر",
|
||||
"no_results": "لا توجد نتائج",
|
||||
"no_libraries_found": "لم يتم العثور على مكتبات",
|
||||
"item_types": {
|
||||
"movies": "أفلام",
|
||||
"series": "مسلسلات",
|
||||
"boxsets": "مجموعات",
|
||||
"items": "عناصر"
|
||||
},
|
||||
"options": {
|
||||
"display": "عرض",
|
||||
"row": "صف",
|
||||
"list": "قائمة",
|
||||
"image_style": "نمط الصورة",
|
||||
"poster": "ملصق",
|
||||
"cover": "غلاف",
|
||||
"show_titles": "إظهار العناوين",
|
||||
"show_stats": "إظهار الإحصائيات"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "الأنواع",
|
||||
"years": "السنوات",
|
||||
"sort_by": "ترتيب حسب",
|
||||
"sort_order": "ترتيب",
|
||||
"asc": "تصاعدي",
|
||||
"desc": "تنازلي",
|
||||
"tags": "الوسوم"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"series": "مسلسلات",
|
||||
"movies": "أفلام",
|
||||
"episodes": "حلقات",
|
||||
"videos": "فيديوهات",
|
||||
"boxsets": "مجموعات",
|
||||
"playlists": "قوائم التشغيل",
|
||||
"noDataTitle": "لا توجد مفضلات بعد",
|
||||
"noData": "ضع علامة على العناصر كمفضلة لتظهر هنا للوصول السريع."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "لا توجد روابط"
|
||||
},
|
||||
"player": {
|
||||
"error": "خطأ",
|
||||
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
|
||||
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
|
||||
"client_error": "خطأ في العميل",
|
||||
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـ Chromecast",
|
||||
"message_from_server": "رسالة من الخادم: {{message}}",
|
||||
"video_has_finished_playing": "انتهى تشغيل الفيديو!",
|
||||
"no_video_source": "لا يوجد مصدر فيديو...",
|
||||
"next_episode": "الحلقة التالية",
|
||||
"refresh_tracks": "تحديث المسارات",
|
||||
"subtitle_tracks": "مسارات الترجمة:",
|
||||
"audio_tracks": "مسارات الصوت:",
|
||||
"playback_state": "حالة التشغيل:",
|
||||
"no_data_available": "لا توجد بيانات متاحة",
|
||||
"index": "الفهرس:",
|
||||
"continue_watching": "متابعة المشاهدة",
|
||||
"go_back": "رجوع"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "التالي",
|
||||
"no_items_to_display": "لا توجد عناصر لعرضها",
|
||||
"cast_and_crew": "طاقم العمل",
|
||||
"series": "مسلسلات",
|
||||
"seasons": "مواسم",
|
||||
"season": "موسم",
|
||||
"no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم",
|
||||
"overview": "نظرة عامة",
|
||||
"more_with": "المزيد مع {{name}}",
|
||||
"similar_items": "عناصر مشابهة",
|
||||
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
|
||||
"video": "فيديو",
|
||||
"more_details": "المزيد من التفاصيل",
|
||||
"quality": "الجودة",
|
||||
"audio": "الصوت",
|
||||
"subtitles": "الترجمة",
|
||||
"show_more": "عرض المزيد",
|
||||
"show_less": "عرض أقل",
|
||||
"appeared_in": "ظهر في",
|
||||
"could_not_load_item": "تعذر تحميل العنصر",
|
||||
"none": "لا شيء",
|
||||
"download": {
|
||||
"download_season": "تنزيل الموسم",
|
||||
"download_series": "تنزيل المسلسل",
|
||||
"download_episode": "تنزيل الحلقة",
|
||||
"download_movie": "تنزيل الفيلم",
|
||||
"download_x_item": "تنزيل {{item_count}} عناصر",
|
||||
"download_unwatched_only": "فقط غير المشاهدة",
|
||||
"download_button": "تنزيل",
|
||||
"using_optimized_server": "استخدام الخادم المحسن",
|
||||
"using_default_method": "استخدام الطريقة الافتراضية"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "التالي",
|
||||
"previous": "السابق",
|
||||
"live_tv": "بث مباشر",
|
||||
"coming_soon": "قريباً",
|
||||
"on_now": "يعرض الآن",
|
||||
"shows": "برامج",
|
||||
"movies": "أفلام",
|
||||
"sports": "رياضة",
|
||||
"for_kids": "للأطفال",
|
||||
"news": "أخبار"
|
||||
},
|
||||
"jellyseerr": {
|
||||
"confirm": "تأكيد",
|
||||
"cancel": "إلغاء",
|
||||
"yes": "نعم",
|
||||
"whats_wrong": "ما المشكلة؟",
|
||||
"issue_type": "نوع المشكلة",
|
||||
"select_an_issue": "حدد مشكلة",
|
||||
"types": "الأنواع",
|
||||
"describe_the_issue": "(اختياري) صف المشكلة...",
|
||||
"submit_button": "إرسال",
|
||||
"report_issue_button": "الإبلاغ عن مشكلة",
|
||||
"request_button": "طلب",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "هل أنت متأكد أنك تريد طلب جميع المواسم؟",
|
||||
"failed_to_login": "فشل تسجيل الدخول",
|
||||
"cast": "طاقم العمل",
|
||||
"details": "التفاصيل",
|
||||
"status": "الحالة",
|
||||
"original_title": "العنوان الأصلي",
|
||||
"series_type": "نوع المسلسل",
|
||||
"release_dates": "تواريخ الإصدار",
|
||||
"first_air_date": "تاريخ أول عرض",
|
||||
"next_air_date": "تاريخ العرض التالي",
|
||||
"revenue": "الإيرادات",
|
||||
"budget": "الميزانية",
|
||||
"original_language": "اللغة الأصلية",
|
||||
"production_country": "بلد الإنتاج",
|
||||
"studios": "استوديوهات",
|
||||
"network": "شبكة",
|
||||
"currently_streaming_on": "يتم بثه حاليًا على",
|
||||
"advanced": "متقدم",
|
||||
"request_as": "طلب باسم",
|
||||
"tags": "الوسوم",
|
||||
"quality_profile": "ملف تعريف الجودة",
|
||||
"root_folder": "المجلد الجذر",
|
||||
"season_all": "الموسم (الكل)",
|
||||
"season_number": "الموسم {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} حلقات",
|
||||
"born": "مواليد",
|
||||
"appearances": "المشاركات",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "خادم Jellyseerr لا يفي بالحد الأدنى من متطلبات الإصدار! يرجى التحديث إلى 2.0.0 على الأقل",
|
||||
"jellyseerr_test_failed": "فشل اختبار Jellyseerr. يرجى المحاولة مرة أخرى.",
|
||||
"failed_to_test_jellyseerr_server_url": "فشل اختبار رابط خادم jellyseerr",
|
||||
"issue_submitted": "تم إرسال المشكلة!",
|
||||
"requested_item": "تم طلب {{item}}!",
|
||||
"you_dont_have_permission_to_request": "ليس لديك إذن للطلب!",
|
||||
"something_went_wrong_requesting_media": "حدث خطأ ما أثناء طلب الوسائط!"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"home": "الرئيسية",
|
||||
"search": "بحث",
|
||||
"library": "المكتبة",
|
||||
"custom_links": "روابط مخصصة",
|
||||
"favorites": "المفضلة"
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@
|
||||
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
|
||||
"features_title": "Features",
|
||||
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
|
||||
"jellyseerr_feature_description": "Connect to your Jellyseerr instance and request movies directly in the app.",
|
||||
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
||||
"downloads_feature_title": "Downloads",
|
||||
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
|
||||
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
|
||||
@@ -199,7 +199,7 @@
|
||||
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
||||
"server_url": "Server URL",
|
||||
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"server_url_placeholder": "Seerr URL",
|
||||
"password": "Password",
|
||||
"password_placeholder": "Enter password for Jellyfin user {{username}}",
|
||||
"login_button": "Login",
|
||||
@@ -208,7 +208,7 @@
|
||||
"movie_quota_days": "Movie Quota Days",
|
||||
"tv_quota_limit": "TV Quota Limit",
|
||||
"tv_quota_days": "TV Quota Days",
|
||||
"reset_jellyseerr_config_button": "Reset Jellyseerr Config",
|
||||
"reset_jellyseerr_config_button": "Reset Seerr Config",
|
||||
"unlimited": "Unlimited",
|
||||
"plus_n_more": "+{{n}} More",
|
||||
"order_by": {
|
||||
@@ -388,7 +388,7 @@
|
||||
"movies": "Movies",
|
||||
"episodes": "Episodes",
|
||||
"videos": "Videos",
|
||||
"boxsets": "Boxsets",
|
||||
"boxsets": "Box Sets",
|
||||
"playlists": "Playlists",
|
||||
"noDataTitle": "No Favorites Yet",
|
||||
"noData": "Mark items as favorites to see them appear here for quick access."
|
||||
@@ -494,9 +494,9 @@
|
||||
"born": "Born",
|
||||
"appearances": "Appearances",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"jellyseerr_test_failed": "Jellyseerr test failed. Please try again.",
|
||||
"failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url",
|
||||
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
||||
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
||||
"issue_submitted": "Issue Submitted!",
|
||||
"requested_item": "Requested {{item}}!",
|
||||
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
||||
|
||||
@@ -6,7 +6,17 @@
|
||||
"jsxImportSource": "react",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": ".tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"app/**/*",
|
||||
|
||||
@@ -91,7 +91,7 @@ export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (key) => {
|
||||
storage.remove(key);
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -108,7 +108,7 @@ export const sortOrderPreferenceAtom = atomWithStorage<SortOrderPreference>(
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (key) => {
|
||||
storage.remove(key);
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -93,6 +93,7 @@ export type HomeSection = {
|
||||
items?: HomeSectionItemResolver;
|
||||
nextUp?: HomeSectionNextUpResolver;
|
||||
latest?: HomeSectionLatestResolver;
|
||||
custom?: HomeSectionCustomEndpointResolver;
|
||||
};
|
||||
|
||||
export type HomeSectionItemResolver = {
|
||||
@@ -106,6 +107,13 @@ export type HomeSectionItemResolver = {
|
||||
filters?: Array<ItemFilter>;
|
||||
};
|
||||
|
||||
export type HomeSectionCustomEndpointResolver = {
|
||||
title?: string;
|
||||
endpoint: string;
|
||||
headers?: any;
|
||||
query?: any;
|
||||
};
|
||||
|
||||
export type HomeSectionNextUpResolver = {
|
||||
parentId?: string;
|
||||
limit?: number;
|
||||
|
||||
@@ -16,7 +16,7 @@ interface LogEntry {
|
||||
const mmkvStorage = createJSONStorage(() => ({
|
||||
getItem: (key: string) => storage.getString(key) || null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.remove(key),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
}));
|
||||
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
|
||||
|
||||
@@ -74,7 +74,7 @@ export const readFromLog = (): LogEntry[] => {
|
||||
};
|
||||
|
||||
export const clearLogs = () => {
|
||||
storage.remove("logs");
|
||||
storage.delete("logs");
|
||||
};
|
||||
|
||||
export const dumpDownloadDiagnostics = (extra: any = {}) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createMMKV } from "react-native-mmkv";
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
|
||||
// Create a single MMKV instance following the official documentation
|
||||
// https://github.com/mrousavy/react-native-mmkv
|
||||
export const storage = createMMKV();
|
||||
export const storage = new MMKV();
|
||||
|
||||
Reference in New Issue
Block a user