mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-26 05:52:34 +00:00
Compare commits
15 Commits
remove-opt
...
password-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
479e23f001 | ||
|
|
019f863b3e | ||
|
|
54d8693999 | ||
|
|
8c0cbffd98 | ||
|
|
9f9d949891 | ||
|
|
8a782fb0c9 | ||
|
|
7d0b6c37b8 | ||
|
|
5443222e4e | ||
|
|
a77e86be21 | ||
|
|
9d82549cd6 | ||
|
|
cb5cb8bff7 | ||
|
|
0b61fb7d98 | ||
|
|
f762b9017f | ||
|
|
b063fd6405 | ||
|
|
88ac94ebeb |
41
.github/copilot-instructions.md
vendored
41
.github/copilot-instructions.md
vendored
@@ -3,7 +3,7 @@
|
||||
## Project Overview
|
||||
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr 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
|
||||
@@ -40,30 +40,9 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||
- `plugins/` – Expo/Metro plugins
|
||||
|
||||
## Code Quality Standards
|
||||
## Coding Standards
|
||||
|
||||
**CRITICAL: Code must be production-ready, reliable, and maintainable**
|
||||
|
||||
### Type Safety
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
|
||||
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
|
||||
- When facing type issues, create proper type definitions and helper functions instead of using `any`
|
||||
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
|
||||
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
|
||||
- Enable and respect strict TypeScript compiler options
|
||||
- Define explicit return types for functions
|
||||
- Use discriminated unions for complex state
|
||||
|
||||
### Code Reliability
|
||||
- Implement comprehensive error handling with try-catch blocks
|
||||
- Validate all external inputs (API responses, user input, query params)
|
||||
- Handle edge cases explicitly (empty arrays, null, undefined)
|
||||
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
|
||||
- Add runtime checks for critical operations
|
||||
- Implement proper loading and error states in components
|
||||
|
||||
### Best Practices
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
@@ -71,10 +50,8 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
- Follow BiomeJS formatting and linting rules
|
||||
- Use `const` over `let`, avoid `var` entirely
|
||||
- Implement proper error boundaries
|
||||
- Use React.memo() for performance optimization when needed
|
||||
- Use React.memo() for performance optimization
|
||||
- Handle both mobile and TV navigation patterns
|
||||
- Write self-documenting code with clear intent
|
||||
- Add comments only when code complexity requires explanation
|
||||
|
||||
## API Integration
|
||||
|
||||
@@ -108,18 +85,6 @@ Exemples:
|
||||
- `fix(auth): handle expired JWT tokens`
|
||||
- `chore(deps): update Jellyfin SDK`
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
|
||||
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
|
||||
- **NEVER add or remove keys** - Crowdin manages the key structure
|
||||
- **Editing translation values is safe** - Bidirectional sync handles merges
|
||||
- Prefer letting Crowdin translators update values, but direct edits work if needed
|
||||
- **Crowdin workflow**:
|
||||
- New keys added to `en.json` sync to Crowdin automatically
|
||||
- Approved translations sync back to language files via GitHub integration
|
||||
- The source of truth is `en.json` for structure, Crowdin for translations
|
||||
|
||||
## Special Instructions
|
||||
|
||||
- Prioritize cross-platform compatibility (mobile + TV)
|
||||
|
||||
32
.github/workflows/build-apps.yml
vendored
32
.github/workflows/build-apps.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -41,12 +41,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -124,12 +124,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -195,12 +195,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
@@ -251,7 +251,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -259,12 +259,12 @@ jobs:
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
|
||||
6
.github/workflows/check-lockfile.yml
vendored
6
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
|
||||
8
.github/workflows/ci-codeql.yml
vendored
8
.github/workflows/ci-codeql.yml
vendored
@@ -24,16 +24,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
|
||||
4
.github/workflows/crowdin.yml
vendored
4
.github/workflows/crowdin.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
||||
uses: crowdin/github-action@5587c43063e52090026857d386174d2599ad323b # v2.14.1
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
|
||||
10
.github/workflows/linting.yml
vendored
10
.github/workflows/linting.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -69,14 +69,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
node-version: '24.x'
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
|
||||
4
.github/workflows/update-issue-form.yml
vendored
4
.github/workflows/update-issue-form.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
dry_run: no-push
|
||||
|
||||
- name: 📬 Commit and create pull request
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||
branch: ci-update-bug-report
|
||||
|
||||
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Seerr integration.
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
||||
101
README.md
101
README.md
@@ -22,75 +22,58 @@
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
|
||||
|
||||
<img src="./assets/images/seerr.PNG" width="21%">
|
||||
<img src="./assets/images/jellyseerr.PNG" width="21%">
|
||||
</p>
|
||||
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
### 🎬 Media Playback
|
||||
- 🚀 **Skip Intro / Credits**: Automatically skip intros and credits during playback
|
||||
- 🖼️ **Trickplay Images**: Chapter previews with thumbnails when seeking
|
||||
- 🎵 **Music Library**: Full support for music playback with playlists and queue management
|
||||
- 📺 **Live TV**: Watch live television streams
|
||||
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
|
||||
- 📥 **Download media**: Save your media locally and watch it offline
|
||||
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin
|
||||
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
|
||||
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
|
||||
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
|
||||
- 🎥 **MPV Player**: Powerful open-source player with wide format support
|
||||
|
||||
### 📱 Media Management
|
||||
- 📥 **Download Media**: Save movies, shows, and music locally for offline viewing
|
||||
- ⭐ **Favorites**: Quick access to your favorite content
|
||||
- 📋 **Watchlists**: Create and manage custom watchlists with Streamystats integration
|
||||
- 🔖 **Continue Watching**: Pick up right where you left off
|
||||
- 🎯 **Next Up**: Suggestions for your next episode
|
||||
## 🧪 Experimental Features
|
||||
|
||||
### ⚙️ Advanced Features
|
||||
- 🤖 **Seerr Integration**: Request new media directly in the app
|
||||
- 🔍 **Smart Search**: Powerful search with Marlin Search and Streamystats support
|
||||
- 👁️ **Active Sessions**: View all active streams on your server
|
||||
- 🌐 **Multi-Language**: Available in 20+ languages with Crowdin integration
|
||||
- 🎨 **Customizable**: Personalize your home screen and settings
|
||||
- 🔌 **Plugin System**: Centralized settings sync across all devices via Jellyfin plugin
|
||||
Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better.
|
||||
|
||||
## 🧩 How It Works
|
||||
|
||||
### 📥 Downloads
|
||||
### 📥 Downloading
|
||||
|
||||
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
|
||||
|
||||
### 🧩 Streamyfin Plugin
|
||||
|
||||
The Jellyfin Plugin for Streamyfin synchronizes settings across all your devices and users. Install it on your Jellyfin server to enable:
|
||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
||||
|
||||
- Automatic Seerr login with no user input required
|
||||
- Default language preferences for audio and subtitles
|
||||
- Configure download settings and search providers (Marlin, Streamystats)
|
||||
- Customize your home screen layout and sections
|
||||
- Centralized configuration management
|
||||
- Set your preferred default languages
|
||||
- Configure download method and search provider
|
||||
- Personalize your home screen
|
||||
- And much more
|
||||
|
||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||
|
||||
### 📡 Chromecast
|
||||
|
||||
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
|
||||
|
||||
### 🎬 MPV Player
|
||||
|
||||
Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback.
|
||||
|
||||
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
|
||||
|
||||
### 🎵 Music Library
|
||||
### 🔍 Jellysearch
|
||||
|
||||
Full music library support with playlists, queue management, background playback, and offline downloads.
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
|
||||
|
||||
### 🔍 Search Providers
|
||||
|
||||
Streamyfin supports multiple search providers:
|
||||
|
||||
- **Marlin Search**: Fast semantic search for your Jellyfin library
|
||||
- **Streamystats**: Advanced statistics and personalized recommendations
|
||||
- **Jellysearch**: Fast full-text search proxy ([Jellysearch](https://gitlab.com/DomiStyle/jellysearch))
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
## 🛣️ Roadmap
|
||||
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||
|
||||
## 📥 Download Streamyfin
|
||||
|
||||
@@ -130,12 +113,13 @@ You can contribute translations directly on our [Crowdin project page](https://c
|
||||
|
||||
### 👨💻 Development Info
|
||||
|
||||
1. Use Node.js `>20`
|
||||
2. Install dependencies: `bun i && bun run submodule-reload`
|
||||
3. Make sure you have Xcode and/or Android Studio installed ([Expo setup guide](https://docs.expo.dev/workflow/android-studio-emulator/))
|
||||
4. Install the [BiomeJS extension](https://biomejs.dev/) in your IDE
|
||||
5. Run `npm run prebuild`
|
||||
6. Create an Expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||
1. Use node `>20`
|
||||
2. Install dependencies `bun i && bun run submodule-reload`
|
||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
|
||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||
4. run `npm run prebuild`
|
||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||
|
||||
For the TV version suffix the npm commands with `:tv`.
|
||||
|
||||
@@ -153,20 +137,10 @@ Need assistance or have any questions?
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
1. **Q: Why can't I see my libraries in Streamyfin?**
|
||||
A: Ensure your Jellyfin server is running a recent version (10.10.0+) and that you have proper permissions to access the libraries.
|
||||
|
||||
2. **Q: How do I enable downloads?**
|
||||
A: Downloads use FFmpeg to convert HLS streams. Ensure your server has transcoding enabled and sufficient resources.
|
||||
|
||||
3. **Q: Does Streamyfin support subtitles?**
|
||||
A: Yes, with full customization including size, color, position, and automatic language selection.
|
||||
|
||||
4. **Q: Can I use Streamyfin on Apple TV or Android TV?**
|
||||
A: Yes, Streamyfin has dedicated TV builds, but they are currently in **early development** and may have stability issues.
|
||||
|
||||
5. **Q: How do I set up Seerr integration?**
|
||||
A: Go to Settings → Plugins → Seerr, enter your server URL and Jellyfin credentials.
|
||||
1. Q: Why can't I see my libraries in Streamyfin?
|
||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
|
||||
2. Q: Why can't I see my music library?
|
||||
A: We don't currently support music and are unlikely to support music in the near future
|
||||
|
||||
## 📝 Credits
|
||||
|
||||
@@ -280,9 +254,7 @@ A special mention to the following people and projects for their contributions:
|
||||
## 📄 License
|
||||
|
||||
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
||||
|
||||
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
|
||||
|
||||
Key points of the MPL-2.0:
|
||||
|
||||
- You can use the software for any purpose
|
||||
@@ -291,13 +263,10 @@ Key points of the MPL-2.0:
|
||||
- You must disclose your source code for any modifications to the covered files
|
||||
- Larger works may combine MPL code with code under other licenses
|
||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||
|
||||
For the full text of the license, please see the LICENSE file in this repository.
|
||||
- For the full text of the license, please see the LICENSE file in this repository
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
|
||||
|
||||
## 🤝 Sponsorship
|
||||
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster).
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||
|
||||
@@ -222,9 +222,9 @@ export default function IndexLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/seerr/page'
|
||||
name='settings/plugins/jellyseerr/page'
|
||||
options={{
|
||||
title: "Seerr",
|
||||
title: "Jellyseerr",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function page() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function page() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -61,7 +61,10 @@ export default function Page() {
|
||||
setLoading(true);
|
||||
try {
|
||||
logsFile.write(JSON.stringify(filteredLogs));
|
||||
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
||||
await Sharing.shareAsync(logsFile.uri, {
|
||||
mimeType: "text/plain",
|
||||
UTI: "public.plain-text",
|
||||
});
|
||||
} catch (e: any) {
|
||||
writeErrorLog("Something went wrong attempting to export", e);
|
||||
} finally {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ScrollView } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { SeerrSettings } from "@/components/settings/Seerr";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function Page() {
|
||||
export default function page() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -17,10 +17,10 @@ export default function Page() {
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.seerrServerUrl?.locked === true}
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className='px-4'
|
||||
>
|
||||
<SeerrSettings />
|
||||
<JellyseerrSettings />
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -3,9 +3,9 @@ import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import {
|
||||
type MovieResult,
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
|
||||
export default function CompanyPage() {
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
const { companyId, image, type } = local as unknown as {
|
||||
companyId: string;
|
||||
@@ -25,12 +25,12 @@ export default function CompanyPage() {
|
||||
};
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
||||
queryKey: ["seerr", "company", type, companyId],
|
||||
queryKey: ["jellyseerr", "company", type, companyId],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params: any = {
|
||||
page: Number(pageParam),
|
||||
};
|
||||
return seerrApi?.discover(
|
||||
return jellyseerrApi?.discover(
|
||||
`${
|
||||
Number(type) === DiscoverSliderType.NETWORKS
|
||||
? Endpoints.DISCOVER_TV_NETWORK
|
||||
@@ -39,7 +39,7 @@ export default function CompanyPage() {
|
||||
params,
|
||||
);
|
||||
},
|
||||
enabled: !!seerrApi && !!companyId,
|
||||
enabled: !!jellyseerrApi && !!companyId,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||
@@ -53,24 +53,25 @@ export default function CompanyPage() {
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap(
|
||||
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
|
||||
(p) =>
|
||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
||||
),
|
||||
"id",
|
||||
) ?? [],
|
||||
[data, isSeerrMovieOrTvResult],
|
||||
[data],
|
||||
);
|
||||
|
||||
const backdrops = useMemo(
|
||||
() =>
|
||||
seerrApi
|
||||
jellyseerrApi
|
||||
? flatData.map((r) =>
|
||||
seerrApi.imageProxy(
|
||||
jellyseerrApi.imageProxy(
|
||||
(r as TvResult | MovieResult).backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[seerrApi, flatData],
|
||||
[jellyseerrApi, flatData],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -91,7 +92,7 @@ export default function CompanyPage() {
|
||||
key={companyId}
|
||||
className='bottom-1 w-1/2'
|
||||
source={{
|
||||
uri: seerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='contain'
|
||||
@@ -100,7 +101,7 @@ export default function CompanyPage() {
|
||||
}}
|
||||
/>
|
||||
}
|
||||
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,15 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
|
||||
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
|
||||
export default function GenrePage() {
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
const { genreId, name, type } = local as unknown as {
|
||||
genreId: string;
|
||||
@@ -20,21 +20,21 @@ export default function GenrePage() {
|
||||
};
|
||||
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ["seerr", "genre", type, genreId],
|
||||
queryKey: ["jellyseerr", "company", type, genreId],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params: any = {
|
||||
page: Number(pageParam),
|
||||
genre: genreId,
|
||||
};
|
||||
|
||||
return seerrApi?.discover(
|
||||
return jellyseerrApi?.discover(
|
||||
type === DiscoverSliderType.MOVIE_GENRES
|
||||
? Endpoints.DISCOVER_MOVIES
|
||||
: Endpoints.DISCOVER_TV,
|
||||
params,
|
||||
);
|
||||
},
|
||||
enabled: !!seerrApi && !!genreId,
|
||||
enabled: !!jellyseerrApi && !!genreId,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||
@@ -48,7 +48,8 @@ export default function GenrePage() {
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap(
|
||||
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
|
||||
(p) =>
|
||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
||||
),
|
||||
"id",
|
||||
) ?? [],
|
||||
@@ -57,12 +58,15 @@ export default function GenrePage() {
|
||||
|
||||
const backdrops = useMemo(
|
||||
() =>
|
||||
seerrApi
|
||||
jellyseerrApi
|
||||
? flatData.map((r) =>
|
||||
seerrApi.imageProxy(r.backdropPath, "w1920_and_h800_multi_faces"),
|
||||
jellyseerrApi.imageProxy(
|
||||
r.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[seerrApi, flatData],
|
||||
[jellyseerrApi, flatData],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -87,7 +91,7 @@ export default function GenrePage() {
|
||||
{name}
|
||||
</Text>
|
||||
}
|
||||
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,18 +18,18 @@ import { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
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 { SeerrRatings } from "@/components/Ratings";
|
||||
import Cast from "@/components/seerr/Cast";
|
||||
import DetailFacts from "@/components/seerr/DetailFacts";
|
||||
import RequestModal from "@/components/seerr/RequestModal";
|
||||
import SeerrSeasons from "@/components/series/SeerrSeasons";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
|
||||
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,
|
||||
@@ -68,7 +68,7 @@ const Page: React.FC = () => {
|
||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const { seerrApi, seerrUser, requestMedia } = useSeerr();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
@@ -83,8 +83,8 @@ const Page: React.FC = () => {
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
enabled: !!seerrApi && !!result && !!result.id,
|
||||
queryKey: ["seerr", "detail", mediaType, result.id],
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
@@ -93,18 +93,21 @@ const Page: React.FC = () => {
|
||||
refetchInterval: 0,
|
||||
queryFn: async () => {
|
||||
return mediaType === MediaType.MOVIE
|
||||
? seerrApi?.movieDetails(result.id!)
|
||||
: seerrApi?.tvDetails(result.id!);
|
||||
? jellyseerrApi?.movieDetails(result.id!)
|
||||
: jellyseerrApi?.tvDetails(result.id!);
|
||||
},
|
||||
});
|
||||
|
||||
const [canRequest, hasAdvancedRequestPermission] =
|
||||
useSeerrCanRequest(details);
|
||||
useJellyseerrCanRequest(details);
|
||||
|
||||
const canManageRequests = useMemo(() => {
|
||||
if (!seerrUser) return false;
|
||||
return hasPermission(Permission.MANAGE_REQUESTS, seerrUser.permissions);
|
||||
}, [seerrUser]);
|
||||
if (!jellyseerrUser) return false;
|
||||
return hasPermission(
|
||||
Permission.MANAGE_REQUESTS,
|
||||
jellyseerrUser.permissions,
|
||||
);
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
const pendingRequest = useMemo(() => {
|
||||
return details?.mediaInfo?.requests?.find(
|
||||
@@ -116,27 +119,27 @@ const Page: React.FC = () => {
|
||||
if (!pendingRequest?.id) return;
|
||||
|
||||
try {
|
||||
await seerrApi?.approveRequest(pendingRequest.id);
|
||||
toast.success(t("seerr.toasts.request_approved"));
|
||||
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(t("seerr.toasts.failed_to_approve_request"));
|
||||
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||
console.error("Failed to approve request:", error);
|
||||
}
|
||||
}, [seerrApi, pendingRequest, refetch, t]);
|
||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||
|
||||
const handleDeclineRequest = useCallback(async () => {
|
||||
if (!pendingRequest?.id) return;
|
||||
|
||||
try {
|
||||
await seerrApi?.declineRequest(pendingRequest.id);
|
||||
toast.success(t("seerr.toasts.request_declined"));
|
||||
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(t("seerr.toasts.failed_to_decline_request"));
|
||||
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||
console.error("Failed to decline request:", error);
|
||||
}
|
||||
}, [seerrApi, pendingRequest, refetch, t]);
|
||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
@@ -151,7 +154,7 @@ const Page: React.FC = () => {
|
||||
|
||||
const submitIssue = useCallback(() => {
|
||||
if (result.id && issueType && issueMessage && details) {
|
||||
seerrApi
|
||||
jellyseerrApi
|
||||
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
||||
.then(() => {
|
||||
setIssueType(undefined);
|
||||
@@ -159,7 +162,7 @@ const Page: React.FC = () => {
|
||||
bottomSheetModalRef?.current?.close();
|
||||
});
|
||||
}
|
||||
}, [seerrApi, details, result, issueType, issueMessage]);
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const handleIssueModalDismiss = useCallback(() => {
|
||||
setIssueTypeDropdownOpen(false);
|
||||
@@ -211,7 +214,7 @@ const Page: React.FC = () => {
|
||||
const issueTypeOptionGroups = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("seerr.types"),
|
||||
title: t("jellyseerr.types"),
|
||||
options: Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value]) => ({
|
||||
@@ -262,7 +265,7 @@ const Page: React.FC = () => {
|
||||
height: "100%",
|
||||
}}
|
||||
source={{
|
||||
uri: seerrApi?.imageProxy(
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
result.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
@@ -292,7 +295,7 @@ const Page: React.FC = () => {
|
||||
<View className='px-4'>
|
||||
<View className='flex flex-row justify-between w-full'>
|
||||
<View className='flex flex-col w-56'>
|
||||
<SeerrRatings
|
||||
<JellyserrRatings
|
||||
result={
|
||||
result as
|
||||
| MovieResult
|
||||
@@ -327,7 +330,7 @@ const Page: React.FC = () => {
|
||||
/>
|
||||
) : canRequest ? (
|
||||
<Button color='purple' onPress={request} className='mt-4'>
|
||||
{t("seerr.request_button")}
|
||||
{t("jellyseerr.request_button")}
|
||||
</Button>
|
||||
) : (
|
||||
details?.mediaInfo?.jellyfinMediaId && (
|
||||
@@ -350,7 +353,7 @@ const Page: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>
|
||||
{t("seerr.report_issue_button")}
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
@@ -386,12 +389,12 @@ const Page: React.FC = () => {
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||
<Text className='text-sm text-neutral-400'>
|
||||
{t("seerr.requested_by", {
|
||||
{t("jellyseerr.requested_by", {
|
||||
user:
|
||||
pendingRequest.requestedBy?.displayName ||
|
||||
pendingRequest.requestedBy?.username ||
|
||||
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||
t("seerr.unknown_user"),
|
||||
t("jellyseerr.unknown_user"),
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -412,7 +415,7 @@ const Page: React.FC = () => {
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>{t("seerr.approve")}</Text>
|
||||
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||
@@ -430,7 +433,7 @@ const Page: React.FC = () => {
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>{t("seerr.decline")}</Text>
|
||||
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -439,7 +442,7 @@ const Page: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{mediaType === MediaType.TV && (
|
||||
<SeerrSeasons
|
||||
<JellyseerrSeasons
|
||||
isLoading={isLoading || isFetching}
|
||||
details={details as TvDetails}
|
||||
refetch={refetch}
|
||||
@@ -488,13 +491,13 @@ const Page: React.FC = () => {
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("seerr.whats_wrong")}
|
||||
{t("jellyseerr.whats_wrong")}
|
||||
</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("seerr.issue_type")}
|
||||
{t("jellyseerr.issue_type")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={issueTypeOptionGroups}
|
||||
@@ -503,11 +506,11 @@ const Page: React.FC = () => {
|
||||
<Text numberOfLines={1}>
|
||||
{issueType
|
||||
? IssueTypeName[issueType]
|
||||
: t("seerr.select_an_issue")}
|
||||
: t("jellyseerr.select_an_issue")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("seerr.types")}
|
||||
title={t("jellyseerr.types")}
|
||||
open={issueTypeDropdownOpen}
|
||||
onOpenChange={setIssueTypeDropdownOpen}
|
||||
/>
|
||||
@@ -519,7 +522,7 @@ const Page: React.FC = () => {
|
||||
maxLength={254}
|
||||
style={{ color: "white" }}
|
||||
clearButtonMode='always'
|
||||
placeholder={t("seerr.describe_the_issue")}
|
||||
placeholder={t("jellyseerr.describe_the_issue")}
|
||||
placeholderTextColor='#9CA3AF'
|
||||
// Issue with multiline + Textinput inside a portal
|
||||
// https://github.com/callstack/react-native-paper/issues/1668
|
||||
@@ -529,7 +532,7 @@ const Page: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||
{t("seerr.submit_button")}
|
||||
{t("jellyseerr.submit_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
@@ -5,27 +5,31 @@ import { orderBy, uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
|
||||
export default function PersonPage() {
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { seerrApi, seerrLocale: locale } = useSeerr();
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrRegion: region,
|
||||
jellyseerrLocale: locale,
|
||||
} = useJellyseerr();
|
||||
|
||||
const { personId } = local as { personId: string };
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["seerr", "person", personId],
|
||||
queryKey: ["jellyseerr", "person", personId],
|
||||
queryFn: async () => ({
|
||||
details: await seerrApi?.personDetails(personId),
|
||||
combinedCredits: await seerrApi?.personCombinedCredits(personId),
|
||||
details: await jellyseerrApi?.personDetails(personId),
|
||||
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
|
||||
}),
|
||||
enabled: !!seerrApi && !!personId,
|
||||
enabled: !!jellyseerrApi && !!personId,
|
||||
});
|
||||
|
||||
const castedRoles: PersonCreditCast[] = useMemo(
|
||||
@@ -42,19 +46,22 @@ export default function PersonPage() {
|
||||
);
|
||||
const backdrops = useMemo(
|
||||
() =>
|
||||
seerrApi
|
||||
jellyseerrApi
|
||||
? castedRoles.map((c) =>
|
||||
seerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"),
|
||||
jellyseerrApi.imageProxy(
|
||||
c.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[seerrApi, data?.combinedCredits],
|
||||
[jellyseerrApi, data?.combinedCredits],
|
||||
);
|
||||
|
||||
return (
|
||||
<ParallaxSlideShow
|
||||
data={castedRoles}
|
||||
images={backdrops}
|
||||
listHeader={t("seerr.appearances")}
|
||||
listHeader={t("jellyseerr.appearances")}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
logo={
|
||||
<Image
|
||||
@@ -62,7 +69,7 @@ export default function PersonPage() {
|
||||
id={data?.details?.id.toString()}
|
||||
className='rounded-full bottom-1'
|
||||
source={{
|
||||
uri: seerrApi?.imageProxy(
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
data?.details?.profilePath,
|
||||
"w600_and_h600_bestv2",
|
||||
),
|
||||
@@ -79,13 +86,16 @@ export default function PersonPage() {
|
||||
<>
|
||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||
<Text className='opacity-50'>
|
||||
{t("seerr.born")}{" "}
|
||||
{t("jellyseerr.born")}{" "}
|
||||
{data?.details?.birthday &&
|
||||
new Date(data.details.birthday).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
new Date(data.details.birthday).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}{" "}
|
||||
| {data?.details?.placeOfBirth}
|
||||
</Text>
|
||||
</>
|
||||
@@ -93,7 +103,7 @@ export default function PersonPage() {
|
||||
MainContent={() => (
|
||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||
)}
|
||||
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -33,17 +33,17 @@ export default function SearchLayout() {
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='seerr/page' options={commonScreenOptions} />
|
||||
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
|
||||
<Stack.Screen
|
||||
name='seerr/person/[personId]'
|
||||
name='jellyseerr/person/[personId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='seerr/company/[companyId]'
|
||||
name='jellyseerr/company/[companyId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='seerr/genre/[genreId]'
|
||||
name='jellyseerr/genre/[genreId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -26,18 +26,18 @@ import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import {
|
||||
JellyseerrSearchSort,
|
||||
JellyserrIndexPage,
|
||||
} 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 {
|
||||
SeerrIndexPage,
|
||||
SeerrSearchSort,
|
||||
} from "@/components/seerr/SeerrIndexPage";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
@@ -55,7 +55,7 @@ const exampleSearches = [
|
||||
"The Mandalorian",
|
||||
];
|
||||
|
||||
export default function SearchPage() {
|
||||
export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
@@ -93,11 +93,16 @@ export default function SearchPage() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const { seerrApi } = useSeerr();
|
||||
const [seerrOrderBy, setSeerrOrderBy] = useState<SeerrSearchSort>(
|
||||
SeerrSearchSort[SeerrSearchSort.DEFAULT] as unknown as SeerrSearchSort,
|
||||
);
|
||||
const [seerrSortOrder, setSeerrSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||
useState<JellyseerrSearchSort>(
|
||||
JellyseerrSearchSort[
|
||||
JellyseerrSearchSort.DEFAULT
|
||||
] as unknown as JellyseerrSearchSort,
|
||||
);
|
||||
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
|
||||
"asc" | "desc"
|
||||
>("desc");
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
return settings?.searchEngine || "Jellyfin";
|
||||
@@ -469,7 +474,7 @@ export default function SearchPage() {
|
||||
className='flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
{seerrApi && (
|
||||
{jellyseerrApi && (
|
||||
<View className='pl-4 pr-4 flex flex-row'>
|
||||
<SearchTabButtons
|
||||
searchType={searchType}
|
||||
@@ -483,10 +488,10 @@ export default function SearchPage() {
|
||||
<DiscoverFilters
|
||||
searchFilterId={searchFilterId}
|
||||
orderFilterId={orderFilterId}
|
||||
seerrOrderBy={seerrOrderBy}
|
||||
setSeerrOrderBy={setSeerrOrderBy}
|
||||
seerrSortOrder={seerrSortOrder}
|
||||
setSeerrSortOrder={setSeerrSortOrder}
|
||||
jellyseerrOrderBy={jellyseerrOrderBy}
|
||||
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
||||
jellyseerrSortOrder={jellyseerrSortOrder}
|
||||
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -749,10 +754,10 @@ export default function SearchPage() {
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<SeerrIndexPage
|
||||
<JellyserrIndexPage
|
||||
searchQuery={debouncedSearch}
|
||||
sortType={seerrOrderBy}
|
||||
order={seerrSortOrder}
|
||||
sortType={jellyseerrOrderBy}
|
||||
order={jellyseerrSortOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
getMpvAudioId,
|
||||
@@ -71,6 +72,9 @@ export default function page() {
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [isPipMode, setIsPipMode] = useState(false);
|
||||
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
|
||||
"default",
|
||||
);
|
||||
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
@@ -501,6 +505,31 @@ export default function page() {
|
||||
return ticksToSeconds(getInitialPlaybackTicks());
|
||||
}, [getInitialPlaybackTicks]);
|
||||
|
||||
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
|
||||
const nowPlayingMetadata = useMemo(() => {
|
||||
if (!item || !api) return undefined;
|
||||
|
||||
const artworkUri = getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
|
||||
return {
|
||||
title: item.Name || "",
|
||||
artist:
|
||||
item.Type === "Episode"
|
||||
? item.SeriesName || ""
|
||||
: item.AlbumArtist || "",
|
||||
albumTitle:
|
||||
item.Type === "Episode" && item.SeasonName
|
||||
? item.SeasonName
|
||||
: undefined,
|
||||
artworkUri: artworkUri || undefined,
|
||||
};
|
||||
}, [item, api]);
|
||||
|
||||
/** Build video source config for MPV */
|
||||
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
||||
if (!stream?.url) return undefined;
|
||||
@@ -929,6 +958,7 @@ export default function page() {
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
nowPlayingMetadata={nowPlayingMetadata}
|
||||
onProgress={onProgress}
|
||||
onPlaybackStateChange={onPlaybackStateChanged}
|
||||
onLoad={() => setIsVideoLoaded(true)}
|
||||
@@ -978,6 +1008,7 @@ export default function page() {
|
||||
pause={pause}
|
||||
seek={seek}
|
||||
enableTrickplay={true}
|
||||
aspectRatio={aspectRatio}
|
||||
isZoomedToFill={isZoomedToFill}
|
||||
onZoomToggle={handleZoomToggle}
|
||||
api={api}
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
@@ -1,3 +1,4 @@
|
||||
export * from "./api";
|
||||
export * from "./mmkv";
|
||||
export * from "./number";
|
||||
export * from "./string";
|
||||
|
||||
@@ -3,6 +3,7 @@ declare global {
|
||||
bytesToReadable(decimals?: number): string;
|
||||
secondsToMilliseconds(): number;
|
||||
minutesToMilliseconds(): number;
|
||||
hoursToMilliseconds(): number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
|
||||
return this.valueOf() * (60).secondsToMilliseconds();
|
||||
};
|
||||
|
||||
Number.prototype.hoursToMilliseconds = function () {
|
||||
return this.valueOf() * (60).minutesToMilliseconds();
|
||||
};
|
||||
|
||||
export {};
|
||||
|
||||
14
augmentations/string.ts
Normal file
14
augmentations/string.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
declare global {
|
||||
interface String {
|
||||
toTitle(): string;
|
||||
}
|
||||
}
|
||||
|
||||
String.prototype.toTitle = function () {
|
||||
return this.replaceAll("_", " ").replace(
|
||||
/\w\S*/g,
|
||||
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||
);
|
||||
};
|
||||
|
||||
export {};
|
||||
42
bun.lock
42
bun.lock
@@ -19,7 +19,7 @@
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||
"@tanstack/react-pacer": "^0.19.1",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"@tanstack/react-query": "5.90.20",
|
||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "~54.0.31",
|
||||
@@ -52,12 +52,12 @@
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"i18next": "^25.0.0",
|
||||
"jotai": "2.16.2",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.17.23",
|
||||
"nativewind": "^2.0.11",
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "16.5.3",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "1.1.0",
|
||||
@@ -97,7 +97,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.6",
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@react-native-community/cli": "20.1.0",
|
||||
"@react-native-community/cli": "20.1.1",
|
||||
"@react-native-tvos/config-tv": "0.1.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.23",
|
||||
@@ -512,29 +512,29 @@
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@react-native-community/cli": ["@react-native-community/cli@20.1.0", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.0", "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-doctor": "20.1.0", "@react-native-community/cli-server-api": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "@react-native-community/cli-types": "20.1.0", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-441WsVtRe4nGJ9OzA+QMU1+22lA6Q2hRWqqIMKD0wjEMLqcSfOZyu2UL9a/yRpL/dRpyUsU4n7AxqKfTKO/Csg=="],
|
||||
"@react-native-community/cli": ["@react-native-community/cli@20.1.1", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.1", "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-doctor": "20.1.1", "@react-native-community/cli-server-api": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "@react-native-community/cli-types": "20.1.1", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-aLPUx43+WSeTOaUepR2FBD5a1V0OAZ1QB2DOlRlW4fOEjtBXgv40eM/ho8g3WCvAOKfPvTvx4fZdcuovTyV81Q=="],
|
||||
|
||||
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-77L4DifWfxAT8ByHnkypge7GBMYpbJAjBGV+toowt5FQSGaTBDcBHCX+FFqFRukD5fH6i8sZ41Gtw+nbfCTTIA=="],
|
||||
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-6nGQ08w2+EcDwTFC4JFiW/wI2pLwzMrk9thz4um7tKRNW8sADX0IyCsfM2F4rHS720C0UNKYBZE9nAsfp8Vkcw=="],
|
||||
|
||||
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-1x9rhLLR/dKKb92Lb5O0l0EmUG08FHf+ZVyVEf9M+tX+p5QIm52MRiy43R0UAZ2jJnFApxRk+N3sxoYK4Dtnag=="],
|
||||
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-ajs2i56MANie/v0bMQ1BmRcrOb6MEvLT2rh/I1CA62NXGqF1Rxv6QwsN84LrADMXHRg8QiEMAIADkyDeQHt7Kg=="],
|
||||
|
||||
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-3A01ZDyFeCALzzPcwP/fleHoP3sGNq1UX7FzxkTrOFX8RRL9ntXNXQd27E56VU4BBxGAjAJT4Utw8pcOjJceIA=="],
|
||||
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-1iUV2rPAyoWPo8EceAFC2vZTF+pEd9YqS87c0aqpbGOFE0gs1rHEB+auVR8CdjzftR4U9sq6m2jrdst0rvpIkg=="],
|
||||
|
||||
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-n6JVs8Q3yxRbtZQOy05ofeb1kGtspGN3SgwPmuaqvURF9fsuS7c4/9up2Kp9C+1D2J1remPJXiZLNGOcJvfpOA=="],
|
||||
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-doepJgLJVqeJb5tNoP9hyFIcoZ1OMGO7QN/YMuCCIjbThUQe/J87XdwPol3Qrjr58KRt9xeBVz+kHeW5mtSutw=="],
|
||||
|
||||
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.0", "", { "dependencies": { "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", "@react-native-community/cli-platform-apple": "20.1.0", "@react-native-community/cli-platform-ios": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-QfJF1GVjA4PBrIT3SJ0vFFIu0km1vwOmLDlOYVqfojajZJ+Dnvl0f94GN1il/jT7fITAxom///XH3/URvi7YTQ=="],
|
||||
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.1", "", { "dependencies": { "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-platform-android": "20.1.1", "@react-native-community/cli-platform-apple": "20.1.1", "@react-native-community/cli-platform-ios": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-eFpg5wWnV7uGqvLemshpgj2trPD8cckqxBuI4nT7sxKF/YpA/e3nnnyytHxPP5EnYfWbMcqfaq8hDJoOnJinGQ=="],
|
||||
|
||||
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-TeHPDThOwDppQRpndm9kCdRCBI8AMy3HSIQ+iy7VYQXL5BtZ5LfmGdusoj7nVN/ZGn0Lc6Gwts5qowyupXdeKg=="],
|
||||
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-KPheizJQI0tVvBLy9owzpo+A9qDsDAa87e7a8xNaHnwqGpExnIzFPrbdvrltiZjstU2eB/+/UgNQxYIEd4Oc+g=="],
|
||||
|
||||
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-0ih1hrYezSM2cuOlVnwBEFtMwtd8YgpTLmZauDJCv50rIumtkI1cQoOgLoS4tbPCj9U/Vn2a9BFH0DLFOOIacg=="],
|
||||
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-mQEjOzRFCcQTrCt73Q/+5WWTfUg6U2vLZv5rPuFiNrLbrwRqxVH3OLaXg5gilJkDTJC80z8iOSsdd8MRxONOig=="],
|
||||
|
||||
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.0", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.0" } }, "sha512-XN7Da9z4WsJxtqVtEzY8q2bv22OsvzaFP5zy5+phMWNoJlU4lf7IvBSxqGYMpQ9XhYP7arDw5vmW4W34s06rnA=="],
|
||||
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.1", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.1" } }, "sha512-6vr10/oSjKkZO/BBgfFJNQTC/0CDF4WrN8iW9ss+Kt6ZL2QrBXLYz7fobrrboOlHwqqs5EyQadlEaNii7gKRJg=="],
|
||||
|
||||
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "ws": "^6.2.3" } }, "sha512-Tb415Oh8syXNT2zOzLzFkBXznzGaqKCiaichxKzGCDKg6JGHp3jSuCmcTcaPeYC7oc32n/S3Psw7798r4Q/7lA=="],
|
||||
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" } }, "sha512-phHfiCa4WqfKfaoV2vGVR3ZrYQDQTpI1k+C+i6rXAxFGxPuy8IgFFVOSL543qjKPpHBVwLcA+/xAJCVpdyCtVQ=="],
|
||||
|
||||
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.0", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-/YmzHGOkY6Bgrv4OaA1L8rFqsBlQd1EB2/ipAoKPiieV0EcB5PUamUSuNeFU3sBZZTYQCUENwX4wgOHgFUlDnQ=="],
|
||||
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.1", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-j+zX/H2X+6ZGneIDj56tZ1Hbnip5nSfnq7yGlMyF/zm3U1hKp3G1jN5v0YEfnz/zEmjr7zruh4Y06KmZrF1lrA=="],
|
||||
|
||||
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.0", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-D0kDspcwgbVXyNjwicT7Bb1JgXjijTw1JJd+qxyF/a9+sHv7TU4IchV+gN38QegeXqVyM4Ym7YZIvXMFBmyJqA=="],
|
||||
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.1", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-Tp+s27I/RDONrGvWVj4IzEmga2HhJhXi8ZlZTfycMMyAcv4LG/CTPira+BUZs8nzLAJNrlJ79pVVPJPqQAe+aw=="],
|
||||
|
||||
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
|
||||
|
||||
@@ -602,7 +602,7 @@
|
||||
|
||||
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.17", "", { "dependencies": { "@tanstack/query-core": "5.90.17" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ=="],
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
|
||||
|
||||
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
|
||||
|
||||
@@ -1376,7 +1376,7 @@
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
||||
|
||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||
|
||||
@@ -1640,7 +1640,7 @@
|
||||
|
||||
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
||||
|
||||
"react-i18next": ["react-i18next@16.5.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw=="],
|
||||
"react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="],
|
||||
|
||||
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
||||
|
||||
@@ -1854,6 +1854,8 @@
|
||||
|
||||
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
|
||||
|
||||
"strict-url-sanitise": ["strict-url-sanitise@0.0.1", "", {}, "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg=="],
|
||||
|
||||
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -2242,7 +2244,7 @@
|
||||
|
||||
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.17", "", {}, "sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ=="],
|
||||
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||
|
||||
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
||||
|
||||
|
||||
0
components/ContextMenu.tv.ts
Normal file
0
components/ContextMenu.tv.ts
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Example Usage of Global Modal
|
||||
*
|
||||
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||
* You can delete this file after understanding how it works.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
/**
|
||||
* Example 1: Simple Content Modal
|
||||
*/
|
||||
export const SimpleModalExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6'>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||
<Text className='text-white mb-4'>
|
||||
This is a simple modal with just some text content.
|
||||
</Text>
|
||||
<Text className='text-neutral-400'>
|
||||
Swipe down or tap outside to close.
|
||||
</Text>
|
||||
</View>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 2: Modal with Custom Snap Points
|
||||
*/
|
||||
export const CustomSnapPointsExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6' style={{ minHeight: 400 }}>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||
Custom Snap Points
|
||||
</Text>
|
||||
<Text className='text-white mb-4'>
|
||||
This modal has custom snap points (25%, 50%, 90%).
|
||||
</Text>
|
||||
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||
<Text className='text-white'>
|
||||
Try dragging the modal to different heights!
|
||||
</Text>
|
||||
</View>
|
||||
</View>,
|
||||
{
|
||||
snapPoints: ["25%", "50%", "90%"],
|
||||
enableDynamicSizing: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 3: Complex Component in Modal
|
||||
*/
|
||||
const SettingsModalContent = () => {
|
||||
const { hideModal } = useGlobalModal();
|
||||
|
||||
const settings = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Notifications",
|
||||
icon: "notifications-outline" as const,
|
||||
enabled: true,
|
||||
},
|
||||
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||
{
|
||||
id: 3,
|
||||
title: "Auto-play",
|
||||
icon: "play-outline" as const,
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View className='p-6'>
|
||||
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||
|
||||
{settings.map((setting, index) => (
|
||||
<View
|
||||
key={setting.id}
|
||||
className={`flex-row items-center justify-between py-4 ${
|
||||
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||
}`}
|
||||
>
|
||||
<View className='flex-row items-center gap-3'>
|
||||
<Ionicons name={setting.icon} size={24} color='white' />
|
||||
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`w-12 h-7 rounded-full ${
|
||||
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={hideModal}
|
||||
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||
>
|
||||
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComplexModalExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(<SettingsModalContent />);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||
*/
|
||||
export const useShowSuccessModal = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
return (message: string) => {
|
||||
showModal(
|
||||
<View className='p-6 items-center'>
|
||||
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||
<Ionicons name='checkmark' size={48} color='white' />
|
||||
</View>
|
||||
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||
<Text className='text-white text-center'>{message}</Text>
|
||||
</View>,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Main Demo Component
|
||||
*/
|
||||
export const GlobalModalDemo = () => {
|
||||
const showSuccess = useShowSuccessModal();
|
||||
|
||||
return (
|
||||
<View className='p-6 gap-4'>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||
Global Modal Examples
|
||||
</Text>
|
||||
|
||||
<SimpleModalExample />
|
||||
<CustomSnapPointsExample />
|
||||
<ComplexModalExample />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => showSuccess("Operation completed successfully!")}
|
||||
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -89,16 +89,16 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
||||
</Text>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<Image
|
||||
source={require("@/assets/icons/seerr-logo.svg")}
|
||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
/>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>Seerr</Text>
|
||||
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.seerr_feature_description")}
|
||||
{t("home.intro.jellyseerr_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -158,12 +158,12 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
||||
</View>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.intro.centralized_settings_plugin_title")}
|
||||
{t("home.intro.centralised_settings_plugin_title")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap items-baseline'>
|
||||
<Text className='shrink text-xs'>
|
||||
{t(
|
||||
"home.intro.centralized_settings_plugin_description",
|
||||
"home.intro.centralised_settings_plugin_description",
|
||||
)}{" "}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -11,11 +11,9 @@ import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
|
||||
const POSTER_CAROUSEL_HEIGHT = 220;
|
||||
|
||||
interface Props extends ViewProps {
|
||||
actorId: string;
|
||||
actorName?: string | null;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useMemo } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type {
|
||||
@@ -55,23 +55,23 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SeerrRatings: React.FC<{
|
||||
export const JellyserrRatings: React.FC<{
|
||||
result: MovieResult | TvResult | TvDetails | MovieDetails;
|
||||
}> = ({ result }) => {
|
||||
const { seerrApi, getMediaType } = useSeerr();
|
||||
const { jellyseerrApi, getMediaType } = useJellyseerr();
|
||||
|
||||
const mediaType = useMemo(() => getMediaType(result), [result]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["seerr", result.id, mediaType, "ratings"],
|
||||
queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
|
||||
queryFn: async () => {
|
||||
return mediaType === MediaType.MOVIE
|
||||
? seerrApi?.movieRatings(result.id)
|
||||
: seerrApi?.tvRatings(result.id);
|
||||
? jellyseerrApi?.movieRatings(result.id)
|
||||
: jellyseerrApi?.tvRatings(result.id);
|
||||
},
|
||||
staleTime: (5).minutesToMilliseconds(),
|
||||
retry: false,
|
||||
enabled: !!seerrApi,
|
||||
enabled: !!jellyseerrApi,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,11 +6,8 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
|
||||
const POSTER_CAROUSEL_HEIGHT = 220;
|
||||
|
||||
import { HorizontalScroll } from "./common/HorizontalScroll";
|
||||
import { Text } from "./common/Text";
|
||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||
|
||||
@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
|
||||
const streams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||
[source, streamType],
|
||||
[source],
|
||||
);
|
||||
|
||||
const selectedSteam = useMemo(
|
||||
|
||||
@@ -21,7 +21,7 @@ interface Props extends TouchableOpacityProps {
|
||||
mediaType: MediaType;
|
||||
}
|
||||
|
||||
export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
@@ -42,24 +42,18 @@ export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
onPress={() => {
|
||||
if (!result) return;
|
||||
|
||||
// Build URL with query params - avoids Expo Router's strict type checking
|
||||
const params = new URLSearchParams({
|
||||
...Object.fromEntries(
|
||||
Object.entries(result).map(([key, value]) => [
|
||||
key,
|
||||
String(value ?? ""),
|
||||
]),
|
||||
),
|
||||
mediaTitle,
|
||||
releaseYear: releaseYear.toString(),
|
||||
canRequest: canRequest.toString(),
|
||||
posterSrc,
|
||||
mediaType: mediaType.toString(),
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
// @ts-expect-error
|
||||
params: {
|
||||
...result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest: canRequest.toString(),
|
||||
posterSrc,
|
||||
mediaType,
|
||||
},
|
||||
});
|
||||
|
||||
router.push(
|
||||
`/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/page?${params.toString()}`,
|
||||
);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
20
components/common/LargePoster.tsx
Normal file
20
components/common/LargePoster.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Image } from "expo-image";
|
||||
import { View } from "react-native";
|
||||
|
||||
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
||||
if (!url)
|
||||
return (
|
||||
<View className='p-4 rounded-xl overflow-hidden '>
|
||||
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className='p-4 rounded-xl overflow-hidden '>
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
28
components/common/VerticalSkeleton.tsx
Normal file
28
components/common/VerticalSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={{
|
||||
width: "32%",
|
||||
}}
|
||||
className='flex flex-col'
|
||||
{...props}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
||||
/>
|
||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -569,31 +569,29 @@ export const HomeWithCarousel = () => {
|
||||
settings.streamyStatsSeriesRecommendations ||
|
||||
settings.streamyStatsPromotedWatchlists;
|
||||
const streamystatsSections =
|
||||
index === streamystatsIndex && hasStreamystatsContent
|
||||
? [
|
||||
settings.streamyStatsMovieRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
key='movie-recommendations'
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_movies",
|
||||
)}
|
||||
type='Movie'
|
||||
/>
|
||||
),
|
||||
settings.streamyStatsSeriesRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
key='series-recommendations'
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_series",
|
||||
)}
|
||||
type='Series'
|
||||
/>
|
||||
),
|
||||
settings.streamyStatsPromotedWatchlists && (
|
||||
<StreamystatsPromotedWatchlists key='promoted-watchlists' />
|
||||
),
|
||||
].filter(Boolean)
|
||||
: null;
|
||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
||||
<>
|
||||
{settings.streamyStatsMovieRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_movies",
|
||||
)}
|
||||
type='Movie'
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsSeriesRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_series",
|
||||
)}
|
||||
type='Series'
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsPromotedWatchlists && (
|
||||
<StreamystatsPromotedWatchlists />
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
return (
|
||||
|
||||
@@ -247,14 +247,15 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<>
|
||||
{watchlists?.map((watchlist) => (
|
||||
<WatchlistSection
|
||||
key={watchlist.id}
|
||||
watchlist={watchlist}
|
||||
jellyfinServerId={jellyfinServerId!}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import PersonPoster from "@/components/seerr/PersonPoster";
|
||||
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
|
||||
@@ -15,17 +15,19 @@ const CastSlide: React.FC<
|
||||
details?.credits?.cast &&
|
||||
details?.credits?.cast?.length > 0 && (
|
||||
<View {...props}>
|
||||
<Text className='text-lg font-bold mb-2 px-4'>{t("seerr.cast")}</Text>
|
||||
<Text className='text-lg font-bold mb-2 px-4'>
|
||||
{t("jellyseerr.cast")}
|
||||
</Text>
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={details?.credits.cast}
|
||||
ItemSeparatorComponent={() => <View className='w-2' />}
|
||||
keyExtractor={(item) => item?.id?.toString() ?? ""}
|
||||
keyExtractor={(item) => item?.id?.toString()}
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
renderItem={({ item }) => (
|
||||
<PersonPoster
|
||||
id={item?.id?.toString() ?? ""}
|
||||
id={item.id.toString()}
|
||||
posterPath={item.profilePath}
|
||||
name={item.name}
|
||||
subName={item.character}
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import CountryFlag from "react-native-country-flag";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import type { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
@@ -50,7 +50,8 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
||||
const DetailFacts: React.FC<
|
||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||
> = ({ details, className, ...props }) => {
|
||||
const { seerrRegion: region, seerrLocale: locale } = useSeerr();
|
||||
const { jellyseerrRegion: region, jellyseerrLocale: locale } =
|
||||
useJellyseerr();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const releases = useMemo(
|
||||
@@ -58,7 +59,7 @@ const DetailFacts: React.FC<
|
||||
(details as MovieDetails)?.releases?.results.find(
|
||||
(r: TmdbRelease) => r.iso_3166_1 === region,
|
||||
)?.release_dates as TmdbRelease["release_dates"],
|
||||
[details, region],
|
||||
[details],
|
||||
);
|
||||
|
||||
// Release date types:
|
||||
@@ -80,34 +81,40 @@ const DetailFacts: React.FC<
|
||||
const firstAirDate = useMemo(() => {
|
||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||
if (firstAirDate) {
|
||||
return new Date(firstAirDate).toLocaleDateString(locale, dateOpts);
|
||||
return new Date(firstAirDate).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts,
|
||||
);
|
||||
}
|
||||
}, [details, locale]);
|
||||
}, [details]);
|
||||
|
||||
const nextAirDate = useMemo(() => {
|
||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
|
||||
if (nextAirDate && firstAirDate !== nextAirDate) {
|
||||
return new Date(nextAirDate).toLocaleDateString(locale, dateOpts);
|
||||
return new Date(nextAirDate).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts,
|
||||
);
|
||||
}
|
||||
}, [details, locale]);
|
||||
}, [details]);
|
||||
|
||||
const revenue = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.revenue?.toLocaleString?.(locale, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}),
|
||||
[details, locale],
|
||||
(details as MovieDetails)?.revenue?.toLocaleString?.(
|
||||
`${locale}-${region}`,
|
||||
{ style: "currency", currency: "USD" },
|
||||
),
|
||||
[details],
|
||||
);
|
||||
|
||||
const budget = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.budget?.toLocaleString?.(locale, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}),
|
||||
[details, locale],
|
||||
(details as MovieDetails)?.budget?.toLocaleString?.(
|
||||
`${locale}-${region}`,
|
||||
{ style: "currency", currency: "USD" },
|
||||
),
|
||||
[details],
|
||||
);
|
||||
|
||||
const streamingProviders = useMemo(
|
||||
@@ -115,7 +122,7 @@ const DetailFacts: React.FC<
|
||||
details?.watchProviders?.find(
|
||||
(provider) => provider.iso_3166_1 === region,
|
||||
)?.flatrate,
|
||||
[details, region],
|
||||
[details],
|
||||
);
|
||||
|
||||
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
|
||||
@@ -131,21 +138,21 @@ const DetailFacts: React.FC<
|
||||
return (
|
||||
details && (
|
||||
<View className='p-4'>
|
||||
<Text className='text-lg font-bold'>{t("seerr.details")}</Text>
|
||||
<Text className='text-lg font-bold'>{t("jellyseerr.details")}</Text>
|
||||
<View
|
||||
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
|
||||
{...props}
|
||||
>
|
||||
<Fact title={t("seerr.status")} fact={details?.status} />
|
||||
<Fact title={t("jellyseerr.status")} fact={details?.status} />
|
||||
<Fact
|
||||
title={t("seerr.original_title")}
|
||||
title={t("jellyseerr.original_title")}
|
||||
fact={(details as TvDetails)?.originalName}
|
||||
/>
|
||||
{details.keywords.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID,
|
||||
) && <Fact title={t("seerr.series_type")} fact='Anime' />}
|
||||
) && <Fact title={t("jellyseerr.series_type")} fact='Anime' />}
|
||||
<Facts
|
||||
title={t("seerr.release_dates")}
|
||||
title={t("jellyseerr.release_dates")}
|
||||
facts={filteredReleases?.map?.((r: Release, idx) => (
|
||||
<View key={idx} className='flex flex-row space-x-2 items-center'>
|
||||
{r.type === 3 ? (
|
||||
@@ -164,20 +171,23 @@ const DetailFacts: React.FC<
|
||||
)}
|
||||
<Text>
|
||||
{new Date(r.release_date).toLocaleDateString(
|
||||
locale,
|
||||
`${locale}-${region}`,
|
||||
dateOpts,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
/>
|
||||
<Fact title={t("seerr.first_air_date")} fact={firstAirDate} />
|
||||
<Fact title={t("seerr.next_air_date")} fact={nextAirDate} />
|
||||
<Fact title={t("seerr.revenue")} fact={revenue} />
|
||||
<Fact title={t("seerr.budget")} fact={budget} />
|
||||
<Fact title={t("seerr.original_language")} fact={spokenLanguage} />
|
||||
<Fact title={t("jellyseerr.first_air_date")} fact={firstAirDate} />
|
||||
<Fact title={t("jellyseerr.next_air_date")} fact={nextAirDate} />
|
||||
<Fact title={t("jellyseerr.revenue")} fact={revenue} />
|
||||
<Fact title={t("jellyseerr.budget")} fact={budget} />
|
||||
<Fact
|
||||
title={t("jellyseerr.original_language")}
|
||||
fact={spokenLanguage}
|
||||
/>
|
||||
<Facts
|
||||
title={t("seerr.production_country")}
|
||||
title={t("jellyseerr.production_country")}
|
||||
facts={details?.productionCountries?.map((n, idx) => (
|
||||
<View key={idx} className='flex flex-row items-center space-x-2'>
|
||||
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
||||
@@ -186,17 +196,17 @@ const DetailFacts: React.FC<
|
||||
))}
|
||||
/>
|
||||
<Facts
|
||||
title={t("seerr.studios")}
|
||||
title={t("jellyseerr.studios")}
|
||||
facts={uniqBy(details?.productionCompanies, "name")?.map(
|
||||
(n) => n.name,
|
||||
)}
|
||||
/>
|
||||
<Facts
|
||||
title={t("seerr.network")}
|
||||
title={t("jellyseerr.network")}
|
||||
facts={networks?.map((n) => n.name)}
|
||||
/>
|
||||
<Facts
|
||||
title={t("seerr.currently_streaming_on")}
|
||||
title={t("jellyseerr.currently_streaming_on")}
|
||||
facts={streamingProviders?.map((s) => s.name)}
|
||||
/>
|
||||
</View>
|
||||
@@ -1,10 +1,16 @@
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
}
|
||||
// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
|
||||
export const GridSkeleton = React.memo(() => {
|
||||
export const GridSkeleton: React.FC<Props> = ({ index }) => {
|
||||
return (
|
||||
<View className='flex flex-col mr-2 h-auto' style={{ width: "30.5%" }}>
|
||||
<View
|
||||
key={index}
|
||||
className='flex flex-col mr-2 h-auto'
|
||||
style={{ width: "30.5%" }}
|
||||
>
|
||||
<View className='relative rounded-lg overflow-hidden border border-neutral-900 w-full mt-4 aspect-[10/15] bg-neutral-800' />
|
||||
<View className='mt-2 flex flex-col w-full'>
|
||||
<View className='h-4 bg-neutral-800 rounded mb-1' />
|
||||
@@ -12,4 +18,4 @@ export const GridSkeleton = React.memo(() => {
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Discover from "@/components/seerr/discover/Discover";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import Discover from "@/components/jellyseerr/discover/Discover";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type {
|
||||
MovieResult,
|
||||
@@ -18,57 +18,57 @@ import type {
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
||||
import { Text } from "../common/Text";
|
||||
import SeerrPoster from "../posters/SeerrPoster";
|
||||
import JellyseerrPoster from "../posters/JellyseerrPoster";
|
||||
import { LoadingSkeleton } from "../search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "../search/SearchItemWrapper";
|
||||
import PersonPoster from "./PersonPoster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
searchQuery: string;
|
||||
sortType?: SeerrSearchSort;
|
||||
sortType?: JellyseerrSearchSort;
|
||||
order?: "asc" | "desc";
|
||||
}
|
||||
|
||||
export enum SeerrSearchSort {
|
||||
export enum JellyseerrSearchSort {
|
||||
DEFAULT = 0,
|
||||
VOTE_COUNT_AND_AVERAGE = 1,
|
||||
POPULARITY = 2,
|
||||
}
|
||||
|
||||
export const SeerrIndexPage: React.FC<Props> = ({
|
||||
export const JellyserrIndexPage: React.FC<Props> = ({
|
||||
searchQuery,
|
||||
sortType,
|
||||
order,
|
||||
}) => {
|
||||
const { seerrApi } = useSeerr();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const opacity = useSharedValue(1);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
data: seerrDiscoverSettings,
|
||||
data: jellyseerrDiscoverSettings,
|
||||
isFetching: f1,
|
||||
isLoading: l1,
|
||||
} = useReactNavigationQuery({
|
||||
queryKey: ["search", "seerr", "discoverSettings", searchQuery],
|
||||
queryFn: async () => seerrApi?.discoverSettings(),
|
||||
enabled: !!seerrApi && searchQuery.length === 0,
|
||||
queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
|
||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||
enabled: !!jellyseerrApi && searchQuery.length === 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: seerrResults,
|
||||
data: jellyseerrResults,
|
||||
isFetching: f2,
|
||||
isLoading: l2,
|
||||
} = useReactNavigationQuery({
|
||||
queryKey: ["search", "seerr", "results", searchQuery],
|
||||
queryKey: ["search", "jellyseerr", "results", searchQuery],
|
||||
queryFn: async () => {
|
||||
const params = {
|
||||
query: new URLSearchParams(searchQuery || "").toString(),
|
||||
};
|
||||
return await Promise.all([
|
||||
seerrApi?.search({ ...params, page: 1 }),
|
||||
seerrApi?.search({ ...params, page: 2 }),
|
||||
seerrApi?.search({ ...params, page: 3 }),
|
||||
seerrApi?.search({ ...params, page: 4 }),
|
||||
jellyseerrApi?.search({ ...params, page: 1 }),
|
||||
jellyseerrApi?.search({ ...params, page: 2 }),
|
||||
jellyseerrApi?.search({ ...params, page: 3 }),
|
||||
jellyseerrApi?.search({ ...params, page: 4 }),
|
||||
]).then((all) =>
|
||||
uniqBy(
|
||||
all.flatMap((v) => v?.results || []),
|
||||
@@ -76,7 +76,7 @@ export const SeerrIndexPage: React.FC<Props> = ({
|
||||
),
|
||||
);
|
||||
},
|
||||
enabled: !!seerrApi && searchQuery.length > 0,
|
||||
enabled: !!jellyseerrApi && searchQuery.length > 0,
|
||||
});
|
||||
|
||||
useAnimatedReaction(
|
||||
@@ -92,20 +92,20 @@ export const SeerrIndexPage: React.FC<Props> = ({
|
||||
|
||||
const sortingType = useMemo(() => {
|
||||
if (!sortType) return;
|
||||
switch (Number(SeerrSearchSort[sortType])) {
|
||||
case SeerrSearchSort.VOTE_COUNT_AND_AVERAGE:
|
||||
switch (Number(JellyseerrSearchSort[sortType])) {
|
||||
case JellyseerrSearchSort.VOTE_COUNT_AND_AVERAGE:
|
||||
return ["voteCount", "voteAverage"];
|
||||
case SeerrSearchSort.POPULARITY:
|
||||
case JellyseerrSearchSort.POPULARITY:
|
||||
return ["voteCount", "popularity"];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [sortType, order]);
|
||||
|
||||
const seerrMovieResults = useMemo(
|
||||
const jellyseerrMovieResults = useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
seerrResults?.filter(
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.MOVIE,
|
||||
) as MovieResult[],
|
||||
sortingType || [
|
||||
@@ -113,37 +113,41 @@ export const SeerrIndexPage: React.FC<Props> = ({
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[seerrResults, sortingType, order, searchQuery],
|
||||
[jellyseerrResults, sortingType, order],
|
||||
);
|
||||
|
||||
const seerrTvResults = useMemo(
|
||||
const jellyseerrTvResults = useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
seerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[],
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.TV,
|
||||
) as TvResult[],
|
||||
sortingType || [
|
||||
(t) => t.name.toLowerCase() === searchQuery.toLowerCase(),
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[seerrResults, sortingType, order, searchQuery],
|
||||
[jellyseerrResults, sortingType, order],
|
||||
);
|
||||
|
||||
const seerrPersonResults = useMemo(
|
||||
const jellyseerrPersonResults = useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
seerrResults?.filter((r) => r.mediaType === "person") as PersonResult[],
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === "person",
|
||||
) as PersonResult[],
|
||||
sortingType || [
|
||||
(p) => p.name.toLowerCase() === searchQuery.toLowerCase(),
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[seerrResults, sortingType, order, searchQuery],
|
||||
[jellyseerrResults, sortingType, order],
|
||||
);
|
||||
|
||||
if (!searchQuery.length)
|
||||
return (
|
||||
<View className='flex flex-col'>
|
||||
<Discover sliders={seerrDiscoverSettings} />
|
||||
<Discover sliders={jellyseerrDiscoverSettings} />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -151,9 +155,9 @@ export const SeerrIndexPage: React.FC<Props> = ({
|
||||
<View>
|
||||
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
|
||||
|
||||
{!seerrMovieResults?.length &&
|
||||
!seerrTvResults?.length &&
|
||||
!seerrPersonResults?.length &&
|
||||
{!jellyseerrMovieResults?.length &&
|
||||
!jellyseerrTvResults?.length &&
|
||||
!jellyseerrPersonResults?.length &&
|
||||
!f1 &&
|
||||
!f2 &&
|
||||
!l1 &&
|
||||
@@ -171,21 +175,21 @@ export const SeerrIndexPage: React.FC<Props> = ({
|
||||
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header={t("search.request_movies")}
|
||||
items={seerrMovieResults}
|
||||
items={jellyseerrMovieResults}
|
||||
renderItem={(item: MovieResult) => (
|
||||
<SeerrPoster item={item} key={item.id} />
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header={t("search.request_series")}
|
||||
items={seerrTvResults}
|
||||
items={jellyseerrTvResults}
|
||||
renderItem={(item: TvResult) => (
|
||||
<SeerrPoster item={item} key={item.id} />
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header={t("search.actors")}
|
||||
items={seerrPersonResults}
|
||||
items={jellyseerrPersonResults}
|
||||
renderItem={(item: PersonResult) => (
|
||||
<PersonPoster
|
||||
className='mr-2'
|
||||
@@ -3,11 +3,9 @@ import { useMemo } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
|
||||
mediaType,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const JellyseerrMediaIcon: React.FC<
|
||||
{ mediaType: "tv" | "movie" } & ViewProps
|
||||
> = ({ mediaType, className, ...props }) => {
|
||||
const style = useMemo(
|
||||
() =>
|
||||
mediaType === MediaType.MOVIE
|
||||
@@ -31,4 +29,4 @@ const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SeerrMediaIcon;
|
||||
export default JellyseerrMediaIcon;
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const SeerrStatusIcon: React.FC<Props & ViewProps> = ({
|
||||
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
|
||||
mediaStatus,
|
||||
showRequestIcon,
|
||||
onPress,
|
||||
@@ -74,4 +74,4 @@ const SeerrStatusIcon: React.FC<Props & ViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SeerrStatusIcon;
|
||||
export default JellyseerrStatusIcon;
|
||||
@@ -133,7 +133,7 @@ const ParallaxSlideShow = <T,>({
|
||||
<View className='px-4'>
|
||||
<View className='flex flex-row flex-wrap'>
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
<GridSkeleton key={i} />
|
||||
<GridSkeleton key={i} index={i} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
@@ -4,7 +4,7 @@ import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -20,7 +20,7 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
|
||||
subName,
|
||||
...props
|
||||
}) => {
|
||||
const { seerrApi } = useSeerr();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
@@ -28,20 +28,20 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/(tabs)/${from}/seerr/person/${id}`)}
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col w-28' {...props}>
|
||||
<Poster
|
||||
id={id}
|
||||
url={seerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
|
||||
url={jellyseerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
|
||||
/>
|
||||
<Text className='mt-2'>{name}</Text>
|
||||
{subName && <Text className='text-xs opacity-50'>{subName}</Text>}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default PersonPoster;
|
||||
@@ -12,7 +12,7 @@ import { View, type ViewProps } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type {
|
||||
QualityProfile,
|
||||
RootFolder,
|
||||
@@ -38,23 +38,14 @@ const RequestModal = forwardRef<
|
||||
Props & Omit<ViewProps, "id">
|
||||
>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
title,
|
||||
requestBody,
|
||||
type,
|
||||
isAnime = false,
|
||||
is4k,
|
||||
onRequested,
|
||||
onDismiss,
|
||||
},
|
||||
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
|
||||
ref,
|
||||
) => {
|
||||
const { seerrApi, seerrUser, requestMedia } = useSeerr();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||
mediaId: Number(id),
|
||||
mediaType: type,
|
||||
userId: seerrUser?.id,
|
||||
userId: jellyseerrUser?.id,
|
||||
});
|
||||
|
||||
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
|
||||
@@ -74,17 +65,18 @@ const RequestModal = forwardRef<
|
||||
}, [onDismiss]);
|
||||
|
||||
const { data: serviceSettings } = useQuery({
|
||||
queryKey: ["seerr", "request", type, "service"],
|
||||
queryKey: ["jellyseerr", "request", type, "service"],
|
||||
queryFn: async () =>
|
||||
seerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
|
||||
enabled: !!seerrApi && !!seerrUser,
|
||||
jellyseerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
|
||||
enabled: !!jellyseerrApi && !!jellyseerrUser,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ["seerr", "users"],
|
||||
queryFn: async () => seerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||
enabled: !!seerrApi && !!seerrUser,
|
||||
queryKey: ["jellyseerr", "users"],
|
||||
queryFn: async () =>
|
||||
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||
enabled: !!jellyseerrApi && !!jellyseerrUser,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
@@ -95,7 +87,7 @@ const RequestModal = forwardRef<
|
||||
|
||||
const { data: defaultServiceDetails } = useQuery({
|
||||
queryKey: [
|
||||
"seerr",
|
||||
"jellyseerr",
|
||||
"request",
|
||||
type,
|
||||
"service",
|
||||
@@ -107,12 +99,12 @@ const RequestModal = forwardRef<
|
||||
...prev,
|
||||
serverId: defaultService?.id,
|
||||
}));
|
||||
return seerrApi?.serviceDetails(
|
||||
return jellyseerrApi?.serviceDetails(
|
||||
type === "movie" ? "radarr" : "sonarr",
|
||||
defaultService!.id,
|
||||
);
|
||||
},
|
||||
enabled: !!seerrApi && !!seerrUser && !!defaultService,
|
||||
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
@@ -156,9 +148,9 @@ const RequestModal = forwardRef<
|
||||
return undefined;
|
||||
}
|
||||
if (requestBody.seasons.length > 1) {
|
||||
return t("seerr.season_all");
|
||||
return t("jellyseerr.season_all");
|
||||
}
|
||||
return t("seerr.season_number", {
|
||||
return t("jellyseerr.season_number", {
|
||||
season_number: requestBody.seasons[0],
|
||||
});
|
||||
}, [requestBody?.seasons]);
|
||||
@@ -253,7 +245,8 @@ const RequestModal = forwardRef<
|
||||
type: "radio" as const,
|
||||
label: user.displayName,
|
||||
value: user.id.toString(),
|
||||
selected: (requestOverrides.userId || seerrUser?.id) === user.id,
|
||||
selected:
|
||||
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||
onPress: () =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
@@ -262,13 +255,12 @@ const RequestModal = forwardRef<
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[users, seerrUser, requestOverrides.userId],
|
||||
[users, jellyseerrUser, requestOverrides.userId],
|
||||
);
|
||||
|
||||
const request = useCallback(() => {
|
||||
const body = {
|
||||
is4k:
|
||||
is4k ?? defaultService?.is4k ?? defaultServiceDetails?.server.is4k,
|
||||
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||
profileId: defaultProfile?.id,
|
||||
rootFolder: defaultFolder?.path,
|
||||
tags: defaultTags.map((t) => t.id),
|
||||
@@ -276,7 +268,7 @@ const RequestModal = forwardRef<
|
||||
...requestOverrides,
|
||||
};
|
||||
|
||||
writeDebugLog("Sending Seerr advanced request", body);
|
||||
writeDebugLog("Sending Jellyseerr advanced request", body);
|
||||
|
||||
requestMedia(
|
||||
seasonTitle ? `${title}, ${seasonTitle}` : title,
|
||||
@@ -284,18 +276,11 @@ const RequestModal = forwardRef<
|
||||
onRequested,
|
||||
);
|
||||
}, [
|
||||
is4k,
|
||||
defaultService?.is4k,
|
||||
defaultServiceDetails?.server.is4k,
|
||||
requestBody,
|
||||
requestOverrides,
|
||||
defaultProfile,
|
||||
defaultFolder,
|
||||
defaultTags,
|
||||
requestMedia,
|
||||
seasonTitle,
|
||||
title,
|
||||
onRequested,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -323,7 +308,7 @@ const RequestModal = forwardRef<
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("seerr.advanced")}
|
||||
{t("jellyseerr.advanced")}
|
||||
</Text>
|
||||
{seasonTitle && (
|
||||
<Text className='text-neutral-300'>{seasonTitle}</Text>
|
||||
@@ -334,7 +319,7 @@ const RequestModal = forwardRef<
|
||||
<>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("seerr.quality_profile")}
|
||||
{t("jellyseerr.quality_profile")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={qualityProfileOptions}
|
||||
@@ -350,7 +335,7 @@ const RequestModal = forwardRef<
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("seerr.quality_profile")}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
open={qualityProfileOpen}
|
||||
onOpenChange={setQualityProfileOpen}
|
||||
/>
|
||||
@@ -358,7 +343,7 @@ const RequestModal = forwardRef<
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("seerr.root_folder")}
|
||||
{t("jellyseerr.root_folder")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={rootFolderOptions}
|
||||
@@ -383,45 +368,42 @@ const RequestModal = forwardRef<
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("seerr.root_folder")}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
open={rootFolderOpen}
|
||||
onOpenChange={setRootFolderOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{defaultServiceDetails?.tags &&
|
||||
defaultServiceDetails.tags.length > 0 && (
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("seerr.tags")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={tagsOptions}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{requestOverrides.tags
|
||||
? defaultServiceDetails.tags
|
||||
.filter((t) =>
|
||||
requestOverrides.tags!.includes(t.id),
|
||||
)
|
||||
.map((t) => t.label)
|
||||
.join(", ") ||
|
||||
defaultTags.map((t) => t.label).join(", ")
|
||||
: defaultTags.map((t) => t.label).join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("seerr.tags")}
|
||||
open={tagsOpen}
|
||||
onOpenChange={setTagsOpen}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.tags")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={tagsOptions}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{requestOverrides.tags
|
||||
? defaultServiceDetails.tags
|
||||
.filter((t) =>
|
||||
requestOverrides.tags!.includes(t.id),
|
||||
)
|
||||
.map((t) => t.label)
|
||||
.join(", ") ||
|
||||
defaultTags.map((t) => t.label).join(", ")
|
||||
: defaultTags.map((t) => t.label).join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.tags")}
|
||||
open={tagsOpen}
|
||||
onOpenChange={setTagsOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("seerr.request_as")}
|
||||
{t("jellyseerr.request_as")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={usersOptions}
|
||||
@@ -431,12 +413,12 @@ const RequestModal = forwardRef<
|
||||
{users.find(
|
||||
(u) =>
|
||||
u.id ===
|
||||
(requestOverrides.userId || seerrUser?.id),
|
||||
)?.displayName || seerrUser!.displayName}
|
||||
(requestOverrides.userId || jellyseerrUser?.id),
|
||||
)?.displayName || jellyseerrUser!.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("seerr.request_as")}
|
||||
title={t("jellyseerr.request_as")}
|
||||
open={usersOpen}
|
||||
onOpenChange={setUsersOpen}
|
||||
/>
|
||||
@@ -445,7 +427,7 @@ const RequestModal = forwardRef<
|
||||
)}
|
||||
</View>
|
||||
<Button className='mt-auto' onPress={request} color='purple'>
|
||||
{t("seerr.request_button")}
|
||||
{t("jellyseerr.request_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
@@ -2,10 +2,10 @@ import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
COMPANY_LOGO_IMAGE_FILTER,
|
||||
type Network,
|
||||
@@ -16,17 +16,17 @@ const CompanySlide: React.FC<
|
||||
{ data: Network[] | Studio[] } & SlideProps & ViewProps
|
||||
> = ({ slide, data, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { seerrApi } = useSeerr();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
({ id, image, name }: Network | Studio) =>
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/seerr/company/${id}` as any,
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any,
|
||||
params: { id, image, name, type: slide.type },
|
||||
}),
|
||||
[router, from, slide.type],
|
||||
[slide],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -40,7 +40,10 @@ const CompanySlide: React.FC<
|
||||
<GenericSlideCard
|
||||
className='w-28 rounded-lg overflow-hidden border border-neutral-900 p-4'
|
||||
id={item.id.toString()}
|
||||
url={seerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
|
||||
url={jellyseerrApi?.imageProxy(
|
||||
item.image,
|
||||
COMPANY_LOGO_IMAGE_FILTER,
|
||||
)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -2,10 +2,10 @@ import { sortBy } from "lodash";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import CompanySlide from "@/components/seerr/discover/CompanySlide";
|
||||
import GenreSlide from "@/components/seerr/discover/GenreSlide";
|
||||
import MovieTvSlide from "@/components/seerr/discover/MovieTvSlide";
|
||||
import RecentRequestsSlide from "@/components/seerr/discover/RecentRequestsSlide";
|
||||
import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
|
||||
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
|
||||
import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
|
||||
import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
@@ -23,6 +23,7 @@ const Discover: React.FC<Props> = ({ sliders }) => {
|
||||
sortBy(
|
||||
(sliders ?? []).filter((s) => s.enabled),
|
||||
"order",
|
||||
"asc",
|
||||
),
|
||||
[sliders],
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image, type ImageContentFit } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import React from "react";
|
||||
import type React from "react";
|
||||
import { StyleSheet, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
@@ -67,4 +67,4 @@ const GenericSlideCard: React.FC<
|
||||
</>
|
||||
);
|
||||
|
||||
export default React.memo(GenericSlideCard);
|
||||
export default GenericSlideCard;
|
||||
@@ -3,38 +3,39 @@ import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
|
||||
|
||||
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { seerrApi } = useSeerr();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
(genre: GenreSliderItem) =>
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/seerr/genre/${genre.id}` as any,
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
|
||||
params: { type: slide.type, name: genre.name },
|
||||
}),
|
||||
[router, from, slide.type],
|
||||
[slide],
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["seerr", "discover", slide.type, slide.id],
|
||||
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
|
||||
queryFn: async () => {
|
||||
return seerrApi?.getGenreSliders(
|
||||
return jellyseerrApi?.getGenreSliders(
|
||||
slide.type === DiscoverSliderType.MOVIE_GENRES
|
||||
? Endpoints.MOVIE
|
||||
: Endpoints.TV,
|
||||
);
|
||||
},
|
||||
enabled: !!seerrApi,
|
||||
enabled: !!jellyseerrApi,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -52,7 +53,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
title={item.name}
|
||||
colors={["transparent", "transparent"]}
|
||||
contentFit={"cover"}
|
||||
url={seerrApi?.imageProxy(
|
||||
url={jellyseerrApi?.imageProxy(
|
||||
item.backdrops?.[0],
|
||||
`w780_filter(duotone,${
|
||||
genreColorMap[item.id] ?? genreColorMap[0]
|
||||
@@ -3,19 +3,23 @@ import { uniqBy } from "lodash";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import { type DiscoverEndpoint, Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import {
|
||||
type DiscoverEndpoint,
|
||||
Endpoints,
|
||||
useJellyseerr,
|
||||
} from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
|
||||
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
slide,
|
||||
...props
|
||||
}) => {
|
||||
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ["seerr", "discover", slide.id],
|
||||
queryKey: ["jellyseerr", "discover", slide.id],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
let endpoint: DiscoverEndpoint | undefined;
|
||||
let params: any = {
|
||||
@@ -46,13 +50,13 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
break;
|
||||
}
|
||||
|
||||
return endpoint ? seerrApi?.discover(endpoint, params) : null;
|
||||
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||
1,
|
||||
enabled: !!seerrApi,
|
||||
enabled: !!jellyseerrApi,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
@@ -61,10 +65,12 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
uniqBy(
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))),
|
||||
.flatMap((p) =>
|
||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
|
||||
),
|
||||
"id",
|
||||
),
|
||||
[data, isSeerrMovieOrTvResult],
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -78,7 +84,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) fetchNextPage();
|
||||
}}
|
||||
renderItem={(item) => <SeerrPoster item={item} key={item?.id} />}
|
||||
renderItem={(item) => <JellyseerrPoster item={item} key={item?.id} />}
|
||||
/>
|
||||
)
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
|
||||
@@ -16,36 +16,36 @@ type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
|
||||
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
|
||||
request,
|
||||
}) => {
|
||||
const { seerrApi } = useSeerr();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { data: details } = useQuery({
|
||||
queryKey: [
|
||||
"seerr",
|
||||
"jellyseerr",
|
||||
"detail",
|
||||
request.media.mediaType,
|
||||
request.media.tmdbId,
|
||||
],
|
||||
queryFn: async () => {
|
||||
return request.media.mediaType === MediaType.MOVIE
|
||||
? seerrApi?.movieDetails(request.media.tmdbId)
|
||||
: seerrApi?.tvDetails(request.media.tmdbId);
|
||||
? jellyseerrApi?.movieDetails(request.media.tmdbId)
|
||||
: jellyseerrApi?.tvDetails(request.media.tmdbId);
|
||||
},
|
||||
enabled: !!seerrApi,
|
||||
enabled: !!jellyseerrApi,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: refreshedRequest } = useQuery({
|
||||
queryKey: ["seerr", "requests", request.media.mediaType, request.id],
|
||||
queryFn: async () => seerrApi?.getRequest(request.id),
|
||||
enabled: !!seerrApi,
|
||||
queryKey: ["jellyseerr", "requests", request.media.mediaType, request.id],
|
||||
queryFn: async () => jellyseerrApi?.getRequest(request.id),
|
||||
enabled: !!jellyseerrApi,
|
||||
refetchOnMount: true,
|
||||
refetchInterval: 5000,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<SeerrPoster
|
||||
<JellyseerrPoster
|
||||
horizontal
|
||||
showDownloadInfo
|
||||
item={details}
|
||||
@@ -58,12 +58,12 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
slide,
|
||||
...props
|
||||
}) => {
|
||||
const { seerrApi } = useSeerr();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { data: requests } = useQuery({
|
||||
queryKey: ["seerr", "recent_requests"],
|
||||
queryFn: async () => seerrApi?.requests(),
|
||||
enabled: !!seerrApi,
|
||||
queryKey: ["jellyseerr", "recent_requests"],
|
||||
queryFn: async () => jellyseerrApi?.requests(),
|
||||
enabled: !!jellyseerrApi,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
});
|
||||
@@ -14,7 +14,10 @@ export interface SlideProps {
|
||||
|
||||
interface Props<T> extends SlideProps {
|
||||
data: T[];
|
||||
renderItem: (item: T, index: number) => React.ReactElement | null;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
) => React.ComponentType<any> | React.ReactElement | null | undefined;
|
||||
keyExtractor: (item: T) => string;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
}
|
||||
@@ -44,6 +47,7 @@ const Slide = <T,>({
|
||||
data={data}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={onEndReached}
|
||||
//@ts-expect-error
|
||||
renderItem={({ item, index }) =>
|
||||
item ? renderItem(item, index) : null
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
api,
|
||||
item: library,
|
||||
}),
|
||||
[api, library],
|
||||
[library],
|
||||
);
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
|
||||
@@ -23,8 +23,10 @@ export const MusicAlbumCard: React.FC<Props> = ({ album, width = 130 }) => {
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!album.Id) return;
|
||||
router.push(`/music/album/${album.Id}`);
|
||||
router.push({
|
||||
pathname: "/music/album/[albumId]",
|
||||
params: { albumId: album.Id! },
|
||||
});
|
||||
}, [router, album.Id]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,8 +24,10 @@ export const MusicAlbumRowCard: React.FC<Props> = ({ album }) => {
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!album.Id) return;
|
||||
router.push(`/music/album/${album.Id}`);
|
||||
router.push({
|
||||
pathname: "/music/album/[albumId]",
|
||||
params: { albumId: album.Id! },
|
||||
});
|
||||
}, [router, album.Id]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -25,8 +25,10 @@ export const MusicArtistCard: React.FC<Props> = ({ artist }) => {
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!artist.Id) return;
|
||||
router.push(`/music/artist/${artist.Id}`);
|
||||
router.push({
|
||||
pathname: "/music/artist/[artistId]",
|
||||
params: { artistId: artist.Id! },
|
||||
});
|
||||
}, [router, artist.Id]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -61,7 +61,10 @@ export const MusicPlaylistCard: React.FC<Props> = ({ playlist }) => {
|
||||
const hasDownloads = downloadStatus.downloaded > 0;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
router.push(`/music/playlist/${playlist.Id}`);
|
||||
router.push({
|
||||
pathname: "/music/playlist/[playlistId]",
|
||||
params: { playlistId: playlist.Id! },
|
||||
});
|
||||
}, [router, playlist.Id]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -197,7 +197,10 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
||||
const artistId = track?.ArtistItems?.[0]?.Id;
|
||||
if (artistId) {
|
||||
setOpen(false);
|
||||
router.push(`/music/artist/${artistId}`);
|
||||
router.push({
|
||||
pathname: "/music/artist/[artistId]",
|
||||
params: { artistId },
|
||||
});
|
||||
}
|
||||
}, [track?.ArtistItems, router, setOpen]);
|
||||
|
||||
@@ -205,7 +208,10 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
||||
const albumId = track?.AlbumId || track?.ParentId;
|
||||
if (albumId) {
|
||||
setOpen(false);
|
||||
router.push(`/music/album/${albumId}`);
|
||||
router.push({
|
||||
pathname: "/music/album/[albumId]",
|
||||
params: { albumId },
|
||||
});
|
||||
}
|
||||
}, [track?.AlbumId, track?.ParentId, router, setOpen]);
|
||||
|
||||
|
||||
12
components/navigation/TabBarIcon.tsx
Normal file
12
components/navigation/TabBarIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||
|
||||
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function TabBarIcon({
|
||||
style,
|
||||
...rest
|
||||
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||
}
|
||||
63
components/posters/EpisodePoster.tsx
Normal file
63
components/posters/EpisodePoster.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
type MoviePosterProps = {
|
||||
item: BaseItemDto;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
||||
item,
|
||||
showProgress = false,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(() => {
|
||||
if (item.Type === "Episode") {
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const [progress, _setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0,
|
||||
);
|
||||
|
||||
const blurhash = useMemo(() => {
|
||||
const key = item.ImageTags?.Primary as string;
|
||||
return item.ImageBlurHashes?.Primary?.[key];
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
|
||||
<Image
|
||||
placeholder={{
|
||||
blurhash,
|
||||
}}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={
|
||||
url
|
||||
? {
|
||||
uri: url,
|
||||
}
|
||||
: null
|
||||
}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
{showProgress && progress > 0 && (
|
||||
<View className='h-1 bg-red-600 w-full' />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -7,15 +7,15 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { TouchableSeerrRouter } from "@/components/common/SeerrItemRouter";
|
||||
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Tag, Tags } from "@/components/GenreTags";
|
||||
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
|
||||
import SeerrMediaIcon from "@/components/seerr/SeerrMediaIcon";
|
||||
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
|
||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
|
||||
@@ -34,13 +34,13 @@ interface Props extends ViewProps {
|
||||
mediaRequest?: MediaRequest;
|
||||
}
|
||||
|
||||
const SeerrPoster: React.FC<Props> = ({
|
||||
const JellyseerrPoster: React.FC<Props> = ({
|
||||
item,
|
||||
horizontal,
|
||||
showDownloadInfo,
|
||||
mediaRequest,
|
||||
}) => {
|
||||
const { seerrApi, getTitle, getYear, getMediaType } = useSeerr();
|
||||
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
|
||||
const loadingOpacity = useSharedValue(1);
|
||||
const imageOpacity = useSharedValue(0);
|
||||
const { t } = useTranslation();
|
||||
@@ -56,13 +56,16 @@ const SeerrPoster: React.FC<Props> = ({
|
||||
|
||||
const backdropSrc = useMemo(
|
||||
() =>
|
||||
seerrApi?.imageProxy(item?.backdropPath, "w1920_and_h800_multi_faces"),
|
||||
[item, seerrApi, horizontal],
|
||||
jellyseerrApi?.imageProxy(
|
||||
item?.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
[item, jellyseerrApi, horizontal],
|
||||
);
|
||||
|
||||
const posterSrc = useMemo(
|
||||
() => seerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
|
||||
[item, seerrApi, horizontal],
|
||||
() => jellyseerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
|
||||
[item, jellyseerrApi, horizontal],
|
||||
);
|
||||
|
||||
const title = useMemo(() => getTitle(item), [item]);
|
||||
@@ -72,7 +75,7 @@ const SeerrPoster: React.FC<Props> = ({
|
||||
const size = useMemo(() => (horizontal ? "h-28" : "w-28"), [horizontal]);
|
||||
const ratio = useMemo(() => (horizontal ? "15/10" : "10/15"), [horizontal]);
|
||||
|
||||
const [canRequest] = useSeerrCanRequest(item);
|
||||
const [canRequest] = useJellyseerrCanRequest(item);
|
||||
|
||||
const is4k = useMemo(() => mediaRequest?.is4k === true, [mediaRequest]);
|
||||
|
||||
@@ -106,7 +109,7 @@ const SeerrPoster: React.FC<Props> = ({
|
||||
second,
|
||||
third,
|
||||
fourth,
|
||||
t("home.settings.plugins.seerr.plus_n_more", { n: rest.length }),
|
||||
t("home.settings.plugins.jellyseerr.plus_n_more", { n: rest.length }),
|
||||
];
|
||||
}
|
||||
return seasons;
|
||||
@@ -118,7 +121,7 @@ const SeerrPoster: React.FC<Props> = ({
|
||||
}, [mediaRequest, is4k]);
|
||||
|
||||
return (
|
||||
<TouchableSeerrRouter
|
||||
<TouchableJellyseerrRouter
|
||||
result={item}
|
||||
mediaTitle={title}
|
||||
releaseYear={releaseYear}
|
||||
@@ -170,7 +173,7 @@ const SeerrPoster: React.FC<Props> = ({
|
||||
className='absolute right-1 top-1 text-right bg-black border border-neutral-800/50'
|
||||
text={mediaRequest?.requestedBy.displayName}
|
||||
/>
|
||||
{(requestedSeasons?.length ?? 0) > 0 && (
|
||||
{requestedSeasons.length > 0 && (
|
||||
<Tags
|
||||
className='absolute bottom-1 left-0.5 w-32'
|
||||
tagProps={{
|
||||
@@ -181,12 +184,12 @@ const SeerrPoster: React.FC<Props> = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<SeerrStatusIcon
|
||||
<JellyseerrStatusIcon
|
||||
className='absolute bottom-1 right-1'
|
||||
showRequestIcon={canRequest}
|
||||
mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
|
||||
/>
|
||||
<SeerrMediaIcon
|
||||
<JellyseerrMediaIcon
|
||||
className='absolute top-1 left-1'
|
||||
mediaType={mediaType}
|
||||
/>
|
||||
@@ -198,8 +201,8 @@ const SeerrPoster: React.FC<Props> = ({
|
||||
{releaseYear || ""}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableSeerrRouter>
|
||||
</TouchableJellyseerrRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeerrPoster;
|
||||
export default JellyseerrPoster;
|
||||
48
components/posters/ParentPoster.tsx
Normal file
48
components/posters/ParentPoster.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
type PosterProps = {
|
||||
id?: string;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(
|
||||
() => `${api?.basePath}/Items/${id}/Images/Primary`,
|
||||
[id],
|
||||
);
|
||||
|
||||
if (!url || !id)
|
||||
return (
|
||||
<View
|
||||
className='border border-neutral-900'
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className='rounded-lg overflow-hidden border border-neutral-900'>
|
||||
<Image
|
||||
key={id}
|
||||
id={id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParentPoster;
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||
import { Platform, View } from "react-native";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { SeerrSearchSort } from "@/components/seerr/SeerrIndexPage";
|
||||
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
|
||||
interface DiscoverFiltersProps {
|
||||
searchFilterId: string;
|
||||
orderFilterId: string;
|
||||
seerrOrderBy: SeerrSearchSort;
|
||||
setSeerrOrderBy: (value: SeerrSearchSort) => void;
|
||||
seerrSortOrder: "asc" | "desc";
|
||||
setSeerrSortOrder: (value: "asc" | "desc") => void;
|
||||
jellyseerrOrderBy: JellyseerrSearchSort;
|
||||
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
|
||||
jellyseerrSortOrder: "asc" | "desc";
|
||||
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const sortOptions = Object.keys(SeerrSearchSort).filter((v) =>
|
||||
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
Number.isNaN(Number(v)),
|
||||
);
|
||||
|
||||
@@ -22,10 +22,10 @@ const orderOptions = ["asc", "desc"] as const;
|
||||
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
searchFilterId,
|
||||
orderFilterId,
|
||||
seerrOrderBy,
|
||||
setSeerrOrderBy,
|
||||
seerrSortOrder,
|
||||
setSeerrSortOrder,
|
||||
jellyseerrOrderBy,
|
||||
setJellyseerrOrderBy,
|
||||
jellyseerrSortOrder,
|
||||
setJellyseerrSortOrder,
|
||||
t,
|
||||
}) => {
|
||||
if (Platform.OS === "ios") {
|
||||
@@ -52,16 +52,16 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
<Picker
|
||||
label={t("library.filters.sort_by")}
|
||||
options={sortOptions.map((item) =>
|
||||
t(`home.settings.plugins.seerr.order_by.${item}`),
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
|
||||
)}
|
||||
variant='menu'
|
||||
selectedIndex={sortOptions.indexOf(
|
||||
seerrOrderBy as unknown as string,
|
||||
jellyseerrOrderBy as unknown as string,
|
||||
)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
setSeerrOrderBy(
|
||||
sortOptions[index] as unknown as SeerrSearchSort,
|
||||
setJellyseerrOrderBy(
|
||||
sortOptions[index] as unknown as JellyseerrSearchSort,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -69,10 +69,10 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
label={t("library.filters.sort_order")}
|
||||
options={orderOptions.map((item) => t(`library.filters.${item}`))}
|
||||
variant='menu'
|
||||
selectedIndex={orderOptions.indexOf(seerrSortOrder)}
|
||||
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
setSeerrSortOrder(orderOptions[index]);
|
||||
setJellyseerrSortOrder(orderOptions[index]);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu.Items>
|
||||
@@ -86,15 +86,17 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||
<FilterButton
|
||||
id={searchFilterId}
|
||||
queryKey='seerr_search'
|
||||
queryKey='jellyseerr_search'
|
||||
queryFn={async () =>
|
||||
Object.keys(SeerrSearchSort).filter((v) => Number.isNaN(Number(v)))
|
||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
Number.isNaN(Number(v)),
|
||||
)
|
||||
}
|
||||
set={(value) => setSeerrOrderBy(value[0])}
|
||||
values={[seerrOrderBy]}
|
||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||
values={[jellyseerrOrderBy]}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
t(`home.settings.plugins.seerr.order_by.${item}`)
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||
}
|
||||
disableSearch={true}
|
||||
/>
|
||||
@@ -102,8 +104,8 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
id={orderFilterId}
|
||||
queryKey='jellysearr_search'
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={(value) => setSeerrSortOrder(value[0])}
|
||||
values={[seerrSortOrder]}
|
||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||
values={[jellyseerrSortOrder]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||
disableSearch={true}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
@@ -52,7 +53,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
<HorizontalScroll
|
||||
loading={loading}
|
||||
keyExtractor={(i, _idx) => i.Id?.toString() || ""}
|
||||
height={220}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
data={destinctPeople}
|
||||
renderItem={(i) => (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
@@ -26,7 +27,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={[item]}
|
||||
height={220}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
renderItem={(item, _index) => (
|
||||
<TouchableOpacity
|
||||
key={item?.Id}
|
||||
|
||||
@@ -14,11 +14,11 @@ import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Tags } from "@/components/GenreTags";
|
||||
import { dateOpts } from "@/components/jellyseerr/DetailFacts";
|
||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { dateOpts } from "@/components/seerr/DetailFacts";
|
||||
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
|
||||
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
@@ -30,15 +30,15 @@ import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
const SeerrSeasonEpisodes: React.FC<{
|
||||
const JellyseerrSeasonEpisodes: React.FC<{
|
||||
details: TvDetails;
|
||||
seasonNumber: number;
|
||||
}> = ({ details, seasonNumber }) => {
|
||||
const { seerrApi } = useSeerr();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { data: seasonWithEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["seerr", details.id, "season", seasonNumber],
|
||||
queryFn: async () => seerrApi?.tvSeason(details.id, seasonNumber),
|
||||
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
|
||||
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
|
||||
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
|
||||
});
|
||||
|
||||
@@ -57,7 +57,11 @@ const SeerrSeasonEpisodes: React.FC<{
|
||||
};
|
||||
|
||||
const RenderItem = ({ item }: any) => {
|
||||
const { seerrApi, seerrRegion: region, seerrLocale: locale } = useSeerr();
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrRegion: region,
|
||||
jellyseerrLocale: locale,
|
||||
} = useJellyseerr();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const upcomingAirDate = useMemo(() => {
|
||||
@@ -65,7 +69,7 @@ const RenderItem = ({ item }: any) => {
|
||||
if (airDate) {
|
||||
const airDateObj = new Date(airDate);
|
||||
if (new Date() < airDateObj) {
|
||||
return airDateObj.toLocaleDateString(locale, dateOpts);
|
||||
return airDateObj.toLocaleDateString(`${locale}-${region}`, dateOpts);
|
||||
}
|
||||
}
|
||||
}, [item, locale, region]);
|
||||
@@ -79,7 +83,7 @@ const RenderItem = ({ item }: any) => {
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
source={{
|
||||
uri: seerrApi?.imageProxy(item.stillPath),
|
||||
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
@@ -127,7 +131,7 @@ const RenderItem = ({ item }: any) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SeerrSeasons: React.FC<{
|
||||
const JellyseerrSeasons: React.FC<{
|
||||
isLoading: boolean;
|
||||
details?: TvDetails;
|
||||
hasAdvancedRequest?: boolean;
|
||||
@@ -144,7 +148,7 @@ const SeerrSeasons: React.FC<{
|
||||
hasAdvancedRequest,
|
||||
onAdvancedRequest,
|
||||
}) => {
|
||||
const { seerrApi, requestMedia } = useSeerr();
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
const [seasonStates, setSeasonStates] = useState<{ [key: number]: boolean }>(
|
||||
{},
|
||||
);
|
||||
@@ -177,7 +181,7 @@ const SeerrSeasons: React.FC<{
|
||||
);
|
||||
|
||||
const requestAll = useCallback(() => {
|
||||
if (details && seerrApi) {
|
||||
if (details && jellyseerrApi) {
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: details.id,
|
||||
mediaType: MediaType.TV,
|
||||
@@ -194,7 +198,7 @@ const SeerrSeasons: React.FC<{
|
||||
requestMedia(details.name, body, refetch);
|
||||
}
|
||||
}, [
|
||||
seerrApi,
|
||||
jellyseerrApi,
|
||||
seasons,
|
||||
details,
|
||||
hasAdvancedRequest,
|
||||
@@ -206,15 +210,15 @@ const SeerrSeasons: React.FC<{
|
||||
const promptRequestAll = useCallback(
|
||||
() =>
|
||||
Alert.alert(
|
||||
t("seerr.confirm"),
|
||||
t("seerr.are_you_sure_you_want_to_request_all_seasons"),
|
||||
t("jellyseerr.confirm"),
|
||||
t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"),
|
||||
[
|
||||
{
|
||||
text: t("seerr.cancel"),
|
||||
text: t("jellyseerr.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("seerr.yes"),
|
||||
text: t("jellyseerr.yes"),
|
||||
onPress: requestAll,
|
||||
},
|
||||
],
|
||||
@@ -297,10 +301,10 @@ const SeerrSeasons: React.FC<{
|
||||
<Tags
|
||||
textClass=''
|
||||
tags={[
|
||||
t("seerr.season_number", {
|
||||
t("jellyseerr.season_number", {
|
||||
season_number: season.seasonNumber,
|
||||
}),
|
||||
t("seerr.number_episodes", {
|
||||
t("jellyseerr.number_episodes", {
|
||||
episode_number: season.episodeCount,
|
||||
}),
|
||||
]}
|
||||
@@ -308,7 +312,7 @@ const SeerrSeasons: React.FC<{
|
||||
{[0].map(() => {
|
||||
const canRequest = season.status === MediaStatus.UNKNOWN;
|
||||
return (
|
||||
<SeerrStatusIcon
|
||||
<JellyseerrStatusIcon
|
||||
key={0}
|
||||
onPress={() =>
|
||||
requestSeason(canRequest, season.seasonNumber)
|
||||
@@ -322,7 +326,7 @@ const SeerrSeasons: React.FC<{
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{seasonStates?.[season.seasonNumber] && (
|
||||
<SeerrSeasonEpisodes
|
||||
<JellyseerrSeasonEpisodes
|
||||
key={season.seasonNumber}
|
||||
details={details}
|
||||
seasonNumber={season.seasonNumber}
|
||||
@@ -334,4 +338,4 @@ const SeerrSeasons: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export default SeerrSeasons;
|
||||
export default JellyseerrSeasons;
|
||||
29
components/settings/Dashboard.tsx
Normal file
29
components/settings/Dashboard.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { settings } = useSettings();
|
||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!settings) return null;
|
||||
return (
|
||||
<View>
|
||||
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
||||
<ListItem
|
||||
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
||||
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||
title={t("home.settings.dashboard.sessions_title")}
|
||||
showArrow
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
3
components/settings/DownloadSettings.tsx
Normal file
3
components/settings/DownloadSettings.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
3
components/settings/DownloadSettings.tv.tsx
Normal file
3
components/settings/DownloadSettings.tv.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
181
components/settings/Jellyseerr.tsx
Normal file
181
components/settings/Jellyseerr.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const JellyseerrSettings = () => {
|
||||
const { jellyseerrUser, setJellyseerrUser, clearAllJellyseerData } =
|
||||
useJellyseerr();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
|
||||
string | undefined
|
||||
>(settings?.jellyseerrServerUrl || undefined);
|
||||
|
||||
const loginToJellyseerrMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
||||
throw new Error("Missing server url");
|
||||
if (!user?.Name)
|
||||
throw new Error("Missing required information for login");
|
||||
const jellyseerrTempApi = new JellyseerrApi(
|
||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
|
||||
);
|
||||
const testResult = await jellyseerrTempApi.test();
|
||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setJellyseerrUser(user);
|
||||
updateSettings({ jellyseerrServerUrl });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("jellyseerr.failed_to_login"));
|
||||
},
|
||||
onSettled: () => {
|
||||
setJellyseerrPassword(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
const clearData = () => {
|
||||
clearAllJellyseerData().finally(() => {
|
||||
setJellyseerrUser(undefined);
|
||||
setJellyseerrPassword(undefined);
|
||||
setjellyseerrServerUrl(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View className=''>
|
||||
<View>
|
||||
{jellyseerrUser ? (
|
||||
<>
|
||||
<ListGroup title={"Jellyseerr"}>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.jellyseerr.total_media_requests",
|
||||
)}
|
||||
value={jellyseerrUser?.requestCount?.toString()}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
|
||||
value={
|
||||
jellyseerrUser?.movieQuotaLimit?.toString() ??
|
||||
t("home.settings.plugins.jellyseerr.unlimited")
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
|
||||
value={
|
||||
jellyseerrUser?.movieQuotaDays?.toString() ??
|
||||
t("home.settings.plugins.jellyseerr.unlimited")
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
|
||||
value={
|
||||
jellyseerrUser?.tvQuotaLimit?.toString() ??
|
||||
t("home.settings.plugins.jellyseerr.unlimited")
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
|
||||
value={
|
||||
jellyseerrUser?.tvQuotaDays?.toString() ??
|
||||
t("home.settings.plugins.jellyseerr.unlimited")
|
||||
}
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<View className='p-4'>
|
||||
<Button color='red' onPress={clearData}>
|
||||
{t(
|
||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||
</Text>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||
</Text>
|
||||
<View className='flex flex-col shrink mb-2'>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Input
|
||||
className='border border-neutral-800 mb-2'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||
)}
|
||||
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
||||
defaultValue={
|
||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||
}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={setjellyseerrServerUrl}
|
||||
editable={!loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
<View>
|
||||
<Text className='font-bold mb-2'>
|
||||
{t("home.settings.plugins.jellyseerr.password")}
|
||||
</Text>
|
||||
<Input
|
||||
className='border border-neutral-800'
|
||||
autoFocus={true}
|
||||
focusable={true}
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.password_placeholder",
|
||||
{ username: user?.Name },
|
||||
)}
|
||||
value={jellyseerrPassword}
|
||||
keyboardType='default'
|
||||
secureTextEntry={true}
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='password'
|
||||
onChangeText={setJellyseerrPassword}
|
||||
editable={!loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
loading={loginToJellyseerrMutation.isPending}
|
||||
disabled={loginToJellyseerrMutation.isPending}
|
||||
color='purple'
|
||||
className='h-12 mt-2'
|
||||
onPress={() => loginToJellyseerrMutation.mutate()}
|
||||
>
|
||||
{t("home.settings.plugins.jellyseerr.login_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
|
||||
/>
|
||||
</OptionGroup>
|
||||
|
||||
<OptionGroup title={t("library.options.options_title")}>
|
||||
<OptionGroup title='Options'>
|
||||
<ToggleItem
|
||||
label={t("library.options.show_titles")}
|
||||
value={settings.showTitles}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -18,21 +17,20 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
const isTv = Platform.isTV;
|
||||
const media = useMedia();
|
||||
const { settings, updateSettings } = media;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||
|
||||
const alignXLabels: Record<AlignX, string> = {
|
||||
left: t("player.alignment_left"),
|
||||
center: t("player.alignment_center"),
|
||||
right: t("player.alignment_right"),
|
||||
left: "Left",
|
||||
center: "Center",
|
||||
right: "Right",
|
||||
};
|
||||
|
||||
const alignYLabels: Record<AlignY, string> = {
|
||||
top: t("player.alignment_top"),
|
||||
center: t("player.alignment_center"),
|
||||
bottom: t("player.alignment_bottom"),
|
||||
top: "Top",
|
||||
center: "Center",
|
||||
bottom: "Bottom",
|
||||
};
|
||||
|
||||
const alignXOptionGroups = useMemo(() => {
|
||||
@@ -63,14 +61,14 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup
|
||||
title={t("player.mpv_subtitle_settings_title")}
|
||||
title='MPV Subtitle Settings'
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
{t("player.mpv_subtitle_settings_description")}
|
||||
Advanced subtitle customization for MPV player
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<ListItem title={t("player.subtitle_scale")}>
|
||||
<ListItem title='Subtitle Scale'>
|
||||
<Stepper
|
||||
value={settings.mpvSubtitleScale ?? 1.0}
|
||||
step={0.1}
|
||||
@@ -82,7 +80,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("player.vertical_margin")}>
|
||||
<ListItem title='Vertical Margin'>
|
||||
<Stepper
|
||||
value={settings.mpvSubtitleMarginY ?? 0}
|
||||
step={5}
|
||||
@@ -92,7 +90,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("player.horizontal_alignment")}>
|
||||
<ListItem title='Horizontal Alignment'>
|
||||
<PlatformDropdown
|
||||
groups={alignXOptionGroups}
|
||||
trigger={
|
||||
@@ -107,11 +105,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("player.horizontal_alignment")}
|
||||
title='Horizontal Alignment'
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("player.vertical_alignment")}>
|
||||
<ListItem title='Vertical Alignment'>
|
||||
<PlatformDropdown
|
||||
groups={alignYOptionGroups}
|
||||
trigger={
|
||||
@@ -126,7 +124,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("player.vertical_alignment")}
|
||||
title='Vertical Alignment'
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
@@ -19,23 +19,23 @@ export const PluginSettings = () => {
|
||||
className='mb-4'
|
||||
>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/seerr/page")}
|
||||
title={"Seerr"}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||
title={"Streamystats"}
|
||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||
title={"Jellyseerr"}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
||||
title={"Marlin Search"}
|
||||
title='Marlin Search'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||
title='Streamystats'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||
title={"KefinTweaks"}
|
||||
title='KefinTweaks'
|
||||
showArrow
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { SeerrApi, useSeerr } from "@/hooks/useSeerr";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const SeerrSettings = () => {
|
||||
const { seerrUser, setSeerrUser, clearAllSeerrData } = useSeerr();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const [seerrPassword, setSeerrPassword] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const [seerrServerUrl, setSeerrServerUrl] = useState<string | undefined>(
|
||||
settings?.seerrServerUrl || undefined,
|
||||
);
|
||||
|
||||
const loginToSeerrMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!seerrServerUrl && !settings?.seerrServerUrl)
|
||||
throw new Error("Missing server url");
|
||||
if (!user?.Name)
|
||||
throw new Error("Missing required information for login");
|
||||
const seerrTempApi = new SeerrApi(
|
||||
seerrServerUrl || settings.seerrServerUrl || "",
|
||||
);
|
||||
const testResult = await seerrTempApi.test();
|
||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||
return seerrTempApi.login(user.Name, seerrPassword || "");
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setSeerrUser(user);
|
||||
updateSettings({ seerrServerUrl });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("seerr.failed_to_login"));
|
||||
},
|
||||
onSettled: () => {
|
||||
setSeerrPassword(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
const clearData = () => {
|
||||
clearAllSeerrData().finally(() => {
|
||||
setSeerrUser(undefined);
|
||||
setSeerrPassword(undefined);
|
||||
setSeerrServerUrl(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View className=''>
|
||||
<View>
|
||||
{seerrUser ? (
|
||||
<>
|
||||
<ListGroup title={"Seerr"}>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.seerr.total_media_requests")}
|
||||
value={seerrUser?.requestCount?.toString()}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.seerr.movie_quota_limit")}
|
||||
value={
|
||||
seerrUser?.movieQuotaLimit?.toString() ??
|
||||
t("home.settings.plugins.seerr.unlimited")
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.seerr.movie_quota_days")}
|
||||
value={
|
||||
seerrUser?.movieQuotaDays?.toString() ??
|
||||
t("home.settings.plugins.seerr.unlimited")
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.seerr.tv_quota_limit")}
|
||||
value={
|
||||
seerrUser?.tvQuotaLimit?.toString() ??
|
||||
t("home.settings.plugins.seerr.unlimited")
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.seerr.tv_quota_days")}
|
||||
value={
|
||||
seerrUser?.tvQuotaDays?.toString() ??
|
||||
t("home.settings.plugins.seerr.unlimited")
|
||||
}
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<View className='p-4'>
|
||||
<Button color='red' onPress={clearData}>
|
||||
{t("home.settings.plugins.seerr.reset_seerr_config_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{t("home.settings.plugins.seerr.seerr_warning")}
|
||||
</Text>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.settings.plugins.seerr.server_url")}
|
||||
</Text>
|
||||
<View className='flex flex-col shrink mb-2'>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{t("home.settings.plugins.seerr.server_url_hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Input
|
||||
className='border border-neutral-800 mb-2'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.seerr.server_url_placeholder",
|
||||
)}
|
||||
value={seerrServerUrl ?? settings?.seerrServerUrl}
|
||||
defaultValue={settings?.seerrServerUrl ?? seerrServerUrl}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={setSeerrServerUrl}
|
||||
editable={!loginToSeerrMutation.isPending}
|
||||
/>
|
||||
<View>
|
||||
<Text className='font-bold mb-2'>
|
||||
{t("home.settings.plugins.seerr.password")}
|
||||
</Text>
|
||||
<Input
|
||||
className='border border-neutral-800'
|
||||
autoFocus={true}
|
||||
focusable={true}
|
||||
placeholder={t(
|
||||
"home.settings.plugins.seerr.password_placeholder",
|
||||
{ username: user?.Name },
|
||||
)}
|
||||
value={seerrPassword}
|
||||
keyboardType='default'
|
||||
secureTextEntry={true}
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='password'
|
||||
onChangeText={setSeerrPassword}
|
||||
editable={!loginToSeerrMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
loading={loginToSeerrMutation.isPending}
|
||||
disabled={loginToSeerrMutation.isPending}
|
||||
color='purple'
|
||||
className='h-12 mt-2'
|
||||
onPress={() => loginToSeerrMutation.mutate()}
|
||||
>
|
||||
{t("home.settings.plugins.seerr.login_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -40,6 +40,7 @@ import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
@@ -57,6 +58,7 @@ interface Props {
|
||||
startPictureInPicture?: () => Promise<void>;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
aspectRatio?: AspectRatio;
|
||||
isZoomedToFill?: boolean;
|
||||
onZoomToggle?: () => void;
|
||||
api?: Api | null;
|
||||
@@ -87,6 +89,7 @@ export const Controls: FC<Props> = ({
|
||||
showControls,
|
||||
setShowControls,
|
||||
mediaSource,
|
||||
aspectRatio = "default",
|
||||
isZoomedToFill = false,
|
||||
onZoomToggle,
|
||||
api = null,
|
||||
@@ -495,6 +498,7 @@ export const Controls: FC<Props> = ({
|
||||
goToNextItem={goToNextItem}
|
||||
previousItem={previousItem}
|
||||
nextItem={nextItem}
|
||||
aspectRatio={aspectRatio}
|
||||
isZoomedToFill={isZoomedToFill}
|
||||
onZoomToggle={onZoomToggle}
|
||||
playbackSpeed={playbackSpeed}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
import { ZoomToggle } from "./ZoomToggle";
|
||||
|
||||
interface HeaderControlsProps {
|
||||
@@ -27,6 +28,7 @@ interface HeaderControlsProps {
|
||||
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
||||
previousItem?: BaseItemDto | null;
|
||||
nextItem?: BaseItemDto | null;
|
||||
aspectRatio?: AspectRatio;
|
||||
isZoomedToFill?: boolean;
|
||||
onZoomToggle?: () => void;
|
||||
// Playback speed props
|
||||
@@ -48,6 +50,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
goToNextItem,
|
||||
previousItem,
|
||||
nextItem,
|
||||
aspectRatio: _aspectRatio = "default",
|
||||
isZoomedToFill = false,
|
||||
onZoomToggle,
|
||||
playbackSpeed = 1.0,
|
||||
|
||||
105
components/video-player/controls/VideoScalingModeSelector.tsx
Normal file
105
components/video-player/controls/VideoScalingModeSelector.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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 { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
|
||||
|
||||
interface AspectRatioSelectorProps {
|
||||
currentRatio: AspectRatio;
|
||||
onRatioChange: (ratio: AspectRatio) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface AspectRatioOption {
|
||||
id: AspectRatio;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const ASPECT_RATIO_OPTIONS: AspectRatioOption[] = [
|
||||
{
|
||||
id: "default",
|
||||
label: "Original",
|
||||
description: "Use video's original aspect ratio",
|
||||
},
|
||||
{
|
||||
id: "16:9",
|
||||
label: "16:9",
|
||||
description: "Widescreen (most common)",
|
||||
},
|
||||
{
|
||||
id: "4:3",
|
||||
label: "4:3",
|
||||
description: "Traditional TV format",
|
||||
},
|
||||
{
|
||||
id: "1:1",
|
||||
label: "1:1",
|
||||
description: "Square format",
|
||||
},
|
||||
{
|
||||
id: "21:9",
|
||||
label: "21:9",
|
||||
description: "Ultra-wide cinematic",
|
||||
},
|
||||
];
|
||||
|
||||
export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
currentRatio,
|
||||
onRatioChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title='Aspect Ratio'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import {
|
||||
@@ -46,7 +45,6 @@ const DropdownView = ({
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
@@ -217,7 +215,7 @@ const DropdownView = ({
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title={t("player.playback_options_title")}
|
||||
title='Playback Options'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
expoUIConfig={{}}
|
||||
|
||||
39
constants/Languages.ts
Normal file
39
constants/Languages.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
|
||||
|
||||
export const LANGUAGES: DefaultLanguageOption[] = [
|
||||
{ label: "English", value: "eng" },
|
||||
{ label: "Spanish", value: "spa" },
|
||||
{ label: "Chinese (Mandarin)", value: "cmn" },
|
||||
{ label: "Hindi", value: "hin" },
|
||||
{ label: "Arabic", value: "ara" },
|
||||
{ label: "French", value: "fra" },
|
||||
{ label: "Russian", value: "rus" },
|
||||
{ label: "Portuguese", value: "por" },
|
||||
{ label: "Japanese", value: "jpn" },
|
||||
{ label: "German", value: "deu" },
|
||||
{ label: "Italian", value: "ita" },
|
||||
{ label: "Korean", value: "kor" },
|
||||
{ label: "Turkish", value: "tur" },
|
||||
{ label: "Dutch", value: "nld" },
|
||||
{ label: "Polish", value: "pol" },
|
||||
{ label: "Vietnamese", value: "vie" },
|
||||
{ label: "Thai", value: "tha" },
|
||||
{ label: "Indonesian", value: "ind" },
|
||||
{ label: "Greek", value: "ell" },
|
||||
{ label: "Swedish", value: "swe" },
|
||||
{ label: "Danish", value: "dan" },
|
||||
{ label: "Norwegian", value: "nor" },
|
||||
{ label: "Finnish", value: "fin" },
|
||||
{ label: "Czech", value: "ces" },
|
||||
{ label: "Hungarian", value: "hun" },
|
||||
{ label: "Romanian", value: "ron" },
|
||||
{ label: "Ukrainian", value: "ukr" },
|
||||
{ label: "Hebrew", value: "heb" },
|
||||
{ label: "Bengali", value: "ben" },
|
||||
{ label: "Punjabi", value: "pan" },
|
||||
{ label: "Tagalog", value: "tgl" },
|
||||
{ label: "Swahili", value: "swa" },
|
||||
{ label: "Malay", value: "msa" },
|
||||
{ label: "Persian", value: "fas" },
|
||||
{ label: "Urdu", value: "urd" },
|
||||
];
|
||||
6
constants/Values.ts
Normal file
6
constants/Values.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export const TAB_HEIGHT = Platform.OS === "android" ? 58 : 74;
|
||||
|
||||
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
|
||||
export const POSTER_CAROUSEL_HEIGHT = 220;
|
||||
37
hooks/useControlsVisibility.ts
Normal file
37
hooks/useControlsVisibility.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
|
||||
export const useControlsVisibility = (timeout = 3000) => {
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const showControls = useCallback(() => {
|
||||
opacity.value = 1;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
hideControlsTimerRef.current = setTimeout(() => {
|
||||
opacity.value = 0;
|
||||
}, timeout);
|
||||
}, [timeout]);
|
||||
|
||||
const hideControls = useCallback(() => {
|
||||
opacity.value = 0;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { opacity, showControls, hideControls };
|
||||
};
|
||||
35
hooks/useDownloadedFileOpener.ts
Normal file
35
hooks/useDownloadedFileOpener.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export const useDownloadedFileOpener = () => {
|
||||
const router = useRouter();
|
||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||
|
||||
const openFile = useCallback(
|
||||
async (item: BaseItemDto) => {
|
||||
if (!item.Id) {
|
||||
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
||||
console.error("Attempted to open a file without an ID.");
|
||||
return;
|
||||
}
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
try {
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error opening file", error);
|
||||
console.error("Error opening file:", error);
|
||||
}
|
||||
},
|
||||
[setOfflineSettings, setPlayUrl, router],
|
||||
);
|
||||
|
||||
return { openFile };
|
||||
};
|
||||
120
hooks/useImageColors.ts
Normal file
120
hooks/useImageColors.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type * as ImageColorsType from "react-native-image-colors";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Conditionally import react-native-image-colors only on non-TV platforms
|
||||
const ImageColors = Platform.isTV
|
||||
? null
|
||||
: (require("react-native-image-colors") as typeof ImageColorsType);
|
||||
|
||||
import {
|
||||
adjustToNearBlack,
|
||||
calculateTextColor,
|
||||
isCloseToBlack,
|
||||
itemThemeColorAtom,
|
||||
} from "@/utils/atoms/primaryColor";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
/**
|
||||
* Custom hook to extract and manage image colors for a given item.
|
||||
*
|
||||
* @param item - The BaseItemDto object representing the item.
|
||||
* @param disabled - A boolean flag to disable color extraction.
|
||||
*
|
||||
*/
|
||||
export const useImageColors = ({
|
||||
item,
|
||||
url,
|
||||
disabled,
|
||||
}: {
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const source = useMemo(() => {
|
||||
if (!api) return;
|
||||
if (url) return { uri: url };
|
||||
if (item)
|
||||
return getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 80,
|
||||
width: 300,
|
||||
});
|
||||
return null;
|
||||
}, [api, item, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTv) return;
|
||||
if (disabled) return;
|
||||
if (source?.uri) {
|
||||
const _primary = storage.getString(`${source.uri}-primary`);
|
||||
const _text = storage.getString(`${source.uri}-text`);
|
||||
|
||||
if (_primary && _text) {
|
||||
setPrimaryColor({
|
||||
primary: _primary,
|
||||
text: _text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract colors from the image
|
||||
if (!ImageColors?.getColors) return;
|
||||
|
||||
ImageColors.getColors(source.uri, {
|
||||
fallback: "#fff",
|
||||
cache: false,
|
||||
})
|
||||
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||
let primary = "#fff";
|
||||
let text = "#000";
|
||||
let backup = "#fff";
|
||||
|
||||
// Select the appropriate color based on the platform
|
||||
if (colors.platform === "android") {
|
||||
primary = colors.dominant;
|
||||
backup = colors.vibrant;
|
||||
} else if (colors.platform === "ios") {
|
||||
primary = colors.detail;
|
||||
backup = colors.primary;
|
||||
}
|
||||
|
||||
// Adjust the primary color if it's too close to black
|
||||
if (primary && isCloseToBlack(primary)) {
|
||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||
primary = adjustToNearBlack(primary);
|
||||
}
|
||||
|
||||
// Calculate the text color based on the primary color
|
||||
if (primary) text = calculateTextColor(primary);
|
||||
|
||||
setPrimaryColor({
|
||||
primary,
|
||||
text,
|
||||
});
|
||||
|
||||
// Cache the colors in storage
|
||||
if (source.uri && primary) {
|
||||
storage.set(`${source.uri}-primary`, primary);
|
||||
storage.set(`${source.uri}-text`, text);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("Error getting colors", error);
|
||||
});
|
||||
}
|
||||
}, [isTv, source?.uri, setPrimaryColor, disabled]);
|
||||
|
||||
if (isTv) return;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import axios, { type AxiosError, type AxiosInstance } from "axios";
|
||||
import { atom } from "jotai";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { inRange } from "lodash";
|
||||
import type { User as SeerrUser } from "@/utils/jellyseerr/server/entity/User";
|
||||
import type { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
|
||||
import type {
|
||||
MovieResult,
|
||||
Results,
|
||||
@@ -62,12 +62,12 @@ interface SearchResults {
|
||||
results: Results[];
|
||||
}
|
||||
|
||||
const SEERR_USER = "SEERR_USER";
|
||||
const SEERR_COOKIES = "SEERR_COOKIES";
|
||||
const JELLYSEERR_USER = "JELLYSEERR_USER";
|
||||
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
|
||||
|
||||
export const clearSeerrStorageData = () => {
|
||||
storage.remove(SEERR_USER);
|
||||
storage.remove(SEERR_COOKIES);
|
||||
export const clearJellyseerrStorageData = () => {
|
||||
storage.remove(JELLYSEERR_USER);
|
||||
storage.remove(JELLYSEERR_COOKIES);
|
||||
};
|
||||
|
||||
export enum Endpoints {
|
||||
@@ -111,27 +111,12 @@ export type TestResult =
|
||||
isValid: false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a URL by ensuring it has a protocol prefix (https:// or http://)
|
||||
* @param url - The URL to normalize
|
||||
* @returns The normalized URL with protocol prefix
|
||||
*/
|
||||
function normalizeUrl(url: string): string {
|
||||
const trimmed = url.trim().replace(/\/+$/, ""); // Remove trailing slashes
|
||||
if (trimmed.match(/^https?:\/\//i)) {
|
||||
return trimmed;
|
||||
}
|
||||
// Default to https if no protocol is specified
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
export class SeerrApi {
|
||||
export class JellyseerrApi {
|
||||
axios: AxiosInstance;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
const normalizedUrl = normalizeUrl(baseUrl);
|
||||
this.axios = axios.create({
|
||||
baseURL: normalizedUrl,
|
||||
baseURL: baseUrl,
|
||||
withCredentials: true,
|
||||
withXSRFToken: true,
|
||||
xsrfHeaderName: "XSRF-TOKEN",
|
||||
@@ -141,8 +126,8 @@ export class SeerrApi {
|
||||
}
|
||||
|
||||
async test(): Promise<TestResult> {
|
||||
const user = storage.get<SeerrUser>(SEERR_USER);
|
||||
const cookies = storage.get<string[]>(SEERR_COOKIES);
|
||||
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
|
||||
if (user && cookies) {
|
||||
return Promise.resolve({
|
||||
@@ -157,13 +142,15 @@ export class SeerrApi {
|
||||
const { status, headers, data } = response;
|
||||
if (inRange(status, 200, 299)) {
|
||||
if (data.version < "2.0.0") {
|
||||
const error = t("seerr.toasts.seer_does_not_meet_requirements");
|
||||
const error = t(
|
||||
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
||||
);
|
||||
toast.error(error);
|
||||
throw Error(error);
|
||||
}
|
||||
|
||||
storage.setAny(
|
||||
SEERR_COOKIES,
|
||||
JELLYSEERR_COOKIES,
|
||||
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? [],
|
||||
);
|
||||
return {
|
||||
@@ -171,9 +158,9 @@ export class SeerrApi {
|
||||
requiresPass: true,
|
||||
};
|
||||
}
|
||||
toast.error(t("seerr.toasts.seerr_test_failed"));
|
||||
toast.error(t("jellyseerr.toasts.jellyseerr_test_failed"));
|
||||
writeErrorLog(
|
||||
`Seerr returned a ${status} for url:\n${response.config.url}`,
|
||||
`Jellyseerr returned a ${status} for url:\n${response.config.url}`,
|
||||
response.data,
|
||||
);
|
||||
return {
|
||||
@@ -182,7 +169,7 @@ export class SeerrApi {
|
||||
};
|
||||
})
|
||||
.catch((e) => {
|
||||
const msg = t("seerr.toasts.failed_to_test_seerr_server_url");
|
||||
const msg = t("jellyseerr.toasts.failed_to_test_jellyseerr_server_url");
|
||||
toast.error(msg);
|
||||
console.error(msg, e);
|
||||
return {
|
||||
@@ -192,9 +179,9 @@ export class SeerrApi {
|
||||
});
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<SeerrUser> {
|
||||
async login(username: string, password: string): Promise<JellyseerrUser> {
|
||||
return this.axios
|
||||
?.post<SeerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
|
||||
?.post<JellyseerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
|
||||
username,
|
||||
password,
|
||||
email: username,
|
||||
@@ -202,7 +189,7 @@ export class SeerrApi {
|
||||
.then((response) => {
|
||||
const user = response?.data;
|
||||
if (!user) throw Error("Login failed");
|
||||
storage.setAny(SEERR_USER, user);
|
||||
storage.setAny(JELLYSEERR_USER, user);
|
||||
return user;
|
||||
});
|
||||
}
|
||||
@@ -377,7 +364,7 @@ export class SeerrApi {
|
||||
const issue = response.data;
|
||||
|
||||
if (issue.status === IssueStatus.OPEN) {
|
||||
toast.success(t("seerr.toasts.issue_submitted"));
|
||||
toast.success(t("jellyseerr.toasts.issue_submitted"));
|
||||
}
|
||||
return issue;
|
||||
});
|
||||
@@ -405,7 +392,7 @@ export class SeerrApi {
|
||||
const cookies = response.headers["set-cookie"];
|
||||
if (cookies) {
|
||||
storage.setAny(
|
||||
SEERR_COOKIES,
|
||||
JELLYSEERR_COOKIES,
|
||||
response.headers["set-cookie"]?.flatMap((c) => c.split("; ")),
|
||||
);
|
||||
}
|
||||
@@ -413,11 +400,11 @@ export class SeerrApi {
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
writeErrorLog(
|
||||
`Seerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
|
||||
`Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
|
||||
error.response?.data,
|
||||
);
|
||||
if (error.response?.status === 403) {
|
||||
clearSeerrStorageData();
|
||||
clearJellyseerrStorageData();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
@@ -425,7 +412,7 @@ export class SeerrApi {
|
||||
|
||||
this.axios.interceptors.request.use(
|
||||
async (config) => {
|
||||
const cookies = storage.get<string[]>(SEERR_COOKIES);
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
if (cookies) {
|
||||
const headerName = this.axios.defaults.xsrfHeaderName!;
|
||||
const xsrfToken = cookies
|
||||
@@ -438,77 +425,78 @@ export class SeerrApi {
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error("Seerr request error", error);
|
||||
return Promise.reject(error);
|
||||
console.error("Jellyseerr request error", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const seerrUserAtom = atom(storage.get<SeerrUser>(SEERR_USER));
|
||||
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
|
||||
|
||||
export const useSeerr = () => {
|
||||
export const useJellyseerr = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const [seerrUser, setSeerrUser] = useAtom(seerrUserAtom);
|
||||
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const seerrApi = useMemo(() => {
|
||||
const cookies = storage.get<string[]>(SEERR_COOKIES);
|
||||
if (settings?.seerrServerUrl && cookies && seerrUser) {
|
||||
return new SeerrApi(settings?.seerrServerUrl);
|
||||
const jellyseerrApi = useMemo(() => {
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) {
|
||||
return new JellyseerrApi(settings?.jellyseerrServerUrl);
|
||||
}
|
||||
return undefined;
|
||||
}, [settings?.seerrServerUrl, seerrUser]);
|
||||
}, [settings?.jellyseerrServerUrl, jellyseerrUser]);
|
||||
|
||||
const clearAllSeerrData = useCallback(async () => {
|
||||
clearSeerrStorageData();
|
||||
setSeerrUser(undefined);
|
||||
updateSettings({ seerrServerUrl: undefined });
|
||||
}, [setSeerrUser, updateSettings]);
|
||||
const clearAllJellyseerData = useCallback(async () => {
|
||||
clearJellyseerrStorageData();
|
||||
setJellyseerrUser(undefined);
|
||||
updateSettings({ jellyseerrServerUrl: undefined });
|
||||
}, []);
|
||||
|
||||
const requestMedia = useCallback(
|
||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||
seerrApi?.request?.(request)?.then(async (mediaRequest) => {
|
||||
jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["search", "seerr"],
|
||||
queryKey: ["search", "jellyseerr"],
|
||||
});
|
||||
|
||||
switch (mediaRequest.status) {
|
||||
case MediaRequestStatus.PENDING:
|
||||
case MediaRequestStatus.APPROVED:
|
||||
toast.success(t("seerr.toasts.requested_item", { item: title }));
|
||||
toast.success(
|
||||
t("jellyseerr.toasts.requested_item", { item: title }),
|
||||
);
|
||||
onSuccess?.();
|
||||
break;
|
||||
case MediaRequestStatus.DECLINED:
|
||||
toast.error(t("seerr.toasts.you_dont_have_permission_to_request"));
|
||||
toast.error(
|
||||
t("jellyseerr.toasts.you_dont_have_permission_to_request"),
|
||||
);
|
||||
break;
|
||||
case MediaRequestStatus.FAILED:
|
||||
toast.error(
|
||||
t("seerr.toasts.something_went_wrong_requesting_media"),
|
||||
t("jellyseerr.toasts.something_went_wrong_requesting_media"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
[seerrApi, queryClient],
|
||||
[jellyseerrApi],
|
||||
);
|
||||
|
||||
const isSeerrMovieOrTvResult = useCallback(
|
||||
(items: any | null | undefined): items is MovieResult | TvResult => {
|
||||
return (
|
||||
items &&
|
||||
Object.hasOwn(items, "mediaType") &&
|
||||
(items.mediaType === MediaType.MOVIE ||
|
||||
items.mediaType === MediaType.TV)
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const isJellyseerrMovieOrTvResult = (
|
||||
items: any | null | undefined,
|
||||
): items is MovieResult | TvResult => {
|
||||
return (
|
||||
items &&
|
||||
Object.hasOwn(items, "mediaType") &&
|
||||
(items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
|
||||
);
|
||||
};
|
||||
|
||||
const getTitle = (
|
||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||
) => {
|
||||
return isSeerrMovieOrTvResult(item)
|
||||
return isJellyseerrMovieOrTvResult(item)
|
||||
? item.mediaType === MediaType.MOVIE
|
||||
? item?.title
|
||||
: item?.name
|
||||
@@ -521,7 +509,7 @@ export const useSeerr = () => {
|
||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||
) => {
|
||||
return new Date(
|
||||
(isSeerrMovieOrTvResult(item)
|
||||
(isJellyseerrMovieOrTvResult(item)
|
||||
? item.mediaType === MediaType.MOVIE
|
||||
? item?.releaseDate
|
||||
: item?.firstAirDate
|
||||
@@ -534,35 +522,32 @@ export const useSeerr = () => {
|
||||
const getMediaType = (
|
||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||
): MediaType => {
|
||||
return isSeerrMovieOrTvResult(item)
|
||||
return isJellyseerrMovieOrTvResult(item)
|
||||
? (item.mediaType as MediaType)
|
||||
: item?.mediaInfo?.mediaType;
|
||||
};
|
||||
|
||||
const seerrRegion = useMemo(
|
||||
const jellyseerrRegion = useMemo(
|
||||
// streamingRegion and discoverRegion exists. region doesn't
|
||||
() => seerrUser?.settings?.discoverRegion || "US",
|
||||
[seerrUser],
|
||||
() => jellyseerrUser?.settings?.discoverRegion || "US",
|
||||
[jellyseerrUser],
|
||||
);
|
||||
|
||||
const seerrLocale = useMemo(() => {
|
||||
const locale = seerrUser?.settings?.locale || "en";
|
||||
// Use regex to check if locale already contains region code (e.g., zh-CN, pt-BR)
|
||||
// If not, append the region to create a valid BCP 47 locale string
|
||||
return /^[a-z]{2,3}-/i.test(locale) ? locale : `${locale}-${seerrRegion}`;
|
||||
}, [seerrUser, seerrRegion]);
|
||||
const jellyseerrLocale = useMemo(() => {
|
||||
return jellyseerrUser?.settings?.locale || "en";
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
return {
|
||||
seerrApi,
|
||||
seerrUser,
|
||||
setSeerrUser,
|
||||
clearAllSeerrData,
|
||||
isSeerrMovieOrTvResult,
|
||||
jellyseerrApi,
|
||||
jellyseerrUser,
|
||||
setJellyseerrUser,
|
||||
clearAllJellyseerData,
|
||||
isJellyseerrMovieOrTvResult,
|
||||
getTitle,
|
||||
getYear,
|
||||
getMediaType,
|
||||
seerrRegion,
|
||||
seerrLocale,
|
||||
jellyseerrRegion,
|
||||
jellyseerrLocale,
|
||||
requestMedia,
|
||||
};
|
||||
};
|
||||
@@ -43,6 +43,12 @@ class MpvPlayerModule : Module() {
|
||||
view.loadVideo(config)
|
||||
}
|
||||
|
||||
// Now Playing metadata for media controls (iOS-only, no-op on Android)
|
||||
// Android handles media session differently via MediaSessionCompat
|
||||
Prop("nowPlayingMetadata") { _: MpvPlayerView, _: Map<String, String>? ->
|
||||
// No-op on Android - media session integration would require MediaSessionCompat
|
||||
}
|
||||
|
||||
// Async function to play video
|
||||
AsyncFunction("play") { view: MpvPlayerView ->
|
||||
view.play()
|
||||
|
||||
@@ -10,6 +10,7 @@ protocol MPVLayerRendererDelegate: AnyObject {
|
||||
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String)
|
||||
}
|
||||
|
||||
/// MPV player using vo_avfoundation for video output.
|
||||
@@ -347,7 +348,8 @@ final class MPVLayerRenderer {
|
||||
("pause", MPV_FORMAT_FLAG),
|
||||
("track-list/count", MPV_FORMAT_INT64),
|
||||
("paused-for-cache", MPV_FORMAT_FLAG),
|
||||
("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||
("demuxer-cache-duration", MPV_FORMAT_DOUBLE),
|
||||
("current-ao", MPV_FORMAT_STRING)
|
||||
]
|
||||
for (name, format) in properties {
|
||||
mpv_observe_property(handle, 0, name, format)
|
||||
@@ -552,6 +554,15 @@ final class MPVLayerRenderer {
|
||||
self.delegate?.renderer(self, didBecomeTracksReady: true)
|
||||
}
|
||||
}
|
||||
case "current-ao":
|
||||
// Audio output is now active - notify delegate
|
||||
if let aoName = getStringProperty(handle: handle, name: name) {
|
||||
print("[MPV] 🔊 Audio output selected: \(aoName)")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didSelectAudioOutput: aoName)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
188
modules/mpv-player/ios/MPVNowPlayingManager.swift
Normal file
188
modules/mpv-player/ios/MPVNowPlayingManager.swift
Normal file
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
/// Simple manager for Now Playing info and remote commands.
|
||||
/// Stores all state internally and updates Now Playing when ready.
|
||||
class MPVNowPlayingManager {
|
||||
static let shared = MPVNowPlayingManager()
|
||||
|
||||
// State
|
||||
private var title: String?
|
||||
private var artist: String?
|
||||
private var albumTitle: String?
|
||||
private var cachedArtwork: MPMediaItemArtwork?
|
||||
private var duration: TimeInterval = 0
|
||||
private var position: TimeInterval = 0
|
||||
private var isPlaying: Bool = false
|
||||
private var isCommandsSetup = false
|
||||
|
||||
private var artworkTask: URLSessionDataTask?
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Audio Session
|
||||
|
||||
func activateAudioSession() {
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .moviePlayback)
|
||||
try session.setActive(true)
|
||||
print("[NowPlaying] Audio session activated")
|
||||
} catch {
|
||||
print("[NowPlaying] Audio session error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func deactivateAudioSession() {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
print("[NowPlaying] Audio session deactivated")
|
||||
} catch {
|
||||
print("[NowPlaying] Deactivation error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Remote Commands
|
||||
|
||||
func setupRemoteCommands(
|
||||
playHandler: @escaping () -> Void,
|
||||
pauseHandler: @escaping () -> Void,
|
||||
toggleHandler: @escaping () -> Void,
|
||||
seekHandler: @escaping (TimeInterval) -> Void,
|
||||
skipForward: @escaping (TimeInterval) -> Void,
|
||||
skipBackward: @escaping (TimeInterval) -> Void
|
||||
) {
|
||||
guard !isCommandsSetup else { return }
|
||||
isCommandsSetup = true
|
||||
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
}
|
||||
|
||||
let cc = MPRemoteCommandCenter.shared()
|
||||
|
||||
cc.playCommand.isEnabled = true
|
||||
cc.playCommand.addTarget { _ in playHandler(); return .success }
|
||||
|
||||
cc.pauseCommand.isEnabled = true
|
||||
cc.pauseCommand.addTarget { _ in pauseHandler(); return .success }
|
||||
|
||||
cc.togglePlayPauseCommand.isEnabled = true
|
||||
cc.togglePlayPauseCommand.addTarget { _ in toggleHandler(); return .success }
|
||||
|
||||
cc.skipForwardCommand.isEnabled = true
|
||||
cc.skipForwardCommand.preferredIntervals = [15]
|
||||
cc.skipForwardCommand.addTarget { e in
|
||||
if let ev = e as? MPSkipIntervalCommandEvent { skipForward(ev.interval) }
|
||||
return .success
|
||||
}
|
||||
|
||||
cc.skipBackwardCommand.isEnabled = true
|
||||
cc.skipBackwardCommand.preferredIntervals = [15]
|
||||
cc.skipBackwardCommand.addTarget { e in
|
||||
if let ev = e as? MPSkipIntervalCommandEvent { skipBackward(ev.interval) }
|
||||
return .success
|
||||
}
|
||||
|
||||
cc.changePlaybackPositionCommand.isEnabled = true
|
||||
cc.changePlaybackPositionCommand.addTarget { e in
|
||||
if let ev = e as? MPChangePlaybackPositionCommandEvent { seekHandler(ev.positionTime) }
|
||||
return .success
|
||||
}
|
||||
|
||||
print("[NowPlaying] Remote commands ready")
|
||||
}
|
||||
|
||||
func cleanupRemoteCommands() {
|
||||
guard isCommandsSetup else { return }
|
||||
|
||||
let cc = MPRemoteCommandCenter.shared()
|
||||
cc.playCommand.removeTarget(nil)
|
||||
cc.pauseCommand.removeTarget(nil)
|
||||
cc.togglePlayPauseCommand.removeTarget(nil)
|
||||
cc.skipForwardCommand.removeTarget(nil)
|
||||
cc.skipBackwardCommand.removeTarget(nil)
|
||||
cc.changePlaybackPositionCommand.removeTarget(nil)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.endReceivingRemoteControlEvents()
|
||||
}
|
||||
|
||||
isCommandsSetup = false
|
||||
print("[NowPlaying] Remote commands cleaned up")
|
||||
}
|
||||
|
||||
// MARK: - State Updates (call these whenever data changes)
|
||||
|
||||
/// Set metadata (title, artist, artwork URL)
|
||||
func setMetadata(title: String?, artist: String?, albumTitle: String?, artworkUrl: String?) {
|
||||
self.title = title
|
||||
self.artist = artist
|
||||
self.albumTitle = albumTitle
|
||||
|
||||
print("[NowPlaying] Metadata: \(title ?? "nil")")
|
||||
|
||||
// Load artwork async
|
||||
artworkTask?.cancel()
|
||||
if let urlString = artworkUrl, let url = URL(string: urlString) {
|
||||
artworkTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||
if let data = data, let image = UIImage(data: data) {
|
||||
self?.cachedArtwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||
print("[NowPlaying] Artwork loaded")
|
||||
DispatchQueue.main.async { self?.refresh() }
|
||||
}
|
||||
}
|
||||
artworkTask?.resume()
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
/// Update playback state (position, duration, playing)
|
||||
func updatePlayback(position: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
|
||||
self.position = position
|
||||
self.duration = duration
|
||||
self.isPlaying = isPlaying
|
||||
refresh()
|
||||
}
|
||||
|
||||
/// Clear everything
|
||||
func clear() {
|
||||
artworkTask?.cancel()
|
||||
title = nil
|
||||
artist = nil
|
||||
albumTitle = nil
|
||||
cachedArtwork = nil
|
||||
duration = 0
|
||||
position = 0
|
||||
isPlaying = false
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
print("[NowPlaying] Cleared")
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Refresh Now Playing info if we have enough data
|
||||
private func refresh() {
|
||||
guard duration > 0 else {
|
||||
print("[NowPlaying] refresh skipped - duration is 0")
|
||||
return
|
||||
}
|
||||
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyPlaybackDuration: duration,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: position,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0
|
||||
]
|
||||
|
||||
if let title { info[MPMediaItemPropertyTitle] = title }
|
||||
if let artist { info[MPMediaItemPropertyArtist] = artist }
|
||||
if let albumTitle { info[MPMediaItemPropertyAlbumTitle] = albumTitle }
|
||||
if let cachedArtwork { info[MPMediaItemPropertyArtwork] = cachedArtwork }
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
print("[NowPlaying] ✅ Set info: title=\(title ?? "nil"), dur=\(Int(duration))s, pos=\(Int(position))s, rate=\(isPlaying ? 1.0 : 0.0)")
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,21 @@ public class MpvPlayerModule: Module {
|
||||
view.loadVideo(config: config)
|
||||
}
|
||||
|
||||
// Now Playing metadata for iOS Control Center and Lock Screen
|
||||
Prop("nowPlayingMetadata") { (view: MpvPlayerView, metadata: [String: Any]?) in
|
||||
guard let metadata = metadata else { return }
|
||||
// Convert Any values to String, filtering out nil/null values
|
||||
var stringMetadata: [String: String] = [:]
|
||||
for (key, value) in metadata {
|
||||
if let stringValue = value as? String {
|
||||
stringMetadata[key] = stringValue
|
||||
}
|
||||
}
|
||||
if !stringMetadata.isEmpty {
|
||||
view.setNowPlayingMetadata(stringMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Async function to play video
|
||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||
view.play()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import ExpoModulesCore
|
||||
import MediaPlayer
|
||||
import UIKit
|
||||
|
||||
/// Configuration for loading a video
|
||||
@@ -41,7 +42,6 @@ class MpvPlayerView: ExpoView {
|
||||
private var renderer: MPVLayerRenderer?
|
||||
private var videoContainer: UIView!
|
||||
private var pipController: PiPController?
|
||||
|
||||
let onLoad = EventDispatcher()
|
||||
let onPlaybackStateChange = EventDispatcher()
|
||||
let onProgress = EventDispatcher()
|
||||
@@ -53,11 +53,14 @@ class MpvPlayerView: ExpoView {
|
||||
private var cachedDuration: Double = 0
|
||||
private var intendedPlayState: Bool = false
|
||||
private var _isZoomedToFill: Bool = false
|
||||
|
||||
// Reference to now playing manager
|
||||
private let nowPlayingManager = MPVNowPlayingManager.shared
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupNotifications()
|
||||
setupView()
|
||||
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
|
||||
}
|
||||
|
||||
private func setupView() {
|
||||
@@ -109,6 +112,77 @@ class MpvPlayerView: ExpoView {
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
// MARK: - Audio Session & Notifications
|
||||
|
||||
private func setupNotifications() {
|
||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleAudioSessionInterruption),
|
||||
name: AVAudioSession.interruptionNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
||||
return
|
||||
}
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
// Interruption began - pause the video
|
||||
print("[MPV] Audio session interrupted - pausing video")
|
||||
self.pause()
|
||||
|
||||
case .ended:
|
||||
// Interruption ended - check if we should resume
|
||||
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||
if options.contains(.shouldResume) {
|
||||
print("[MPV] Audio session interruption ended - can resume")
|
||||
// Don't auto-resume - let user manually resume playback
|
||||
} else {
|
||||
print("[MPV] Audio session interruption ended - should not resume")
|
||||
}
|
||||
}
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func setupRemoteCommands() {
|
||||
nowPlayingManager.setupRemoteCommands(
|
||||
playHandler: { [weak self] in self?.play() },
|
||||
pauseHandler: { [weak self] in self?.pause() },
|
||||
toggleHandler: { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.intendedPlayState { self.pause() } else { self.play() }
|
||||
},
|
||||
seekHandler: { [weak self] time in self?.seekTo(position: time) },
|
||||
skipForward: { [weak self] interval in self?.seekBy(offset: interval) },
|
||||
skipBackward: { [weak self] interval in self?.seekBy(offset: -interval) }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Now Playing Info
|
||||
|
||||
func setNowPlayingMetadata(_ metadata: [String: String]) {
|
||||
print("[MPV] setNowPlayingMetadata: \(metadata["title"] ?? "nil")")
|
||||
nowPlayingManager.setMetadata(
|
||||
title: metadata["title"],
|
||||
artist: metadata["artist"],
|
||||
albumTitle: metadata["albumTitle"],
|
||||
artworkUrl: metadata["artworkUri"]
|
||||
)
|
||||
}
|
||||
|
||||
private func clearNowPlayingInfo() {
|
||||
nowPlayingManager.cleanupRemoteCommands()
|
||||
nowPlayingManager.deactivateAudioSession()
|
||||
nowPlayingManager.clear()
|
||||
}
|
||||
|
||||
func loadVideo(config: VideoLoadConfig) {
|
||||
// Skip reload if same URL is already playing
|
||||
if currentURL == config.url {
|
||||
@@ -149,6 +223,7 @@ class MpvPlayerView: ExpoView {
|
||||
|
||||
func play() {
|
||||
intendedPlayState = true
|
||||
setupRemoteCommands()
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
pipController?.updatePlaybackState()
|
||||
@@ -162,10 +237,17 @@ class MpvPlayerView: ExpoView {
|
||||
}
|
||||
|
||||
func seekTo(position: Double) {
|
||||
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||
cachedPosition = position
|
||||
syncNowPlaying(isPlaying: !isPaused())
|
||||
renderer?.seek(to: position)
|
||||
}
|
||||
|
||||
func seekBy(offset: Double) {
|
||||
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||
let newPosition = max(0, min(cachedPosition + offset, cachedDuration))
|
||||
cachedPosition = newPosition
|
||||
syncNowPlaying(isPlaying: !isPaused())
|
||||
renderer?.seek(by: offset)
|
||||
}
|
||||
|
||||
@@ -292,23 +374,32 @@ class MpvPlayerView: ExpoView {
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
displayLayer.removeFromSuperlayer()
|
||||
clearNowPlayingInfo()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MPVLayerRendererDelegate
|
||||
|
||||
extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||
|
||||
// MARK: - Single location for Now Playing updates
|
||||
private func syncNowPlaying(isPlaying: Bool) {
|
||||
print("[MPV] syncNowPlaying: pos=\(Int(cachedPosition))s, dur=\(Int(cachedDuration))s, playing=\(isPlaying)")
|
||||
nowPlayingManager.updatePlayback(position: cachedPosition, duration: cachedDuration, isPlaying: isPlaying)
|
||||
}
|
||||
|
||||
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
|
||||
cachedPosition = position
|
||||
cachedDuration = duration
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
// Update PiP current time for progress bar
|
||||
|
||||
if self.pipController?.isPictureInPictureActive == true {
|
||||
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
|
||||
}
|
||||
|
||||
|
||||
self.onProgress([
|
||||
"position": position,
|
||||
"duration": duration,
|
||||
@@ -321,12 +412,10 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
// Don't update intendedPlayState here - it's only set by user actions (play/pause)
|
||||
// This prevents PiP UI flicker during seeking
|
||||
|
||||
// Sync timebase rate with actual playback state
|
||||
print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)")
|
||||
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
|
||||
|
||||
self.syncNowPlaying(isPlaying: !isPaused)
|
||||
self.onPlaybackStateChange([
|
||||
"isPaused": isPaused,
|
||||
"isPlaying": !isPaused,
|
||||
@@ -358,6 +447,13 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||
self.onTracksReady([:])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
||||
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
||||
nowPlayingManager.activateAudioSession()
|
||||
syncNowPlaying(isPlaying: !isPaused())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PiPControllerDelegate
|
||||
|
||||
@@ -25,6 +25,13 @@ export type OnErrorEventPayload = {
|
||||
|
||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||
|
||||
export type NowPlayingMetadata = {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
albumTitle?: string;
|
||||
artworkUri?: string;
|
||||
};
|
||||
|
||||
export type MpvPlayerModuleEvents = {
|
||||
onChange: (params: ChangeEventPayload) => void;
|
||||
};
|
||||
@@ -48,6 +55,8 @@ export type VideoSource = {
|
||||
export type MpvPlayerViewProps = {
|
||||
source?: VideoSource;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Metadata for iOS Control Center and Lock Screen now playing info */
|
||||
nowPlayingMetadata?: NowPlayingMetadata;
|
||||
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
|
||||
onPlaybackStateChange?: (event: {
|
||||
nativeEvent: OnPlaybackStateChangePayload;
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MpvPlayerViewProps } from "./MpvPlayer.types";
|
||||
|
||||
export default function MpvPlayerView(props: MpvPlayerViewProps) {
|
||||
const url = props.source?.url ?? "";
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<iframe
|
||||
title={t("player.mpv_player_title")}
|
||||
title='MPV Player'
|
||||
style={{ flex: 1 }}
|
||||
src={url}
|
||||
onLoad={() => props.onLoad?.({ nativeEvent: { url } })}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||
"@tanstack/react-pacer": "^0.19.1",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"@tanstack/react-query": "5.90.20",
|
||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "~54.0.31",
|
||||
@@ -72,12 +72,12 @@
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"i18next": "^25.0.0",
|
||||
"jotai": "2.16.2",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.17.23",
|
||||
"nativewind": "^2.0.11",
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "16.5.3",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "1.1.0",
|
||||
@@ -117,7 +117,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.6",
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@react-native-community/cli": "20.1.0",
|
||||
"@react-native-community/cli": "20.1.1",
|
||||
"@react-native-tvos/config-tv": "0.1.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.23",
|
||||
|
||||
@@ -23,7 +23,7 @@ import { getDeviceName } from "react-native-device-info";
|
||||
import uuid from "react-native-uuid";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { SeerrApi, useSeerr } from "@/hooks/useSeerr";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
@@ -113,7 +113,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [isPolling, setIsPolling] = useState<boolean>(false);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
|
||||
const { clearAllSeerrData, setSeerrUser } = useSeerr();
|
||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||
|
||||
const headers = useMemo(() => {
|
||||
if (!deviceId) return {};
|
||||
@@ -290,13 +290,13 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
|
||||
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
||||
if (recentPluginSettings?.seerrServerUrl?.value) {
|
||||
const seerrApi = new SeerrApi(
|
||||
recentPluginSettings.seerrServerUrl.value,
|
||||
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
||||
const jellyseerrApi = new JellyseerrApi(
|
||||
recentPluginSettings.jellyseerrServerUrl.value,
|
||||
);
|
||||
await seerrApi.test().then((result) => {
|
||||
await jellyseerrApi.test().then((result) => {
|
||||
if (result.isValid && result.requiresPass) {
|
||||
seerrApi.login(username, password).then(setSeerrUser);
|
||||
jellyseerrApi.login(username, password).then(setJellyseerrUser);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -349,7 +349,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setUser(null);
|
||||
setApi(null);
|
||||
setPluginSettings(undefined);
|
||||
await clearAllSeerrData();
|
||||
await clearAllJellyseerData();
|
||||
// Note: We keep saved credentials for quick switching back
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Check for unused translation keys in en.json
|
||||
* Usage: bun run scripts/check-unused-translations.js [--remove]
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
const TRANSLATION_FILE = path.join(__dirname, "../translations/en.json");
|
||||
const REMOVE_UNUSED = process.argv.includes("--remove");
|
||||
|
||||
// Read translation file
|
||||
const translations = JSON.parse(fs.readFileSync(TRANSLATION_FILE, "utf8"));
|
||||
|
||||
// Flatten nested keys
|
||||
function flattenKeys(obj, prefix = "") {
|
||||
let keys = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
keys = keys.concat(flattenKeys(value, fullKey));
|
||||
} else {
|
||||
keys.push(fullKey);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Search for key usage in codebase
|
||||
function isKeyUsed(key) {
|
||||
try {
|
||||
// Escape special regex characters in the key
|
||||
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
// Search in TypeScript/TSX files
|
||||
const result = execSync(
|
||||
`git grep -l "${escapedKey}" -- "*.ts" "*.tsx" 2>nul || echo ""`,
|
||||
{
|
||||
encoding: "utf8",
|
||||
cwd: path.join(__dirname, ".."),
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
).trim();
|
||||
|
||||
return result.length > 0;
|
||||
} catch (_error) {
|
||||
// If grep fails, assume key is used to be safe
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove nested key from object
|
||||
function removeNestedKey(obj, keyPath) {
|
||||
const keys = keyPath.split(".");
|
||||
const lastKey = keys.pop();
|
||||
let current = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!current[key]) return false;
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
if (current[lastKey] !== undefined) {
|
||||
delete current[lastKey];
|
||||
|
||||
// Clean up empty parent objects
|
||||
if (Object.keys(current).length === 0 && keys.length > 0) {
|
||||
removeNestedKey(obj, keys.join("."));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("🔍 Checking for unused translation keys...\n");
|
||||
|
||||
const allKeys = flattenKeys(translations);
|
||||
const unusedKeys = [];
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (!isKeyUsed(key)) {
|
||||
unusedKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (unusedKeys.length === 0) {
|
||||
console.log("✅ All translation keys are being used!");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${unusedKeys.length} unused translation keys:\n`);
|
||||
for (const key of unusedKeys) {
|
||||
console.log(` ❌ ${key}`);
|
||||
}
|
||||
|
||||
if (REMOVE_UNUSED) {
|
||||
console.log("\n🗑️ Removing unused keys...");
|
||||
|
||||
let removed = 0;
|
||||
for (const key of unusedKeys) {
|
||||
if (removeNestedKey(translations, key)) {
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
fs.writeFileSync(
|
||||
TRANSLATION_FILE,
|
||||
`${JSON.stringify(translations, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
console.log(`✅ Removed ${removed} unused translation keys from en.json`);
|
||||
} else {
|
||||
console.log("\n💡 Run with --remove flag to remove these keys from en.json");
|
||||
console.log(
|
||||
" Example: bun run scripts/check-unused-translations.js --remove",
|
||||
);
|
||||
}
|
||||
@@ -176,7 +176,7 @@ function runTypeCheck() {
|
||||
} catch (error) {
|
||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
||||
|
||||
// Filter out seerr utils errors - this is a third-party git submodule
|
||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||
// that generates a large volume of known type errors
|
||||
const filteredLines = errorOutput.split("\n").filter((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
@@ -227,7 +227,7 @@ function runTypeCheck() {
|
||||
}
|
||||
|
||||
log(
|
||||
`✅ ${colors.bold}TypeScript check passed${colors.reset} ${colors.gray}(seerr utils errors ignored)${colors.reset}`,
|
||||
`✅ ${colors.bold}TypeScript check passed${colors.reset} ${colors.gray}(jellyseerr utils errors ignored)${colors.reset}`,
|
||||
colors.green,
|
||||
);
|
||||
return { ok: true };
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"username_placeholder": "Benutzername",
|
||||
"password_placeholder": "Passwort",
|
||||
"login_button": "Anmelden",
|
||||
"quick_connect": "Schnellverbindung",
|
||||
"quick_connect": "Quick Connect",
|
||||
"enter_code_to_login": "Gib den Code {{code}} ein, um dich anzumelden",
|
||||
"failed_to_initiate_quick_connect": "Fehler beim Initiieren der Schnellverbindung",
|
||||
"got_it": "Verstanden",
|
||||
@@ -30,48 +30,48 @@
|
||||
"connect_button": "Verbinden",
|
||||
"previous_servers": "Vorherige Server",
|
||||
"clear_button": "Löschen",
|
||||
"swipe_to_remove": "Swipe to remove",
|
||||
"swipe_to_remove": "Wischen, um zu entfernen",
|
||||
"search_for_local_servers": "Nach lokalen Servern suchen",
|
||||
"searching": "Suche...",
|
||||
"servers": "Server",
|
||||
"saved": "Saved",
|
||||
"session_expired": "Session Expired",
|
||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||
"remove_saved_login": "Remove Saved Login",
|
||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
"saved": "Gespeichert",
|
||||
"session_expired": "Sitzung abgelaufen",
|
||||
"please_login_again": "Ihre Sitzung ist abgelaufen. Bitte erneut anmelden.",
|
||||
"remove_saved_login": "Gespeicherte Zugangsdaten entfernen",
|
||||
"remove_saved_login_description": "Hiermit werden ihre gespeicherten Zugangsdaten für diesen Server entfernt. Sie müssen sich dann erneut anmelden.",
|
||||
"accounts_count": "{{count}} Konten",
|
||||
"select_account": "Konto auswählen",
|
||||
"add_account": "Konto hinzufügen",
|
||||
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
"title": "Konto speichern",
|
||||
"save_for_later": "Dieses Konto speichern",
|
||||
"security_option": "Sicherheitseinstellung",
|
||||
"no_protection": "Keine",
|
||||
"no_protection_desc": "Schnellanmeldung ohne Authentifizierung",
|
||||
"pin_code": "PIN",
|
||||
"pin_code_desc": "4-stellige PIN bei Konto-Wechsel erforderlich",
|
||||
"password": "Passwort wiederholen",
|
||||
"password_desc": "Passwort bei Konto-Wechsel erforderlich",
|
||||
"save_button": "Speichern",
|
||||
"cancel_button": "Abbrechen"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
"enter_pin": "PIN eingeben",
|
||||
"enter_pin_for": "PIN für {{username}} eingeben",
|
||||
"enter_4_digits": "4 Ziffern eingeben",
|
||||
"invalid_pin": "Ungültige PIN",
|
||||
"setup_pin": "PIN festlegen",
|
||||
"confirm_pin": "PIN bestätigen",
|
||||
"pins_dont_match": "PIN stimmt nicht überein",
|
||||
"forgot_pin": "PIN vergessen?",
|
||||
"forgot_pin_desc": "Ihre gespeicherten Zugangsdaten werden entfernt"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
"enter_password": "Passwort eingeben",
|
||||
"enter_password_for": "Passwort für {{username}} eingeben",
|
||||
"invalid_password": "Ungültiges Passwort"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Überprüfe Serververbindung...",
|
||||
@@ -87,7 +87,7 @@
|
||||
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
||||
"continue_watching": "Weiterschauen",
|
||||
"next_up": "Als nächstes",
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
||||
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
||||
"suggested_movies": "Empfohlene Filme",
|
||||
"suggested_episodes": "Empfohlene Episoden",
|
||||
@@ -120,36 +120,36 @@
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aussehen",
|
||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||
"hide_remote_session_button": "Hide Remote Session Button"
|
||||
"merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren",
|
||||
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"title": "Netzwerk",
|
||||
"local_network": "Lokales Netzwerk",
|
||||
"auto_switch_enabled": "Zuhause automatisch wechseln",
|
||||
"auto_switch_description": "Im WLAN Zuhause automatisch zu lokaler URL wechseln",
|
||||
"local_url": "Lokale URL",
|
||||
"local_url_hint": "Lokale Server-URL eingeben (zB. http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"home_wifi_networks": "Private WLAN-Netze",
|
||||
"add_current_network": "{{ssid}} hinzufügen",
|
||||
"not_connected_to_wifi": "Nicht mit WLAN verbunden",
|
||||
"no_networks_configured": "Keine Netzwerke konfiguriert",
|
||||
"add_network_hint": "Füge dein privates WLAN-Netz hinzu um automatischen Wechsel zu aktivieren",
|
||||
"current_wifi": "Aktuelles WLAN-Netz",
|
||||
"using_url": "Verwendet",
|
||||
"local": "Lokale URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"not_connected": "Nicht verbunden",
|
||||
"current_server": "Aktueller Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
"active_url": "Aktive URL",
|
||||
"not_configured": "Nicht konfiguriert",
|
||||
"network_added": "Netzwerk hinzugefügt",
|
||||
"network_already_added": "Netzwerk bereits hinzugefügt",
|
||||
"no_wifi_connected": "Nicht mit WLAN verbunden",
|
||||
"permission_denied": "Standortberechtigung nicht verfügbar",
|
||||
"permission_denied_explanation": "Standortberechtigung ist nötig um WLAN-Netze für den automatischen Wechsel zu erkennen. Bitte in den Einstellungen aktivieren."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Benutzerinformationen",
|
||||
@@ -159,82 +159,82 @@
|
||||
"app_version": "App-Version"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Schnellverbindung",
|
||||
"authorize_button": "Schnellverbindung autorisieren",
|
||||
"enter_the_quick_connect_code": "Gib den Schnellverbindungscode ein...",
|
||||
"success": "Erfolg",
|
||||
"quick_connect_autorized": "Schnellverbindung autorisiert",
|
||||
"quick_connect_title": "Quick Connect",
|
||||
"authorize_button": "Quick Connect autorisieren",
|
||||
"enter_the_quick_connect_code": "Quick Connect-Code eingeben...",
|
||||
"success": "Erfolgreich verbunden",
|
||||
"quick_connect_autorized": "Quick Connect autorisiert",
|
||||
"error": "Fehler",
|
||||
"invalid_code": "Ungültiger Code",
|
||||
"authorize": "Autorisieren"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Mediensteuerung",
|
||||
"forward_skip_length": "Vorspulzeit",
|
||||
"rewind_length": "Rückspulzeit",
|
||||
"forward_skip_length": "Vorspullänge",
|
||||
"rewind_length": "Rückspullänge",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Gestensteuerung",
|
||||
"horizontal_swipe_skip": "Horizontales Wischen zum Überspringen",
|
||||
"horizontal_swipe_skip_description": "Wische links/rechts, wenn Steuerelemente ausgeblendet werden um zu überspringen",
|
||||
"left_side_brightness": "Helligkeitskontrolle der linken Seite",
|
||||
"left_side_brightness_description": "Wischen Sie auf der linken Seite nach oben/runter, um die Helligkeit anzupassen",
|
||||
"right_side_volume": "Lautstärkeregelung der rechten Seite",
|
||||
"right_side_volume_description": "Auf der rechten Seite nach oben/unten wischen, um Lautstärke anzupassen",
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
"horizontal_swipe_skip": "Horizontal Wischen zum Überspringen",
|
||||
"horizontal_swipe_skip_description": "Wische links/rechts, wenn Steuerelemente ausgeblendet sind um zu überspringen",
|
||||
"left_side_brightness": "Helligkeitsregler Links",
|
||||
"left_side_brightness_description": "Links nach oben/unten wischen um Helligkeit anzupassen",
|
||||
"right_side_volume": "Lautstärkeregler Rechts",
|
||||
"right_side_volume_description": "Rechts nach oben/unten wischen um Lautstärke anzupassen",
|
||||
"hide_volume_slider": "Lautstärkeregler ausblenden",
|
||||
"hide_volume_slider_description": "Lautstärkeregler im Videoplayer ausblenden",
|
||||
"hide_brightness_slider": "Helligkeitsregler ausblenden",
|
||||
"hide_brightness_slider_description": "Helligkeitsregler im Videoplayer ausblenden"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
"set_audio_track": "Audiospur aus dem vorherigen Element festlegen",
|
||||
"set_audio_track": "Audiospur aus dem vorherigen Element übernehmen",
|
||||
"audio_language": "Audio-Sprache",
|
||||
"audio_hint": "Wähl die Standardsprache für Audio aus.",
|
||||
"audio_hint": "Standardsprache für Audio auswählen.",
|
||||
"none": "Keine",
|
||||
"language": "Sprache",
|
||||
"transcode_mode": {
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"title": "Audio-Transcoding",
|
||||
"description": "Legt fest, wie Surround-Audio (7.1, TrueHD, DTS-HD) behandelt wird",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"stereo": "Stereo erzwingen",
|
||||
"5_1": "5.1 erlauben",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Untertitel",
|
||||
"subtitle_hint": "Konfigurier die Untertitel-Präferenzen.",
|
||||
"subtitle_hint": "Untertitel-Erscheinungsbild und Verhalten konfigurieren.",
|
||||
"subtitle_language": "Untertitel-Sprache",
|
||||
"subtitle_mode": "Untertitel-Modus",
|
||||
"set_subtitle_track": "Untertitel-Spur aus dem vorherigen Element festlegen",
|
||||
"set_subtitle_track": "Untertitel-Spur aus dem vorherigen Element übernehmen",
|
||||
"subtitle_size": "Untertitel-Größe",
|
||||
"none": "Keine",
|
||||
"language": "Sprache",
|
||||
"loading": "Lädt",
|
||||
"modes": {
|
||||
"Default": "Standard",
|
||||
"Smart": "Intelligent",
|
||||
"Smart": "Smart",
|
||||
"Always": "Immer",
|
||||
"None": "Keine",
|
||||
"OnlyForced": "Nur erzwungen"
|
||||
"OnlyForced": "Nur erzwungene"
|
||||
},
|
||||
"text_color": "Textfarbe",
|
||||
"background_color": "Hintergrundfarbe",
|
||||
"outline_color": "Konturfarbe",
|
||||
"outline_thickness": "Umriss Dicke",
|
||||
"outline_thickness": "Konturdicke",
|
||||
"background_opacity": "Hintergrundtransparenz",
|
||||
"outline_opacity": "Kontur-Deckkraft",
|
||||
"bold_text": "Bold Text",
|
||||
"outline_opacity": "Konturtransparenz",
|
||||
"bold_text": "Fettgedruckter Text",
|
||||
"colors": {
|
||||
"Black": "Schwarz",
|
||||
"Gray": "Grau",
|
||||
"Silver": "Silber",
|
||||
"White": "Weiß",
|
||||
"Maroon": "Marotte",
|
||||
"Maroon": "Rotbraun",
|
||||
"Red": "Rot",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Fuchsia": "Magenta",
|
||||
"Yellow": "Gelb",
|
||||
"Olive": "Olivgrün",
|
||||
"Green": "Grün",
|
||||
@@ -251,29 +251,29 @@
|
||||
"Normal": "Normal",
|
||||
"Thick": "Dick"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"subtitle_color": "Untertitelfarbe",
|
||||
"subtitle_background_color": "Hintergrundfarbe",
|
||||
"subtitle_font": "Untertitel-Schriftart",
|
||||
"ksplayer_title": "KSPlayer Einstellungen",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
||||
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
"title": "VLC Untertitel-Einstellungen",
|
||||
"hint": "Anpassen des Untertitel-Erscheinungsbildes für VLC. Änderungen werden bei der nächsten Wiedergabe übernommen.",
|
||||
"text_color": "Schriftfarbe",
|
||||
"background_color": "Hintergrundfarbe",
|
||||
"background_opacity": "Hintergrundtransparenz",
|
||||
"outline_color": "Konturfarbe",
|
||||
"outline_opacity": "Konturtransparenz",
|
||||
"outline_thickness": "Konturdicke",
|
||||
"bold": "Fettgedruckter Text",
|
||||
"margin": "Unterer Abstand"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"title": "Videoplayer",
|
||||
"video_player": "Videoplayer",
|
||||
"video_player_description": "Videoplayer auf iOS auswählen.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
@@ -282,7 +282,7 @@
|
||||
"video_orientation": "Videoausrichtung",
|
||||
"orientation": "Ausrichtung",
|
||||
"orientations": {
|
||||
"DEFAULT": "Standard",
|
||||
"DEFAULT": "Geräteausrichtung folgen",
|
||||
"ALL": "Alle",
|
||||
"PORTRAIT": "Hochformat",
|
||||
"PORTRAIT_UP": "Hochformat oben",
|
||||
@@ -294,54 +294,54 @@
|
||||
"UNKNOWN": "Unbekannt"
|
||||
},
|
||||
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
||||
"video_player": "Video player",
|
||||
"video_player": "Videoplayer",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimentell + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
||||
"show_large_home_carousel": "Zeige Großes Heimkarussell (Beta)",
|
||||
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
|
||||
"hide_libraries": "Bibliotheken ausblenden",
|
||||
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
|
||||
"select_liraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
|
||||
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
|
||||
"default_quality": "Standardqualität",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max. automatische Wiedergabe Episodenanzahl",
|
||||
"default_playback_speed": "Standard-Wiedergabegeschwindigkeit",
|
||||
"auto_play_next_episode": "Automatisch nächste Episode abspielen",
|
||||
"max_auto_play_episode_count": "Maximale automatisch abzuspielende Episodenanzahl",
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"title": "Musik",
|
||||
"playback_title": "Wiedergabe",
|
||||
"playback_description": "Konfigurieren, wie Musik abgespielt wird.",
|
||||
"prefer_downloaded": "Bevorzuge heruntergeladene Titel",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "Tracks to Pre-cache",
|
||||
"max_cache_size": "Max Cache Size"
|
||||
"caching_description": "Automatisches Caching anstehender Titel für bessere Wiedergabe.",
|
||||
"lookahead_enabled": "Look-Ahead Caching aktivieren",
|
||||
"lookahead_count": "Titel vorher in den Cache laden",
|
||||
"max_cache_size": "Maximale Cache-Größe"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Erweiterungen",
|
||||
"plugins_title": "Plugins",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Diese integration ist in einer frühen Entwicklungsphase. Erwarte Veränderungen.",
|
||||
"server_url": "Server Adresse",
|
||||
"server_url_hint": "Beispiel: http(s)://your-host.url\n(Portnummer hinzufügen, falls erforderlich)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"jellyseerr_warning": "Diese Integration ist in einer frühen Entwicklungsphase und kann jederzeit geändert werden.",
|
||||
"server_url": "Server URL",
|
||||
"server_url_hint": "Beispiel: http(s)://your-host.url\n(Port hinzufügen, falls erforderlich)",
|
||||
"server_url_placeholder": "Seerr URL",
|
||||
"password": "Passwort",
|
||||
"password_placeholder": "Passwort für Jellyfin Benutzer {{username}} eingeben",
|
||||
"login_button": "Anmelden",
|
||||
"total_media_requests": "Gesamtanfragen",
|
||||
"movie_quota_limit": "Film-Anfragelimit",
|
||||
"movie_quota_days": "Film-Anfragetage",
|
||||
"tv_quota_limit": "TV-Anfragelimit",
|
||||
"tv_quota_days": "TV-Anfragetage",
|
||||
"reset_jellyseerr_config_button": "Setze Jellyseerr-Konfiguration zurück",
|
||||
"movie_quota_days": "Film-Anfragetagelimit",
|
||||
"tv_quota_limit": "Serien-Anfragelimit",
|
||||
"tv_quota_days": "Serien-Anfragetagelimit",
|
||||
"reset_jellyseerr_config_button": "Seerr-Konfiguration zurücksetzen",
|
||||
"unlimited": "Unlimitiert",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"plus_n_more": "+{{n}} weitere",
|
||||
"order_by": {
|
||||
"DEFAULT": "Standard",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Stimmenanzahl und Durchschnitt",
|
||||
@@ -352,71 +352,71 @@
|
||||
"enable_marlin_search": "Aktiviere Marlin Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "Gib die URL für den Marlin Server ein. Die URL sollte http oder https enthalten und optional den Port.",
|
||||
"marlin_search_hint": "URL für den Marlin Server eingeben. Die URL sollte http oder https enthalten und optional den Port.",
|
||||
"read_more_about_marlin": "Erfahre mehr über Marlin.",
|
||||
"save_button": "Speichern",
|
||||
"toasts": {
|
||||
"saved": "Gespeichert",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
"refreshed": "Einstellungen vom Server aktualisiert"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"enable_streamystats": "Streamystats aktivieren",
|
||||
"disable_streamystats": "Streamystats deaktivieren",
|
||||
"enable_search": "Zum Suchen verwenden",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"streamystats_search_hint": "URL für den Streamystats-Server eingeben.",
|
||||
"read_more_about_streamystats": "Mehr über Streamystats erfahren.",
|
||||
"save_button": "Speichern",
|
||||
"save": "Gespeichert",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"home_sections_title": "Startseitenbereiche",
|
||||
"enable_movie_recommendations": "Filmempfehlungen",
|
||||
"enable_series_recommendations": "Serienempfehlungen",
|
||||
"enable_promoted_watchlists": "Empfohlene Merklisten",
|
||||
"hide_watchlists_tab": "Merklisten-Tab ausblenden",
|
||||
"home_sections_hint": "Zeige personalisierte Empfehlungen und empfohlene Merklisten von Streamystats auf der Startseite.",
|
||||
"recommended_movies": "Empfohlene Filme",
|
||||
"recommended_series": "Empfohlene Serien",
|
||||
"toasts": {
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
"saved": "Gespeichert",
|
||||
"refreshed": "Einstellungen vom Server aktualisiert",
|
||||
"disabled": "Streamystats deaktiviert"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
"refresh_from_server": "Einstellungen vom Server aktualisieren"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
"watchlist_enabler": "Merklisten-Integration aktivieren",
|
||||
"watchlist_button": "Merklisten-Integration umschalten"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "Speicher",
|
||||
"app_usage": "App {{usedSpace}}%",
|
||||
"device_usage": "Gerät {{availableSpace}}%",
|
||||
"size_used": "{{used}} von {{total}} benutzt",
|
||||
"delete_all_downloaded_files": "Alle Downloads löschen",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||
"size_used": "{{used}} von {{total}} genutzt",
|
||||
"delete_all_downloaded_files": "Alle heruntergeladenen Dateien löschen",
|
||||
"music_cache_title": "Musik-Cache",
|
||||
"music_cache_description": "Beim Anhören Titel automatisch in den Cache laden um bessere Wiedergabe und Offline-Wiedergabe zu ermöglichen",
|
||||
"enable_music_cache": "Musik-Cache aktivieren",
|
||||
"clear_music_cache": "Musik-Cache leeren",
|
||||
"music_cache_size": "{{size}} gechached",
|
||||
"music_cache_cleared": "Musik-Cache geleert",
|
||||
"delete_all_downloaded_songs": "Alle heruntergeladenen Titel löschen",
|
||||
"downloaded_songs_size": "{{size}} heruntergeladen",
|
||||
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht"
|
||||
},
|
||||
"intro": {
|
||||
"title": "Intro ",
|
||||
"show_intro": "Show intro",
|
||||
"reset_intro": "Reset intro"
|
||||
"title": "Einführung",
|
||||
"show_intro": "Einführung anzeigen",
|
||||
"reset_intro": "Einführung zurücksetzen"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Logs",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"export_logs": "Logs exportieren",
|
||||
"click_for_more_info": "Für mehr Informationen klicken",
|
||||
"level": "Level",
|
||||
"no_logs_available": "Keine Logs verfügbar",
|
||||
"delete_all_logs": "Alle Logs löschen"
|
||||
@@ -438,21 +438,21 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Serien",
|
||||
"tvseries": "Serien",
|
||||
"movies": "Filme",
|
||||
"queue": "Warteschlange",
|
||||
"other_media": "Andere Medien",
|
||||
"queue_hint": "Warteschlange und aktive Downloads gehen verloren bei App-Neustart",
|
||||
"queue_hint": "Warteschlange und aktive Downloads gehen verloren wenn die App neu gestartet wird",
|
||||
"no_items_in_queue": "Keine Elemente in der Warteschlange",
|
||||
"no_downloaded_items": "Keine heruntergeladenen Elemente",
|
||||
"delete_all_movies_button": "Alle Filme löschen",
|
||||
"delete_all_tvseries_button": "Alle TV-Serien löschen",
|
||||
"delete_all_tvseries_button": "Alle Serien löschen",
|
||||
"delete_all_button": "Alles löschen",
|
||||
"delete_all_other_media_button": "Andere Medien löschen",
|
||||
"delete_all_other_media_button": "Alle anderen Medien löschen",
|
||||
"active_download": "Aktiver Download",
|
||||
"no_active_downloads": "Keine aktiven Downloads",
|
||||
"active_downloads": "Aktive Downloads",
|
||||
"new_app_version_requires_re_download": "Die neue App-Version erfordert das erneute Herunterladen.",
|
||||
"new_app_version_requires_re_download": "Neue App-Version erfordert erneutes Herunterladen",
|
||||
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
|
||||
"back": "Zurück",
|
||||
"delete": "Löschen",
|
||||
@@ -463,8 +463,8 @@
|
||||
"you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen",
|
||||
"deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!",
|
||||
"failed_to_delete_all_movies": "Fehler beim Löschen aller Filme",
|
||||
"deleted_all_tvseries_successfully": "Alle TV-Serien erfolgreich gelöscht!",
|
||||
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller TV-Serien",
|
||||
"deleted_all_tvseries_successfully": "Alle Serien erfolgreich gelöscht!",
|
||||
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller Serien",
|
||||
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
||||
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
||||
"download_deleted": "Download gelöscht",
|
||||
@@ -486,7 +486,7 @@
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle Dateien, Ordner und Jobs erfolgreich gelöscht",
|
||||
"failed_to_clean_cache_directory": "Fehler beim Bereinigen des Cache-Verzeichnisses",
|
||||
"could_not_get_download_url_for_item": "Download-URL für {{itemName}} konnte nicht geladen werden",
|
||||
"go_to_downloads": "Gehe zu den Downloads",
|
||||
"go_to_downloads": "Zu Downloads gehen",
|
||||
"file_deleted": "{{item}} gelöscht"
|
||||
}
|
||||
}
|
||||
@@ -499,18 +499,18 @@
|
||||
"subtitle": "Untertitel",
|
||||
"play": "Abspielen",
|
||||
"none": "Keine",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"track": "Spur",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
"remove": "Entfernen",
|
||||
"next": "Weiter",
|
||||
"back": "Zurück",
|
||||
"continue": "Fortsetzen",
|
||||
"verifying": "Verifiziere..."
|
||||
},
|
||||
"search": {
|
||||
"search": "Suche...",
|
||||
"search": "Suchen...",
|
||||
"x_items": "{{count}} Elemente",
|
||||
"library": "Bibliothek",
|
||||
"discover": "Entdecken",
|
||||
@@ -521,33 +521,33 @@
|
||||
"episodes": "Episoden",
|
||||
"collections": "Sammlungen",
|
||||
"actors": "Schauspieler",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"artists": "Künstler",
|
||||
"albums": "Alben",
|
||||
"songs": "Titel",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Film anfragen",
|
||||
"request_series": "Serie anfragen",
|
||||
"recently_added": "Kürzlich hinzugefügt",
|
||||
"recent_requests": "Kürzlich angefragt",
|
||||
"plex_watchlist": "Plex Watchlist",
|
||||
"trending": "In den Trends",
|
||||
"plex_watchlist": "Plex Merkliste",
|
||||
"trending": "Beliebt",
|
||||
"popular_movies": "Beliebte Filme",
|
||||
"movie_genres": "Film-Genres",
|
||||
"upcoming_movies": "Kommende Filme",
|
||||
"studios": "Studios",
|
||||
"popular_tv": "Beliebte TV-Serien",
|
||||
"tv_genres": "TV-Serien-Genres",
|
||||
"upcoming_tv": "Kommende TV-Serien",
|
||||
"networks": "Netzwerke",
|
||||
"popular_tv": "Beliebte Serien",
|
||||
"tv_genres": "Serien-Genres",
|
||||
"upcoming_tv": "Kommende Serien",
|
||||
"networks": "Sender",
|
||||
"tmdb_movie_keyword": "TMDB Film-Schlüsselwort",
|
||||
"tmdb_movie_genre": "TMDB Film-Genre",
|
||||
"tmdb_tv_keyword": "TMDB TV-Serien-Schlüsselwort",
|
||||
"tmdb_tv_genre": "TMDB TV-Serien-Genre",
|
||||
"tmdb_tv_keyword": "TMDB Serien-Schlüsselwort",
|
||||
"tmdb_tv_genre": "TMDB Serien-Genre",
|
||||
"tmdb_search": "TMDB Suche",
|
||||
"tmdb_studio": "TMDB Studio",
|
||||
"tmdb_network": "TMDB Netzwerk",
|
||||
"tmdb_movie_streaming_services": "TMDB Film-Streaming-Dienste",
|
||||
"tmdb_tv_streaming_services": "TMDB TV-Serien-Streaming-Dienste"
|
||||
"tmdb_tv_streaming_services": "TMDB Serien-Streaming-Dienste"
|
||||
},
|
||||
"library": {
|
||||
"no_results": "Keine Ergebnisse",
|
||||
@@ -572,7 +572,7 @@
|
||||
"genres": "Genres",
|
||||
"years": "Jahre",
|
||||
"sort_by": "Sortieren nach",
|
||||
"filter_by": "Filter By",
|
||||
"filter_by": "Filtern nach",
|
||||
"sort_order": "Sortierreihenfolge",
|
||||
"tags": "Tags"
|
||||
}
|
||||
@@ -585,7 +585,7 @@
|
||||
"boxsets": "Boxsets",
|
||||
"playlists": "Wiedergabelisten",
|
||||
"noDataTitle": "Noch keine Favoriten",
|
||||
"noData": "Markiere Elemente als Favoriten, damit sie hier für einen schnellen Zugriff angezeigt werden."
|
||||
"noData": "Elemente als Favoriten markieren, um sie hier anzuzeigen."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Keine Links"
|
||||
@@ -593,7 +593,7 @@
|
||||
"player": {
|
||||
"error": "Fehler",
|
||||
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
|
||||
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Überprüf die Logs in den Einstellungen.",
|
||||
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Logs in den Einstellungen überprüfen.",
|
||||
"client_error": "Client-Fehler",
|
||||
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
||||
"message_from_server": "Nachricht vom Server: {{message}}",
|
||||
@@ -602,17 +602,17 @@
|
||||
"audio_tracks": "Audiospuren:",
|
||||
"playback_state": "Wiedergabestatus:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Weiterschauen",
|
||||
"continue_watching": "Fortsetzen",
|
||||
"go_back": "Zurück",
|
||||
"downloaded_file_title": "Diese Datei wurde heruntergeladen",
|
||||
"downloaded_file_message": "Möchten Sie die heruntergeladene Datei abspielen?",
|
||||
"downloaded_file_title": "Diese Datei wurde bereits heruntergeladen",
|
||||
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
|
||||
"downloaded_file_yes": "Ja",
|
||||
"downloaded_file_no": "Nein",
|
||||
"downloaded_file_cancel": "Abbrechen"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Als Nächstes",
|
||||
"no_items_to_display": "Keine Elemente zum Anzeigen",
|
||||
"no_items_to_display": "Keine Elemente",
|
||||
"cast_and_crew": "Besetzung und Crew",
|
||||
"series": "Serien",
|
||||
"seasons": "Staffeln",
|
||||
@@ -630,7 +630,7 @@
|
||||
"subtitles": "Untertitel",
|
||||
"show_more": "Mehr anzeigen",
|
||||
"show_less": "Weniger anzeigen",
|
||||
"appeared_in": "Erschienen in",
|
||||
"appeared_in": "Erschien in",
|
||||
"could_not_load_item": "Konnte Element nicht laden",
|
||||
"none": "Keine",
|
||||
"download": {
|
||||
@@ -639,13 +639,13 @@
|
||||
"download_episode": "Episode herunterladen",
|
||||
"download_movie": "Film herunterladen",
|
||||
"download_x_item": "{{item_count}} Elemente herunterladen",
|
||||
"download_unwatched_only": "Nur unbeobachtete",
|
||||
"download_unwatched_only": "Nur Ungesehene",
|
||||
"download_button": "Herunterladen"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Nächster",
|
||||
"previous": "Vorheriger",
|
||||
"next": "Nächste",
|
||||
"previous": "Vorherige",
|
||||
"coming_soon": "Demnächst",
|
||||
"on_now": "Jetzt",
|
||||
"shows": "Serien",
|
||||
@@ -658,10 +658,10 @@
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"yes": "Ja",
|
||||
"whats_wrong": "Hast du Probleme?",
|
||||
"issue_type": "Fehlerart",
|
||||
"select_an_issue": "Wähle einen Fehlerart aus",
|
||||
"types": "Arten",
|
||||
"whats_wrong": "Was stimmt nicht?",
|
||||
"issue_type": "Art des Problems",
|
||||
"select_an_issue": "Wähle die Art des Problems aus",
|
||||
"types": "Problem-Arten",
|
||||
"describe_the_issue": "(optional) Beschreibe das Problem",
|
||||
"submit_button": "Absenden",
|
||||
"report_issue_button": "Fehler melden",
|
||||
@@ -671,7 +671,7 @@
|
||||
"cast": "Besetzung",
|
||||
"details": "Details",
|
||||
"status": "Status",
|
||||
"original_title": "Original Titel",
|
||||
"original_title": "Originaltitel",
|
||||
"series_type": "Serien Typ",
|
||||
"release_dates": "Veröffentlichungsdaten",
|
||||
"first_air_date": "Erstausstrahlungsdatum",
|
||||
@@ -687,10 +687,10 @@
|
||||
"request_as": "Anfragen als",
|
||||
"tags": "Tags",
|
||||
"quality_profile": "Qualitätsprofil",
|
||||
"root_folder": "Root-Ordner",
|
||||
"season_all": "Season (all)",
|
||||
"root_folder": "Stammverzeichnis",
|
||||
"season_all": "Staffeln (alle)",
|
||||
"season_number": "Staffel {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} Folgen",
|
||||
"number_episodes": "{{episode_number}} Episoden",
|
||||
"born": "Geboren",
|
||||
"appearances": "Auftritte",
|
||||
"approve": "Genehmigen",
|
||||
@@ -698,9 +698,9 @@
|
||||
"requested_by": "Angefragt von {{user}}",
|
||||
"unknown_user": "Unbekannter Nutzer",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr Server erfüllt nicht die Anforderungsversion. Bitte aktualisiere deinen Jellyseerr Server auf mindestens 2.0.0",
|
||||
"jellyseerr_test_failed": "Jellyseerr-Test fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"failed_to_test_jellyseerr_server_url": "Fehler beim Testen der Jellyseerr-Server-URL",
|
||||
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
|
||||
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"failed_to_test_jellyseerr_server_url": "Fehler beim Test der Seerr-Server-URL",
|
||||
"issue_submitted": "Problem eingereicht!",
|
||||
"requested_item": "{{item}} angefragt!",
|
||||
"you_dont_have_permission_to_request": "Du hast keine Berechtigung Anfragen zu stellen",
|
||||
@@ -715,131 +715,131 @@
|
||||
"home": "Startseite",
|
||||
"search": "Suche",
|
||||
"library": "Bibliothek",
|
||||
"custom_links": "Benutzerdefinierte Links",
|
||||
"custom_links": "Links",
|
||||
"favorites": "Favoriten"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"title": "Musik",
|
||||
"tabs": {
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"suggestions": "Vorschläge",
|
||||
"albums": "Alben",
|
||||
"artists": "Künstler",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
"tracks": "Titel"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
"all": "Alle"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"recently_added": "Kürzlich hinzugefügt",
|
||||
"recently_played": "Vor kurzem gehört",
|
||||
"frequently_played": "Oft gehört",
|
||||
"explore": "Entdecken",
|
||||
"top_tracks": "Top-Titel",
|
||||
"play": "Abspielen",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"play_top_tracks": "Top-Tracks abspielen",
|
||||
"no_suggestions": "Keine Vorschläge verfügbar",
|
||||
"no_albums": "Keine Alben gefunden",
|
||||
"no_artists": "Keine Künstler gefunden",
|
||||
"no_playlists": "Keine Playlists gefunden",
|
||||
"album_not_found": "Album nicht gefunden",
|
||||
"artist_not_found": "Künstler nicht gefunden",
|
||||
"playlist_not_found": "Playlist nicht gefunden",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
"play_next": "Als Nächstes wiedergeben",
|
||||
"add_to_queue": "Zur Warteschlange hinzufügen",
|
||||
"add_to_playlist": "Zur Playlist hinzufügen",
|
||||
"download": "Herunterladen",
|
||||
"downloaded": "Heruntergeladen",
|
||||
"downloading": "Wird heruntergeladen...",
|
||||
"cached": "Gecached",
|
||||
"delete_download": "Download löschen",
|
||||
"delete_cache": "Aus dem Cache löschen",
|
||||
"go_to_artist": "Zum Künstler gehen",
|
||||
"go_to_album": "Zum Album gehen",
|
||||
"add_to_favorites": "Zu Favoriten hinzufügen",
|
||||
"remove_from_favorites": "Aus Favoriten entfernen",
|
||||
"remove_from_playlist": "Aus Playlist entfernen"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"create_playlist": "Playlist erstellen",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
"enter_name": "Playlist Name eingeben",
|
||||
"create": "Erstellen",
|
||||
"search_playlists": "Playlisten durchsuchen...",
|
||||
"added_to": "Zu {{name}} hinzugefügt",
|
||||
"added": "Zur Playlist hinzugefügt",
|
||||
"removed_from": "Aus {{name}} entfernt",
|
||||
"removed": "Aus Playlist entfernt",
|
||||
"created": "Playlist erstellt",
|
||||
"create_new": "Neue Playlist erstellen",
|
||||
"failed_to_add": "Fehler beim Hinzufügen zur Playlist",
|
||||
"failed_to_remove": "Fehler beim Entfernen aus der Playlist",
|
||||
"failed_to_create": "Fehler beim Erstellen der Playlist",
|
||||
"delete_playlist": "Playlist löschen",
|
||||
"delete_confirm": "Bist Du sicher, dass Du \"{{name}}\" löschen möchtest? Das kann nicht rückgängig gemacht werden.",
|
||||
"deleted": "Playlist gelöscht",
|
||||
"failed_to_delete": "Fehler beim Löschen der Playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
"title": "Sortieren nach",
|
||||
"alphabetical": "Alphabetisch",
|
||||
"date_created": "Erstellungsdatum"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"title": "Merklisten",
|
||||
"my_watchlists": "Meine Merklisten",
|
||||
"public_watchlists": "Öffentliche Merklisten",
|
||||
"create_title": "Merkliste erstellen",
|
||||
"edit_title": "Merkliste bearbeiten",
|
||||
"create_button": "Merkliste erstellen",
|
||||
"save_button": "Änderungen speichern",
|
||||
"delete_button": "Löschen",
|
||||
"remove_button": "Entfernen",
|
||||
"cancel_button": "Abbrechen",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
"name_placeholder": "Merklistenname eingeben",
|
||||
"description_label": "Beschreibung",
|
||||
"description_placeholder": "Beschreibung eingeben (optional)",
|
||||
"is_public_label": "Öffentliche Merkliste",
|
||||
"is_public_description": "Anderen erlauben diese Merkliste anzusehen",
|
||||
"allowed_type_label": "Inhaltstyp",
|
||||
"sort_order_label": "Standard-Sortierreihenfolge",
|
||||
"empty_title": "Keine Merklisten",
|
||||
"empty_description": "Erstelle deine erste Merkliste um deine Medien zu organisieren",
|
||||
"empty_watchlist": "Diese Merkliste ist leer",
|
||||
"empty_watchlist_hint": "Füge Elemente aus deiner Bibliothek zu dieser Merkliste hinzu",
|
||||
"not_configured_title": "Streamystats nicht konfiguriert",
|
||||
"not_configured_description": "Streamystats in den Einstellungen konfigurieren, um Merklisten zu verwenden",
|
||||
"go_to_settings": "Gehe zu Einstellungen",
|
||||
"add_to_watchlist": "Zur Merkliste hinzufügen",
|
||||
"remove_from_watchlist": "Von Merkliste entfernen",
|
||||
"select_watchlist": "Merkliste auswählen",
|
||||
"create_new": "Neue Merkliste erstellen",
|
||||
"item": "Element",
|
||||
"items": "Elemente",
|
||||
"public": "Öffentlich",
|
||||
"private": "Privat",
|
||||
"you": "Du",
|
||||
"by_owner": "Von einem anderen Benutzer",
|
||||
"not_found": "Merkliste nicht gefunden",
|
||||
"delete_confirm_title": "Merkliste löschen",
|
||||
"delete_confirm_message": "Bist Du sicher, dass Du \"{{name}}\" löschen möchtest? Das kann nicht rückgängig gemacht werden.",
|
||||
"remove_item_title": "Von Merkliste entfernen",
|
||||
"remove_item_message": "\"{{name}}\" von dieser Merkliste entfernen?",
|
||||
"loading": "Lade Merklisten...",
|
||||
"no_compatible_watchlists": "Keine kompatiblen Merklisten",
|
||||
"create_one_first": "Erstelle eine Merkliste, welche diesen Inhaltstyp akzeptiert"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"title": "Wiedergabegeschwindigkeit",
|
||||
"apply_to": "Anwenden auf",
|
||||
"speed": "Geschwindigkeit",
|
||||
"scope": {
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
"media": "Nur hier",
|
||||
"show": "Nur diese Serie",
|
||||
"all": "Alle (Standard)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "Username is required",
|
||||
"username_required": "Username Is Required",
|
||||
"error_title": "Error",
|
||||
"login_title": "Log in",
|
||||
"login_title": "Log In",
|
||||
"login_to_title": "Log in to",
|
||||
"username_placeholder": "Username",
|
||||
"password_placeholder": "Password",
|
||||
"login_button": "Log in",
|
||||
"login_button": "Log In",
|
||||
"quick_connect": "Quick Connect",
|
||||
"enter_code_to_login": "Enter code {{code}} to log in",
|
||||
"enter_code_to_login": "Enter code {{code}} to login",
|
||||
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
|
||||
"got_it": "Got It",
|
||||
"connection_failed": "Connection Failed",
|
||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||
"an_unexpected_error_occured": "An unexpected error occurred",
|
||||
"an_unexpected_error_occured": "An Unexpected Error Occurred",
|
||||
"change_server": "Change Server",
|
||||
"invalid_username_or_password": "Invalid Username or Password",
|
||||
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
||||
@@ -22,7 +22,7 @@
|
||||
"there_is_a_server_error": "There is a server error",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||
"too_old_server_description": "Please update Jellyfin to the latest version."
|
||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||
@@ -88,27 +88,27 @@
|
||||
"continue_watching": "Continue Watching",
|
||||
"next_up": "Next Up",
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Recently added in {{libraryName}}",
|
||||
"recently_added_in": "Recently Added in {{libraryName}}",
|
||||
"suggested_movies": "Suggested Movies",
|
||||
"suggested_episodes": "Suggested Episodes",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Welcome to Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
|
||||
"features_title": "Features",
|
||||
"features_description": "Streamyfin offers many features and integrates with a wide array of software which you can find in the settings menu, including:",
|
||||
"seerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
||||
"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 Seerr instance and request movies directly in the app.",
|
||||
"downloads_feature_title": "Downloads",
|
||||
"downloads_feature_description": "Download movies and TV shows to view offline.",
|
||||
"chromecast_feature_description": "Cast movies and TV shows to your Chromecast devices.",
|
||||
"centralized_settings_plugin_title": "Centralized Settings Plugin",
|
||||
"centralized_settings_plugin_description": "Configure settings from a centralized location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
||||
"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.",
|
||||
"centralised_settings_plugin_title": "Centralised Settings Plugin",
|
||||
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
||||
"done_button": "Done",
|
||||
"go_to_settings_button": "Go to Settings",
|
||||
"read_more": "Read More"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Settings",
|
||||
"log_out_button": "Log out",
|
||||
"log_out_button": "Log Out",
|
||||
"categories": {
|
||||
"title": "Categories"
|
||||
},
|
||||
@@ -179,9 +179,9 @@
|
||||
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
|
||||
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
|
||||
"left_side_brightness": "Left Side Brightness Control",
|
||||
"left_side_brightness_description": "Swipe up/down on the left side to adjust brightness",
|
||||
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
|
||||
"right_side_volume": "Right Side Volume Control",
|
||||
"right_side_volume_description": "Swipe up/down on the right side to adjust volume",
|
||||
"right_side_volume_description": "Swipe up/down on right side to adjust volume",
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
@@ -218,7 +218,7 @@
|
||||
"Smart": "Smart",
|
||||
"Always": "Always",
|
||||
"None": "None",
|
||||
"OnlyForced": "Only Forced"
|
||||
"OnlyForced": "OnlyForced"
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
@@ -253,7 +253,29 @@
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font"
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Other",
|
||||
@@ -272,15 +294,20 @@
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"safe_area_in_controls": "Safe Area in Controls",
|
||||
"video_player": "Video Player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Show Custom Menu Links",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Hide Libraries",
|
||||
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||
"disable_haptic_feedback": "Disable Haptic Feedback",
|
||||
"default_quality": "Default Quality",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Autoplay Next Episode",
|
||||
"max_auto_play_episode_count": "Max Autoplay Episode Count",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"downloads": {
|
||||
@@ -299,8 +326,8 @@
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"seerr": {
|
||||
"seerr_warning": "This integration is in early development. Features may change.",
|
||||
"jellyseerr": {
|
||||
"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": "Seerr URL",
|
||||
@@ -312,9 +339,9 @@
|
||||
"movie_quota_days": "Movie Quota Days",
|
||||
"tv_quota_limit": "TV Quota Limit",
|
||||
"tv_quota_days": "TV Quota Days",
|
||||
"reset_seerr_config_button": "Reset Seerr Config",
|
||||
"reset_jellyseerr_config_button": "Reset Seerr Config",
|
||||
"unlimited": "Unlimited",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"plus_n_more": "+{{n}} More",
|
||||
"order_by": {
|
||||
"DEFAULT": "Default",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
|
||||
@@ -326,7 +353,7 @@
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_marlin": "Read more about Marlin.",
|
||||
"read_more_about_marlin": "Read More About Marlin.",
|
||||
"save_button": "Save",
|
||||
"toasts": {
|
||||
"saved": "Saved",
|
||||
@@ -341,7 +368,7 @@
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read more about Streamystats.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
@@ -400,18 +427,18 @@
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error deleting files",
|
||||
"error_deleting_files": "Error Deleting Files",
|
||||
"background_downloads_enabled": "Background downloads enabled",
|
||||
"background_downloads_disabled": "Background downloads disabled"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessions",
|
||||
"no_active_sessions": "No active sessions"
|
||||
"no_active_sessions": "No Active Sessions"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV Series",
|
||||
"tvseries": "TV-Series",
|
||||
"movies": "Movies",
|
||||
"queue": "Queue",
|
||||
"other_media": "Other media",
|
||||
@@ -419,7 +446,7 @@
|
||||
"no_items_in_queue": "No Items in Queue",
|
||||
"no_downloaded_items": "No Downloaded Items",
|
||||
"delete_all_movies_button": "Delete All Movies",
|
||||
"delete_all_tvseries_button": "Delete All TV Series",
|
||||
"delete_all_tvseries_button": "Delete All TV-Series",
|
||||
"delete_all_button": "Delete All",
|
||||
"delete_all_other_media_button": "Delete other media",
|
||||
"active_download": "Active Download",
|
||||
@@ -434,27 +461,27 @@
|
||||
"eta": "ETA {{eta}}",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
|
||||
"deleted_all_movies_successfully": "Deleted all movies successfully!",
|
||||
"failed_to_delete_all_movies": "Failed to delete all movies",
|
||||
"deleted_all_tvseries_successfully": "Deleted all TV series successfully!",
|
||||
"failed_to_delete_all_tvseries": "Failed to delete all TV series",
|
||||
"deleted_media_successfully": "Deleted other media successfully!",
|
||||
"failed_to_delete_media": "Failed to delete other media",
|
||||
"download_deleted": "Download deleted",
|
||||
"download_cancelled": "Download cancelled",
|
||||
"could_not_delete_download": "Could not delete download",
|
||||
"download_paused": "Download paused",
|
||||
"could_not_pause_download": "Could not pause download",
|
||||
"download_resumed": "Download resumed",
|
||||
"could_not_resume_download": "Could not resume download",
|
||||
"download_completed": "Download completed",
|
||||
"download_failed": "Download failed",
|
||||
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
|
||||
"failed_to_delete_all_movies": "Failed to Delete All Movies",
|
||||
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
|
||||
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Download Completed",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||
"download_completed_for_item": "Download completed for {{item}}",
|
||||
"download_started_for_item": "Download started for {{item}}",
|
||||
"download_completed_for_item": "Download Completed for {{item}}",
|
||||
"download_started_for_item": "Download Started for {{item}}",
|
||||
"failed_to_start_download": "Failed to start download",
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All downloads deleted successfully",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
@@ -488,7 +515,7 @@
|
||||
"library": "Library",
|
||||
"discover": "Discover",
|
||||
"no_results": "No Results",
|
||||
"no_results_found_for": "No results found for",
|
||||
"no_results_found_for": "No Results Found For",
|
||||
"movies": "Movies",
|
||||
"series": "Series",
|
||||
"episodes": "Episodes",
|
||||
@@ -532,7 +559,6 @@
|
||||
"items": "Items"
|
||||
},
|
||||
"options": {
|
||||
"options_title": "Options",
|
||||
"display": "Display",
|
||||
"row": "Row",
|
||||
"list": "List",
|
||||
@@ -572,26 +598,17 @@
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel",
|
||||
"playback_options_title": "Playback Options",
|
||||
"mpv_subtitle_settings_title": "MPV Subtitle Settings",
|
||||
"mpv_subtitle_settings_description": "Advanced subtitle customization for MPV player",
|
||||
"subtitle_scale": "Subtitle Scale",
|
||||
"vertical_margin": "Vertical Margin",
|
||||
"horizontal_alignment": "Horizontal Alignment",
|
||||
"vertical_alignment": "Vertical Alignment",
|
||||
"alignment_left": "Left",
|
||||
"alignment_center": "Center",
|
||||
"alignment_right": "Right",
|
||||
"alignment_top": "Top",
|
||||
"alignment_bottom": "Bottom",
|
||||
"mpv_player_title": "MPV Player"
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Next Up",
|
||||
@@ -610,11 +627,11 @@
|
||||
"media_options": "Media Options",
|
||||
"quality": "Quality",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subtitles",
|
||||
"subtitles": "Subtitle",
|
||||
"show_more": "Show More",
|
||||
"show_less": "Show Less",
|
||||
"appeared_in": "Appeared In",
|
||||
"could_not_load_item": "Could not load item",
|
||||
"could_not_load_item": "Could Not Load Item",
|
||||
"none": "None",
|
||||
"download": {
|
||||
"download_season": "Download Season",
|
||||
@@ -637,11 +654,11 @@
|
||||
"for_kids": "For Kids",
|
||||
"news": "News"
|
||||
},
|
||||
"seerr": {
|
||||
"jellyseerr": {
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"yes": "Yes",
|
||||
"whats_wrong": "What's wrong?",
|
||||
"whats_wrong": "What's Wrong?",
|
||||
"issue_type": "Issue Type",
|
||||
"select_an_issue": "Select an Issue",
|
||||
"types": "Types",
|
||||
@@ -650,7 +667,7 @@
|
||||
"report_issue_button": "Report Issue",
|
||||
"request_button": "Request",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
||||
"failed_to_login": "Failed to log in",
|
||||
"failed_to_login": "Failed to Login",
|
||||
"cast": "Cast",
|
||||
"details": "Details",
|
||||
"status": "Status",
|
||||
@@ -681,17 +698,17 @@
|
||||
"requested_by": "Requested by {{user}}",
|
||||
"unknown_user": "Unknown User",
|
||||
"toasts": {
|
||||
"seerr_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"seerr_test_failed": "Seerr test failed. Please try again.",
|
||||
"failed_to_test_seerr_server_url": "Failed to test Seerr 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!",
|
||||
"something_went_wrong_requesting_media": "Something went wrong requesting media!",
|
||||
"request_approved": "Request Approved!",
|
||||
"request_declined": "Request Declined!",
|
||||
"failed_to_approve_request": "Failed to approve request",
|
||||
"failed_to_decline_request": "Failed to decline request"
|
||||
"failed_to_approve_request": "Failed to Approve Request",
|
||||
"failed_to_decline_request": "Failed to Decline Request"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
@@ -708,7 +725,7 @@
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "Tracks"
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user