mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-21 08:14:42 +01:00
Compare commits
32 Commits
I10n_crowd
...
remove-opt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
381dc351e2 | ||
|
|
3b2649bb65 | ||
|
|
bae8161591 | ||
|
|
9a17e36882 | ||
|
|
391ca897a5 | ||
|
|
c8fefdf4a1 | ||
|
|
956eea8848 | ||
|
|
895c245254 | ||
|
|
376d2e84da | ||
|
|
12047cbe12 | ||
|
|
32f4bbcc7d | ||
|
|
138a86f473 | ||
|
|
6dd111defe | ||
|
|
4ba03b669e | ||
|
|
aacf5327d1 | ||
|
|
f543fa9e3e | ||
|
|
4385fe5502 | ||
|
|
3d72c9c783 | ||
|
|
e41d1b4818 | ||
|
|
fd3766fc23 | ||
|
|
f211a9ce7a | ||
|
|
b7db06f53d | ||
|
|
ceac74dbfa | ||
|
|
b6bd427e19 | ||
|
|
47bf1c9201 | ||
|
|
4aaddd2104 | ||
|
|
4a75e8f551 | ||
|
|
0c8c27bfc0 | ||
|
|
67e61f3ab8 | ||
|
|
c66541ce4d | ||
|
|
07a0a48613 | ||
|
|
b630e0784b |
41
.github/copilot-instructions.md
vendored
41
.github/copilot-instructions.md
vendored
@@ -3,7 +3,7 @@
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
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 Jellyseerr APIs,
|
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
|
||||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||||
|
|
||||||
## Main Technologies
|
## Main Technologies
|
||||||
@@ -40,9 +40,30 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||||
- `plugins/` – Expo/Metro plugins
|
- `plugins/` – Expo/Metro plugins
|
||||||
|
|
||||||
## Coding Standards
|
## Code Quality Standards
|
||||||
|
|
||||||
|
**CRITICAL: Code must be production-ready, reliable, and maintainable**
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
- Use TypeScript for ALL files (no .js files)
|
- 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
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
@@ -50,8 +71,10 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
- Follow BiomeJS formatting and linting rules
|
- Follow BiomeJS formatting and linting rules
|
||||||
- Use `const` over `let`, avoid `var` entirely
|
- Use `const` over `let`, avoid `var` entirely
|
||||||
- Implement proper error boundaries
|
- Implement proper error boundaries
|
||||||
- Use React.memo() for performance optimization
|
- Use React.memo() for performance optimization when needed
|
||||||
- Handle both mobile and TV navigation patterns
|
- Handle both mobile and TV navigation patterns
|
||||||
|
- Write self-documenting code with clear intent
|
||||||
|
- Add comments only when code complexity requires explanation
|
||||||
|
|
||||||
## API Integration
|
## API Integration
|
||||||
|
|
||||||
@@ -85,6 +108,18 @@ Exemples:
|
|||||||
- `fix(auth): handle expired JWT tokens`
|
- `fix(auth): handle expired JWT tokens`
|
||||||
- `chore(deps): update Jellyfin SDK`
|
- `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
|
## Special Instructions
|
||||||
|
|
||||||
- Prioritize cross-platform compatibility (mobile + TV)
|
- 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
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -41,12 +41,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -116,7 +116,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -124,12 +124,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -143,7 +143,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -156,7 +156,7 @@ jobs:
|
|||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -187,7 +187,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -195,12 +195,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
@@ -251,7 +251,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -259,12 +259,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
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:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
@@ -27,12 +27,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
8
.github/workflows/ci-codeql.yml
vendored
8
.github/workflows/ci-codeql.yml
vendored
@@ -24,16 +24,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|||||||
4
.github/workflows/crowdin.yml
vendored
4
.github/workflows/crowdin.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@5587c43063e52090026857d386174d2599ad323b # v2.14.1
|
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
|
|||||||
10
.github/workflows/linting.yml
vendored
10
.github/workflows/linting.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -69,14 +69,14 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛒 Checkout repository
|
- name: 🛒 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -112,7 +112,7 @@ jobs:
|
|||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
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:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
dry_run: no-push
|
dry_run: no-push
|
||||||
|
|
||||||
- name: 📬 Commit and create pull request
|
- name: 📬 Commit and create pull request
|
||||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||||
with:
|
with:
|
||||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||||
branch: ci-update-bug-report
|
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
|
## 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 Jellyseerr 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 Seerr integration.
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
|
|||||||
101
README.md
101
README.md
@@ -22,58 +22,75 @@
|
|||||||
|
|
||||||
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
|
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
|
||||||
|
|
||||||
<img src="./assets/images/jellyseerr.PNG" width="21%">
|
<img src="./assets/images/seerr.PNG" width="21%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
|
### 🎬 Media Playback
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
|
- 🚀 **Skip Intro / Credits**: Automatically skip intros and credits during playback
|
||||||
- 📥 **Download media**: Save your media locally and watch it offline
|
- 🖼️ **Trickplay Images**: Chapter previews with thumbnails when seeking
|
||||||
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin
|
- 🎵 **Music Library**: Full support for music playback with playlists and queue management
|
||||||
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
|
- 📺 **Live TV**: Watch live television streams
|
||||||
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
|
|
||||||
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
|
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
|
||||||
|
- 🎥 **MPV Player**: Powerful open-source player with wide format support
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
### 📱 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
|
||||||
|
|
||||||
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.
|
### ⚙️ 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
|
||||||
|
|
||||||
### 📥 Downloading
|
## 🧩 How It Works
|
||||||
|
|
||||||
|
### 📥 Downloads
|
||||||
|
|
||||||
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.
|
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
|
### 🧩 Streamyfin Plugin
|
||||||
|
|
||||||
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:
|
The Jellyfin Plugin for Streamyfin synchronizes settings across all your devices and users. Install it on your Jellyfin server to enable:
|
||||||
|
|
||||||
- Automatic Seerr login with no user input required
|
- Automatic Seerr login with no user input required
|
||||||
- Set your preferred default languages
|
- Default language preferences for audio and subtitles
|
||||||
- Configure download method and search provider
|
- Configure download settings and search providers (Marlin, Streamystats)
|
||||||
- Personalize your home screen
|
- Customize your home screen layout and sections
|
||||||
|
- Centralized configuration management
|
||||||
- And much more
|
- And much more
|
||||||
|
|
||||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
[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
|
### 🎬 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.
|
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.
|
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
|
||||||
|
|
||||||
### 🔍 Jellysearch
|
### 🎵 Music Library
|
||||||
|
|
||||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
|
Full music library support with playlists, queue management, background playback, and offline downloads.
|
||||||
|
|
||||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
### 🔍 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))
|
||||||
|
|
||||||
## 🛣️ Roadmap
|
## 🛣️ 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
|
## 📥 Download Streamyfin
|
||||||
|
|
||||||
@@ -113,13 +130,12 @@ You can contribute translations directly on our [Crowdin project page](https://c
|
|||||||
|
|
||||||
### 👨💻 Development Info
|
### 👨💻 Development Info
|
||||||
|
|
||||||
1. Use node `>20`
|
1. Use Node.js `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
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/)
|
3. Make sure you have Xcode and/or Android Studio installed ([Expo setup guide](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 the [BiomeJS extension](https://biomejs.dev/) in your IDE
|
||||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
5. Run `npm run prebuild`
|
||||||
4. 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
|
||||||
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`.
|
For the TV version suffix the npm commands with `:tv`.
|
||||||
|
|
||||||
@@ -137,10 +153,20 @@ Need assistance or have any questions?
|
|||||||
|
|
||||||
## ❓ FAQ
|
## ❓ FAQ
|
||||||
|
|
||||||
1. Q: Why can't I see my libraries in Streamyfin?
|
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
|
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: 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
|
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.
|
||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
@@ -254,7 +280,9 @@ A special mention to the following people and projects for their contributions:
|
|||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
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.
|
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:
|
Key points of the MPL-2.0:
|
||||||
|
|
||||||
- You can use the software for any purpose
|
- You can use the software for any purpose
|
||||||
@@ -263,10 +291,13 @@ Key points of the MPL-2.0:
|
|||||||
- You must disclose your source code for any modifications to the covered files
|
- You must disclose your source code for any modifications to the covered files
|
||||||
- Larger works may combine MPL code with code under other licenses
|
- 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
|
- 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
|
## ⚠️ 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.
|
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
|
## 🤝 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
|
<Stack.Screen
|
||||||
name='settings/plugins/jellyseerr/page'
|
name='settings/plugins/seerr/page'
|
||||||
options={{
|
options={{
|
||||||
title: "Jellyseerr",
|
title: "Seerr",
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function page() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function page() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ export default function Page() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
logsFile.write(JSON.stringify(filteredLogs));
|
logsFile.write(JSON.stringify(filteredLogs));
|
||||||
await Sharing.shareAsync(logsFile.uri, {
|
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
||||||
mimeType: "text/plain",
|
|
||||||
UTI: "public.plain-text",
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
writeErrorLog("Something went wrong attempting to export", e);
|
writeErrorLog("Something went wrong attempting to export", e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ScrollView } from "react-native";
|
import { ScrollView } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { SeerrSettings } from "@/components/settings/Seerr";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function Page() {
|
||||||
const { pluginSettings } = useSettings();
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -17,10 +17,10 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
disabled={pluginSettings?.seerrServerUrl?.locked === true}
|
||||||
className='px-4'
|
className='px-4'
|
||||||
>
|
>
|
||||||
<JellyseerrSettings />
|
<SeerrSettings />
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
@@ -3,9 +3,9 @@ import { Image } from "expo-image";
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import {
|
import {
|
||||||
type MovieResult,
|
type MovieResult,
|
||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
|
|
||||||
export default function page() {
|
export default function CompanyPage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||||
|
|
||||||
const { companyId, image, type } = local as unknown as {
|
const { companyId, image, type } = local as unknown as {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -25,12 +25,12 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, companyId],
|
queryKey: ["seerr", "company", type, companyId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
};
|
};
|
||||||
return jellyseerrApi?.discover(
|
return seerrApi?.discover(
|
||||||
`${
|
`${
|
||||||
Number(type) === DiscoverSliderType.NETWORKS
|
Number(type) === DiscoverSliderType.NETWORKS
|
||||||
? Endpoints.DISCOVER_TV_NETWORK
|
? Endpoints.DISCOVER_TV_NETWORK
|
||||||
@@ -39,7 +39,7 @@ export default function page() {
|
|||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi && !!companyId,
|
enabled: !!seerrApi && !!companyId,
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getNextPageParam: (lastPage, pages) =>
|
getNextPageParam: (lastPage, pages) =>
|
||||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
@@ -53,25 +53,24 @@ export default function page() {
|
|||||||
data?.pages
|
data?.pages
|
||||||
?.filter((p) => p?.results.length)
|
?.filter((p) => p?.results.length)
|
||||||
.flatMap(
|
.flatMap(
|
||||||
(p) =>
|
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
|
||||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
|
||||||
),
|
),
|
||||||
"id",
|
"id",
|
||||||
) ?? [],
|
) ?? [],
|
||||||
[data],
|
[data, isSeerrMovieOrTvResult],
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() =>
|
() =>
|
||||||
jellyseerrApi
|
seerrApi
|
||||||
? flatData.map((r) =>
|
? flatData.map((r) =>
|
||||||
jellyseerrApi.imageProxy(
|
seerrApi.imageProxy(
|
||||||
(r as TvResult | MovieResult).backdropPath,
|
(r as TvResult | MovieResult).backdropPath,
|
||||||
"w1920_and_h800_multi_faces",
|
"w1920_and_h800_multi_faces",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
[jellyseerrApi, flatData],
|
[seerrApi, flatData],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,7 +91,7 @@ export default function page() {
|
|||||||
key={companyId}
|
key={companyId}
|
||||||
className='bottom-1 w-1/2'
|
className='bottom-1 w-1/2'
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
uri: seerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
@@ -101,7 +100,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,15 +3,15 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
|
||||||
export default function page() {
|
export default function GenrePage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||||
|
|
||||||
const { genreId, name, type } = local as unknown as {
|
const { genreId, name, type } = local as unknown as {
|
||||||
genreId: string;
|
genreId: string;
|
||||||
@@ -20,21 +20,21 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, genreId],
|
queryKey: ["seerr", "genre", type, genreId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
genre: genreId,
|
genre: genreId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return jellyseerrApi?.discover(
|
return seerrApi?.discover(
|
||||||
type === DiscoverSliderType.MOVIE_GENRES
|
type === DiscoverSliderType.MOVIE_GENRES
|
||||||
? Endpoints.DISCOVER_MOVIES
|
? Endpoints.DISCOVER_MOVIES
|
||||||
: Endpoints.DISCOVER_TV,
|
: Endpoints.DISCOVER_TV,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi && !!genreId,
|
enabled: !!seerrApi && !!genreId,
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getNextPageParam: (lastPage, pages) =>
|
getNextPageParam: (lastPage, pages) =>
|
||||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
@@ -48,8 +48,7 @@ export default function page() {
|
|||||||
data?.pages
|
data?.pages
|
||||||
?.filter((p) => p?.results.length)
|
?.filter((p) => p?.results.length)
|
||||||
.flatMap(
|
.flatMap(
|
||||||
(p) =>
|
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
|
||||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
|
||||||
),
|
),
|
||||||
"id",
|
"id",
|
||||||
) ?? [],
|
) ?? [],
|
||||||
@@ -58,15 +57,12 @@ export default function page() {
|
|||||||
|
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() =>
|
() =>
|
||||||
jellyseerrApi
|
seerrApi
|
||||||
? flatData.map((r) =>
|
? flatData.map((r) =>
|
||||||
jellyseerrApi.imageProxy(
|
seerrApi.imageProxy(r.backdropPath, "w1920_and_h800_multi_faces"),
|
||||||
r.backdropPath,
|
|
||||||
"w1920_and_h800_multi_faces",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
[jellyseerrApi, flatData],
|
[seerrApi, flatData],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,7 +87,7 @@ export default function page() {
|
|||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -18,18 +18,18 @@ import { toast } from "sonner-native";
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import Cast from "@/components/jellyseerr/Cast";
|
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { SeerrRatings } from "@/components/Ratings";
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
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 { ItemActions } from "@/components/series/SeriesActions";
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
|
||||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
import {
|
import {
|
||||||
type IssueType,
|
type IssueType,
|
||||||
@@ -68,7 +68,7 @@ const Page: React.FC = () => {
|
|||||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { seerrApi, seerrUser, requestMedia } = useSeerr();
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
@@ -83,8 +83,8 @@ const Page: React.FC = () => {
|
|||||||
isLoading,
|
isLoading,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
enabled: !!seerrApi && !!result && !!result.id,
|
||||||
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
queryKey: ["seerr", "detail", mediaType, result.id],
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
@@ -93,21 +93,18 @@ const Page: React.FC = () => {
|
|||||||
refetchInterval: 0,
|
refetchInterval: 0,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return mediaType === MediaType.MOVIE
|
return mediaType === MediaType.MOVIE
|
||||||
? jellyseerrApi?.movieDetails(result.id!)
|
? seerrApi?.movieDetails(result.id!)
|
||||||
: jellyseerrApi?.tvDetails(result.id!);
|
: seerrApi?.tvDetails(result.id!);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [canRequest, hasAdvancedRequestPermission] =
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
useJellyseerrCanRequest(details);
|
useSeerrCanRequest(details);
|
||||||
|
|
||||||
const canManageRequests = useMemo(() => {
|
const canManageRequests = useMemo(() => {
|
||||||
if (!jellyseerrUser) return false;
|
if (!seerrUser) return false;
|
||||||
return hasPermission(
|
return hasPermission(Permission.MANAGE_REQUESTS, seerrUser.permissions);
|
||||||
Permission.MANAGE_REQUESTS,
|
}, [seerrUser]);
|
||||||
jellyseerrUser.permissions,
|
|
||||||
);
|
|
||||||
}, [jellyseerrUser]);
|
|
||||||
|
|
||||||
const pendingRequest = useMemo(() => {
|
const pendingRequest = useMemo(() => {
|
||||||
return details?.mediaInfo?.requests?.find(
|
return details?.mediaInfo?.requests?.find(
|
||||||
@@ -119,27 +116,27 @@ const Page: React.FC = () => {
|
|||||||
if (!pendingRequest?.id) return;
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
await seerrApi?.approveRequest(pendingRequest.id);
|
||||||
toast.success(t("jellyseerr.toasts.request_approved"));
|
toast.success(t("seerr.toasts.request_approved"));
|
||||||
refetch();
|
refetch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
toast.error(t("seerr.toasts.failed_to_approve_request"));
|
||||||
console.error("Failed to approve request:", error);
|
console.error("Failed to approve request:", error);
|
||||||
}
|
}
|
||||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
}, [seerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
const handleDeclineRequest = useCallback(async () => {
|
const handleDeclineRequest = useCallback(async () => {
|
||||||
if (!pendingRequest?.id) return;
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
await seerrApi?.declineRequest(pendingRequest.id);
|
||||||
toast.success(t("jellyseerr.toasts.request_declined"));
|
toast.success(t("seerr.toasts.request_declined"));
|
||||||
refetch();
|
refetch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
toast.error(t("seerr.toasts.failed_to_decline_request"));
|
||||||
console.error("Failed to decline request:", error);
|
console.error("Failed to decline request:", error);
|
||||||
}
|
}
|
||||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
}, [seerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -154,7 +151,7 @@ const Page: React.FC = () => {
|
|||||||
|
|
||||||
const submitIssue = useCallback(() => {
|
const submitIssue = useCallback(() => {
|
||||||
if (result.id && issueType && issueMessage && details) {
|
if (result.id && issueType && issueMessage && details) {
|
||||||
jellyseerrApi
|
seerrApi
|
||||||
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setIssueType(undefined);
|
setIssueType(undefined);
|
||||||
@@ -162,7 +159,7 @@ const Page: React.FC = () => {
|
|||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [seerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
const handleIssueModalDismiss = useCallback(() => {
|
const handleIssueModalDismiss = useCallback(() => {
|
||||||
setIssueTypeDropdownOpen(false);
|
setIssueTypeDropdownOpen(false);
|
||||||
@@ -214,7 +211,7 @@ const Page: React.FC = () => {
|
|||||||
const issueTypeOptionGroups = useMemo(
|
const issueTypeOptionGroups = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
title: t("jellyseerr.types"),
|
title: t("seerr.types"),
|
||||||
options: Object.entries(IssueTypeName)
|
options: Object.entries(IssueTypeName)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map(([key, value]) => ({
|
.map(([key, value]) => ({
|
||||||
@@ -265,7 +262,7 @@ const Page: React.FC = () => {
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(
|
uri: seerrApi?.imageProxy(
|
||||||
result.backdropPath,
|
result.backdropPath,
|
||||||
"w1920_and_h800_multi_faces",
|
"w1920_and_h800_multi_faces",
|
||||||
),
|
),
|
||||||
@@ -295,7 +292,7 @@ const Page: React.FC = () => {
|
|||||||
<View className='px-4'>
|
<View className='px-4'>
|
||||||
<View className='flex flex-row justify-between w-full'>
|
<View className='flex flex-row justify-between w-full'>
|
||||||
<View className='flex flex-col w-56'>
|
<View className='flex flex-col w-56'>
|
||||||
<JellyserrRatings
|
<SeerrRatings
|
||||||
result={
|
result={
|
||||||
result as
|
result as
|
||||||
| MovieResult
|
| MovieResult
|
||||||
@@ -330,7 +327,7 @@ const Page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
) : canRequest ? (
|
) : canRequest ? (
|
||||||
<Button color='purple' onPress={request} className='mt-4'>
|
<Button color='purple' onPress={request} className='mt-4'>
|
||||||
{t("jellyseerr.request_button")}
|
{t("seerr.request_button")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
details?.mediaInfo?.jellyfinMediaId && (
|
details?.mediaInfo?.jellyfinMediaId && (
|
||||||
@@ -353,7 +350,7 @@ const Page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-sm'>
|
<Text className='text-sm'>
|
||||||
{t("jellyseerr.report_issue_button")}
|
{t("seerr.report_issue_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -389,12 +386,12 @@ const Page: React.FC = () => {
|
|||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||||
<Text className='text-sm text-neutral-400'>
|
<Text className='text-sm text-neutral-400'>
|
||||||
{t("jellyseerr.requested_by", {
|
{t("seerr.requested_by", {
|
||||||
user:
|
user:
|
||||||
pendingRequest.requestedBy?.displayName ||
|
pendingRequest.requestedBy?.displayName ||
|
||||||
pendingRequest.requestedBy?.username ||
|
pendingRequest.requestedBy?.username ||
|
||||||
pendingRequest.requestedBy?.jellyfinUsername ||
|
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||||
t("jellyseerr.unknown_user"),
|
t("seerr.unknown_user"),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -415,7 +412,7 @@ const Page: React.FC = () => {
|
|||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
<Text className='text-sm'>{t("seerr.approve")}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||||
@@ -433,7 +430,7 @@ const Page: React.FC = () => {
|
|||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
<Text className='text-sm'>{t("seerr.decline")}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -442,7 +439,7 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{mediaType === MediaType.TV && (
|
{mediaType === MediaType.TV && (
|
||||||
<JellyseerrSeasons
|
<SeerrSeasons
|
||||||
isLoading={isLoading || isFetching}
|
isLoading={isLoading || isFetching}
|
||||||
details={details as TvDetails}
|
details={details as TvDetails}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
@@ -491,13 +488,13 @@ const Page: React.FC = () => {
|
|||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
{t("jellyseerr.whats_wrong")}
|
{t("seerr.whats_wrong")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<View className='flex flex-col w-full'>
|
<View className='flex flex-col w-full'>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
{t("jellyseerr.issue_type")}
|
{t("seerr.issue_type")}
|
||||||
</Text>
|
</Text>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={issueTypeOptionGroups}
|
groups={issueTypeOptionGroups}
|
||||||
@@ -506,11 +503,11 @@ const Page: React.FC = () => {
|
|||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{issueType
|
{issueType
|
||||||
? IssueTypeName[issueType]
|
? IssueTypeName[issueType]
|
||||||
: t("jellyseerr.select_an_issue")}
|
: t("seerr.select_an_issue")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title={t("jellyseerr.types")}
|
title={t("seerr.types")}
|
||||||
open={issueTypeDropdownOpen}
|
open={issueTypeDropdownOpen}
|
||||||
onOpenChange={setIssueTypeDropdownOpen}
|
onOpenChange={setIssueTypeDropdownOpen}
|
||||||
/>
|
/>
|
||||||
@@ -522,7 +519,7 @@ const Page: React.FC = () => {
|
|||||||
maxLength={254}
|
maxLength={254}
|
||||||
style={{ color: "white" }}
|
style={{ color: "white" }}
|
||||||
clearButtonMode='always'
|
clearButtonMode='always'
|
||||||
placeholder={t("jellyseerr.describe_the_issue")}
|
placeholder={t("seerr.describe_the_issue")}
|
||||||
placeholderTextColor='#9CA3AF'
|
placeholderTextColor='#9CA3AF'
|
||||||
// Issue with multiline + Textinput inside a portal
|
// Issue with multiline + Textinput inside a portal
|
||||||
// https://github.com/callstack/react-native-paper/issues/1668
|
// https://github.com/callstack/react-native-paper/issues/1668
|
||||||
@@ -532,7 +529,7 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||||
{t("jellyseerr.submit_button")}
|
{t("seerr.submit_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
@@ -5,31 +5,27 @@ import { orderBy, uniqBy } from "lodash";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
|
||||||
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
|
|
||||||
export default function page() {
|
export default function PersonPage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const {
|
const { seerrApi, seerrLocale: locale } = useSeerr();
|
||||||
jellyseerrApi,
|
|
||||||
jellyseerrRegion: region,
|
|
||||||
jellyseerrLocale: locale,
|
|
||||||
} = useJellyseerr();
|
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["jellyseerr", "person", personId],
|
queryKey: ["seerr", "person", personId],
|
||||||
queryFn: async () => ({
|
queryFn: async () => ({
|
||||||
details: await jellyseerrApi?.personDetails(personId),
|
details: await seerrApi?.personDetails(personId),
|
||||||
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
|
combinedCredits: await seerrApi?.personCombinedCredits(personId),
|
||||||
}),
|
}),
|
||||||
enabled: !!jellyseerrApi && !!personId,
|
enabled: !!seerrApi && !!personId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const castedRoles: PersonCreditCast[] = useMemo(
|
const castedRoles: PersonCreditCast[] = useMemo(
|
||||||
@@ -46,22 +42,19 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() =>
|
() =>
|
||||||
jellyseerrApi
|
seerrApi
|
||||||
? castedRoles.map((c) =>
|
? castedRoles.map((c) =>
|
||||||
jellyseerrApi.imageProxy(
|
seerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"),
|
||||||
c.backdropPath,
|
|
||||||
"w1920_and_h800_multi_faces",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
[jellyseerrApi, data?.combinedCredits],
|
[seerrApi, data?.combinedCredits],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParallaxSlideShow
|
<ParallaxSlideShow
|
||||||
data={castedRoles}
|
data={castedRoles}
|
||||||
images={backdrops}
|
images={backdrops}
|
||||||
listHeader={t("jellyseerr.appearances")}
|
listHeader={t("seerr.appearances")}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
logo={
|
logo={
|
||||||
<Image
|
<Image
|
||||||
@@ -69,7 +62,7 @@ export default function page() {
|
|||||||
id={data?.details?.id.toString()}
|
id={data?.details?.id.toString()}
|
||||||
className='rounded-full bottom-1'
|
className='rounded-full bottom-1'
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(
|
uri: seerrApi?.imageProxy(
|
||||||
data?.details?.profilePath,
|
data?.details?.profilePath,
|
||||||
"w600_and_h600_bestv2",
|
"w600_and_h600_bestv2",
|
||||||
),
|
),
|
||||||
@@ -86,16 +79,13 @@ export default function page() {
|
|||||||
<>
|
<>
|
||||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||||
<Text className='opacity-50'>
|
<Text className='opacity-50'>
|
||||||
{t("jellyseerr.born")}{" "}
|
{t("seerr.born")}{" "}
|
||||||
{data?.details?.birthday &&
|
{data?.details?.birthday &&
|
||||||
new Date(data.details.birthday).toLocaleDateString(
|
new Date(data.details.birthday).toLocaleDateString(locale, {
|
||||||
`${locale}-${region}`,
|
year: "numeric",
|
||||||
{
|
month: "long",
|
||||||
year: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
})}{" "}
|
||||||
day: "numeric",
|
|
||||||
},
|
|
||||||
)}{" "}
|
|
||||||
| {data?.details?.placeOfBirth}
|
| {data?.details?.placeOfBirth}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@@ -103,7 +93,7 @@ export default function page() {
|
|||||||
MainContent={() => (
|
MainContent={() => (
|
||||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||||
)}
|
)}
|
||||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
renderItem={(item, _index) => <SeerrPoster item={item} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,17 +33,17 @@ export default function SearchLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
|
<Stack.Screen name='seerr/page' options={commonScreenOptions} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='jellyseerr/person/[personId]'
|
name='seerr/person/[personId]'
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='jellyseerr/company/[companyId]'
|
name='seerr/company/[companyId]'
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='jellyseerr/genre/[genreId]'
|
name='seerr/genre/[genreId]'
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -26,18 +26,18 @@ import { Input } from "@/components/common/Input";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import {
|
|
||||||
JellyseerrSearchSort,
|
|
||||||
JellyserrIndexPage,
|
|
||||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
|
import {
|
||||||
|
SeerrIndexPage,
|
||||||
|
SeerrSearchSort,
|
||||||
|
} from "@/components/seerr/SeerrIndexPage";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -55,7 +55,7 @@ const exampleSearches = [
|
|||||||
"The Mandalorian",
|
"The Mandalorian",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function search() {
|
export default function SearchPage() {
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -93,16 +93,11 @@ export default function search() {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { seerrApi } = useSeerr();
|
||||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
const [seerrOrderBy, setSeerrOrderBy] = useState<SeerrSearchSort>(
|
||||||
useState<JellyseerrSearchSort>(
|
SeerrSearchSort[SeerrSearchSort.DEFAULT] as unknown as SeerrSearchSort,
|
||||||
JellyseerrSearchSort[
|
);
|
||||||
JellyseerrSearchSort.DEFAULT
|
const [seerrSortOrder, setSeerrSortOrder] = useState<"asc" | "desc">("desc");
|
||||||
] as unknown as JellyseerrSearchSort,
|
|
||||||
);
|
|
||||||
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
|
|
||||||
"asc" | "desc"
|
|
||||||
>("desc");
|
|
||||||
|
|
||||||
const searchEngine = useMemo(() => {
|
const searchEngine = useMemo(() => {
|
||||||
return settings?.searchEngine || "Jellyfin";
|
return settings?.searchEngine || "Jellyfin";
|
||||||
@@ -474,7 +469,7 @@ export default function search() {
|
|||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
{jellyseerrApi && (
|
{seerrApi && (
|
||||||
<View className='pl-4 pr-4 flex flex-row'>
|
<View className='pl-4 pr-4 flex flex-row'>
|
||||||
<SearchTabButtons
|
<SearchTabButtons
|
||||||
searchType={searchType}
|
searchType={searchType}
|
||||||
@@ -488,10 +483,10 @@ export default function search() {
|
|||||||
<DiscoverFilters
|
<DiscoverFilters
|
||||||
searchFilterId={searchFilterId}
|
searchFilterId={searchFilterId}
|
||||||
orderFilterId={orderFilterId}
|
orderFilterId={orderFilterId}
|
||||||
jellyseerrOrderBy={jellyseerrOrderBy}
|
seerrOrderBy={seerrOrderBy}
|
||||||
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
setSeerrOrderBy={setSeerrOrderBy}
|
||||||
jellyseerrSortOrder={jellyseerrSortOrder}
|
seerrSortOrder={seerrSortOrder}
|
||||||
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
setSeerrSortOrder={setSeerrSortOrder}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -754,10 +749,10 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<JellyserrIndexPage
|
<SeerrIndexPage
|
||||||
searchQuery={debouncedSearch}
|
searchQuery={debouncedSearch}
|
||||||
sortType={jellyseerrOrderBy}
|
sortType={seerrOrderBy}
|
||||||
order={jellyseerrSortOrder}
|
order={seerrSortOrder}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
@@ -72,9 +71,6 @@ export default function page() {
|
|||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [isPipMode, setIsPipMode] = useState(false);
|
const [isPipMode, setIsPipMode] = useState(false);
|
||||||
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
|
|
||||||
"default",
|
|
||||||
);
|
|
||||||
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
|
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
@@ -505,31 +501,6 @@ export default function page() {
|
|||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
return ticksToSeconds(getInitialPlaybackTicks());
|
||||||
}, [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 */
|
/** Build video source config for MPV */
|
||||||
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
|
||||||
if (!stream?.url) return undefined;
|
if (!stream?.url) return undefined;
|
||||||
@@ -958,7 +929,6 @@ export default function page() {
|
|||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
nowPlayingMetadata={nowPlayingMetadata}
|
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onPlaybackStateChange={onPlaybackStateChanged}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
onLoad={() => setIsVideoLoaded(true)}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
@@ -1008,7 +978,6 @@ export default function page() {
|
|||||||
pause={pause}
|
pause={pause}
|
||||||
seek={seek}
|
seek={seek}
|
||||||
enableTrickplay={true}
|
enableTrickplay={true}
|
||||||
aspectRatio={aspectRatio}
|
|
||||||
isZoomedToFill={isZoomedToFill}
|
isZoomedToFill={isZoomedToFill}
|
||||||
onZoomToggle={handleZoomToggle}
|
onZoomToggle={handleZoomToggle}
|
||||||
api={api}
|
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,4 +1,3 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
export * from "./string";
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ declare global {
|
|||||||
bytesToReadable(decimals?: number): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
hoursToMilliseconds(): number;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
|
|||||||
return this.valueOf() * (60).secondsToMilliseconds();
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
};
|
};
|
||||||
|
|
||||||
Number.prototype.hoursToMilliseconds = function () {
|
|
||||||
return this.valueOf() * (60).minutesToMilliseconds();
|
|
||||||
};
|
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.90.20",
|
"@tanstack/react-query": "5.90.17",
|
||||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~54.0.31",
|
"expo": "~54.0.31",
|
||||||
@@ -52,12 +52,12 @@
|
|||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"i18next": "^25.0.0",
|
"i18next": "^25.0.0",
|
||||||
"jotai": "2.16.2",
|
"jotai": "2.16.2",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "16.5.4",
|
"react-i18next": "16.5.3",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.1.0",
|
"react-native-bottom-tabs": "1.1.0",
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.6",
|
"@babel/core": "7.28.6",
|
||||||
"@biomejs/biome": "2.3.11",
|
"@biomejs/biome": "2.3.11",
|
||||||
"@react-native-community/cli": "20.1.1",
|
"@react-native-community/cli": "20.1.0",
|
||||||
"@react-native-tvos/config-tv": "0.1.4",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.23",
|
"@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=="],
|
"@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.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": ["@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-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-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-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": ["@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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-types": ["@react-native-community/cli-types@20.1.1", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-Tp+s27I/RDONrGvWVj4IzEmga2HhJhXi8ZlZTfycMMyAcv4LG/CTPira+BUZs8nzLAJNrlJ79pVVPJPqQAe+aw=="],
|
"@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/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
|
"@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-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.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
|
"@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-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=="],
|
"@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=="],
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
|
|
||||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
"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-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
||||||
|
|
||||||
"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-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-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
||||||
|
|
||||||
@@ -1854,8 +1854,6 @@
|
|||||||
|
|
||||||
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
|
"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-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=="],
|
"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=="],
|
||||||
@@ -2244,7 +2242,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=="],
|
"@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.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.17", "", {}, "sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* Example Usage of Global Modal
|
|
||||||
*
|
|
||||||
* This file demonstrates how to use the global modal system from anywhere in your app.
|
|
||||||
* You can delete this file after understanding how it works.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 1: Simple Content Modal
|
|
||||||
*/
|
|
||||||
export const SimpleModalExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6'>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
|
||||||
<Text className='text-white mb-4'>
|
|
||||||
This is a simple modal with just some text content.
|
|
||||||
</Text>
|
|
||||||
<Text className='text-neutral-400'>
|
|
||||||
Swipe down or tap outside to close.
|
|
||||||
</Text>
|
|
||||||
</View>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-purple-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 2: Modal with Custom Snap Points
|
|
||||||
*/
|
|
||||||
export const CustomSnapPointsExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6' style={{ minHeight: 400 }}>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
|
||||||
Custom Snap Points
|
|
||||||
</Text>
|
|
||||||
<Text className='text-white mb-4'>
|
|
||||||
This modal has custom snap points (25%, 50%, 90%).
|
|
||||||
</Text>
|
|
||||||
<View className='bg-neutral-800 p-4 rounded-lg'>
|
|
||||||
<Text className='text-white'>
|
|
||||||
Try dragging the modal to different heights!
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>,
|
|
||||||
{
|
|
||||||
snapPoints: ["25%", "50%", "90%"],
|
|
||||||
enableDynamicSizing: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-blue-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 3: Complex Component in Modal
|
|
||||||
*/
|
|
||||||
const SettingsModalContent = () => {
|
|
||||||
const { hideModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const settings = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: "Notifications",
|
|
||||||
icon: "notifications-outline" as const,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Auto-play",
|
|
||||||
icon: "play-outline" as const,
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='p-6'>
|
|
||||||
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
|
||||||
|
|
||||||
{settings.map((setting, index) => (
|
|
||||||
<View
|
|
||||||
key={setting.id}
|
|
||||||
className={`flex-row items-center justify-between py-4 ${
|
|
||||||
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className='flex-row items-center gap-3'>
|
|
||||||
<Ionicons name={setting.icon} size={24} color='white' />
|
|
||||||
<Text className='text-white text-lg'>{setting.title}</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`w-12 h-7 rounded-full ${
|
|
||||||
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
|
||||||
setting.enabled ? "translate-x-6" : "translate-x-1"
|
|
||||||
}`}
|
|
||||||
style={{ marginTop: 4 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={hideModal}
|
|
||||||
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold text-center'>Close</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComplexModalExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(<SettingsModalContent />);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-green-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Complex Component</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 4: Modal Triggered from Function (e.g., API response)
|
|
||||||
*/
|
|
||||||
export const useShowSuccessModal = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
return (message: string) => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6 items-center'>
|
|
||||||
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
|
||||||
<Ionicons name='checkmark' size={48} color='white' />
|
|
||||||
</View>
|
|
||||||
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
|
||||||
<Text className='text-white text-center'>{message}</Text>
|
|
||||||
</View>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main Demo Component
|
|
||||||
*/
|
|
||||||
export const GlobalModalDemo = () => {
|
|
||||||
const showSuccess = useShowSuccessModal();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='p-6 gap-4'>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
|
||||||
Global Modal Examples
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SimpleModalExample />
|
|
||||||
<CustomSnapPointsExample />
|
|
||||||
<ComplexModalExample />
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => showSuccess("Operation completed successfully!")}
|
|
||||||
className='bg-orange-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -89,16 +89,16 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-row items-center mt-4'>
|
<View className='flex flex-row items-center mt-4'>
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
source={require("@/assets/icons/seerr-logo.svg")}
|
||||||
style={{
|
style={{
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View className='shrink ml-2'>
|
<View className='shrink ml-2'>
|
||||||
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
<Text className='font-bold mb-1'>Seerr</Text>
|
||||||
<Text className='shrink text-xs'>
|
<Text className='shrink text-xs'>
|
||||||
{t("home.intro.jellyseerr_feature_description")}
|
{t("home.intro.seerr_feature_description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -158,12 +158,12 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
|||||||
</View>
|
</View>
|
||||||
<View className='shrink ml-2'>
|
<View className='shrink ml-2'>
|
||||||
<Text className='font-bold mb-1'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.intro.centralised_settings_plugin_title")}
|
{t("home.intro.centralized_settings_plugin_title")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex-row flex-wrap items-baseline'>
|
<View className='flex-row flex-wrap items-baseline'>
|
||||||
<Text className='shrink text-xs'>
|
<Text className='shrink text-xs'>
|
||||||
{t(
|
{t(
|
||||||
"home.intro.centralised_settings_plugin_description",
|
"home.intro.centralized_settings_plugin_description",
|
||||||
)}{" "}
|
)}{" "}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
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 {
|
interface Props extends ViewProps {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
actorName?: string | null;
|
actorName?: string | null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import type {
|
import type {
|
||||||
@@ -55,23 +55,23 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const JellyserrRatings: React.FC<{
|
export const SeerrRatings: React.FC<{
|
||||||
result: MovieResult | TvResult | TvDetails | MovieDetails;
|
result: MovieResult | TvResult | TvDetails | MovieDetails;
|
||||||
}> = ({ result }) => {
|
}> = ({ result }) => {
|
||||||
const { jellyseerrApi, getMediaType } = useJellyseerr();
|
const { seerrApi, getMediaType } = useSeerr();
|
||||||
|
|
||||||
const mediaType = useMemo(() => getMediaType(result), [result]);
|
const mediaType = useMemo(() => getMediaType(result), [result]);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
|
queryKey: ["seerr", result.id, mediaType, "ratings"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return mediaType === MediaType.MOVIE
|
return mediaType === MediaType.MOVIE
|
||||||
? jellyseerrApi?.movieRatings(result.id)
|
? seerrApi?.movieRatings(result.id)
|
||||||
: jellyseerrApi?.tvRatings(result.id);
|
: seerrApi?.tvRatings(result.id);
|
||||||
},
|
},
|
||||||
staleTime: (5).minutesToMilliseconds(),
|
staleTime: (5).minutesToMilliseconds(),
|
||||||
retry: false,
|
retry: false,
|
||||||
enabled: !!jellyseerrApi,
|
enabled: !!seerrApi,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
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 { HorizontalScroll } from "./common/HorizontalScroll";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
|
|
||||||
const streams = useMemo(
|
const streams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||||
[source],
|
[source, streamType],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSteam = useMemo(
|
const selectedSteam = useMemo(
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -21,7 +21,7 @@ interface Props extends TouchableOpacityProps {
|
|||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
result,
|
result,
|
||||||
mediaTitle,
|
mediaTitle,
|
||||||
releaseYear,
|
releaseYear,
|
||||||
@@ -42,18 +42,24 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
router.push({
|
// Build URL with query params - avoids Expo Router's strict type checking
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
const params = new URLSearchParams({
|
||||||
// @ts-expect-error
|
...Object.fromEntries(
|
||||||
params: {
|
Object.entries(result).map(([key, value]) => [
|
||||||
...result,
|
key,
|
||||||
mediaTitle,
|
String(value ?? ""),
|
||||||
releaseYear,
|
]),
|
||||||
canRequest: canRequest.toString(),
|
),
|
||||||
posterSrc,
|
mediaTitle,
|
||||||
mediaType,
|
releaseYear: releaseYear.toString(),
|
||||||
},
|
canRequest: canRequest.toString(),
|
||||||
|
posterSrc,
|
||||||
|
mediaType: mediaType.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.push(
|
||||||
|
`/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/page?${params.toString()}`,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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,29 +569,31 @@ export const HomeWithCarousel = () => {
|
|||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
settings.streamyStatsPromotedWatchlists;
|
settings.streamyStatsPromotedWatchlists;
|
||||||
const streamystatsSections =
|
const streamystatsSections =
|
||||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
index === streamystatsIndex && hasStreamystatsContent
|
||||||
<>
|
? [
|
||||||
{settings.streamyStatsMovieRecommendations && (
|
settings.streamyStatsMovieRecommendations && (
|
||||||
<StreamystatsRecommendations
|
<StreamystatsRecommendations
|
||||||
title={t(
|
key='movie-recommendations'
|
||||||
"home.settings.plugins.streamystats.recommended_movies",
|
title={t(
|
||||||
)}
|
"home.settings.plugins.streamystats.recommended_movies",
|
||||||
type='Movie'
|
)}
|
||||||
/>
|
type='Movie'
|
||||||
)}
|
/>
|
||||||
{settings.streamyStatsSeriesRecommendations && (
|
),
|
||||||
<StreamystatsRecommendations
|
settings.streamyStatsSeriesRecommendations && (
|
||||||
title={t(
|
<StreamystatsRecommendations
|
||||||
"home.settings.plugins.streamystats.recommended_series",
|
key='series-recommendations'
|
||||||
)}
|
title={t(
|
||||||
type='Series'
|
"home.settings.plugins.streamystats.recommended_series",
|
||||||
/>
|
)}
|
||||||
)}
|
type='Series'
|
||||||
{settings.streamyStatsPromotedWatchlists && (
|
/>
|
||||||
<StreamystatsPromotedWatchlists />
|
),
|
||||||
)}
|
settings.streamyStatsPromotedWatchlists && (
|
||||||
</>
|
<StreamystatsPromotedWatchlists key='promoted-watchlists' />
|
||||||
) : null;
|
),
|
||||||
|
].filter(Boolean)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -247,15 +247,14 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View {...props}>
|
||||||
{watchlists?.map((watchlist) => (
|
{watchlists?.map((watchlist) => (
|
||||||
<WatchlistSection
|
<WatchlistSection
|
||||||
key={watchlist.id}
|
key={watchlist.id}
|
||||||
watchlist={watchlist}
|
watchlist={watchlist}
|
||||||
jellyfinServerId={jellyfinServerId!}
|
jellyfinServerId={jellyfinServerId!}
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
api,
|
api,
|
||||||
item: library,
|
item: library,
|
||||||
}),
|
}),
|
||||||
[library],
|
[api, library],
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemType = useMemo(() => {
|
const itemType = useMemo(() => {
|
||||||
|
|||||||
@@ -23,10 +23,8 @@ export const MusicAlbumCard: React.FC<Props> = ({ album, width = 130 }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
router.push({
|
if (!album.Id) return;
|
||||||
pathname: "/music/album/[albumId]",
|
router.push(`/music/album/${album.Id}`);
|
||||||
params: { albumId: album.Id! },
|
|
||||||
});
|
|
||||||
}, [router, album.Id]);
|
}, [router, album.Id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,10 +24,8 @@ export const MusicAlbumRowCard: React.FC<Props> = ({ album }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
router.push({
|
if (!album.Id) return;
|
||||||
pathname: "/music/album/[albumId]",
|
router.push(`/music/album/${album.Id}`);
|
||||||
params: { albumId: album.Id! },
|
|
||||||
});
|
|
||||||
}, [router, album.Id]);
|
}, [router, album.Id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ export const MusicArtistCard: React.FC<Props> = ({ artist }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
router.push({
|
if (!artist.Id) return;
|
||||||
pathname: "/music/artist/[artistId]",
|
router.push(`/music/artist/${artist.Id}`);
|
||||||
params: { artistId: artist.Id! },
|
|
||||||
});
|
|
||||||
}, [router, artist.Id]);
|
}, [router, artist.Id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ export const MusicPlaylistCard: React.FC<Props> = ({ playlist }) => {
|
|||||||
const hasDownloads = downloadStatus.downloaded > 0;
|
const hasDownloads = downloadStatus.downloaded > 0;
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
router.push({
|
router.push(`/music/playlist/${playlist.Id}`);
|
||||||
pathname: "/music/playlist/[playlistId]",
|
|
||||||
params: { playlistId: playlist.Id! },
|
|
||||||
});
|
|
||||||
}, [router, playlist.Id]);
|
}, [router, playlist.Id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -197,10 +197,7 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
|||||||
const artistId = track?.ArtistItems?.[0]?.Id;
|
const artistId = track?.ArtistItems?.[0]?.Id;
|
||||||
if (artistId) {
|
if (artistId) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
router.push({
|
router.push(`/music/artist/${artistId}`);
|
||||||
pathname: "/music/artist/[artistId]",
|
|
||||||
params: { artistId },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [track?.ArtistItems, router, setOpen]);
|
}, [track?.ArtistItems, router, setOpen]);
|
||||||
|
|
||||||
@@ -208,10 +205,7 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
|||||||
const albumId = track?.AlbumId || track?.ParentId;
|
const albumId = track?.AlbumId || track?.ParentId;
|
||||||
if (albumId) {
|
if (albumId) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
router.push({
|
router.push(`/music/album/${albumId}`);
|
||||||
pathname: "/music/album/[albumId]",
|
|
||||||
params: { albumId },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [track?.AlbumId, track?.ParentId, router, setOpen]);
|
}, [track?.AlbumId, track?.ParentId, router, setOpen]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
// 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} />;
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -7,15 +7,15 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
|
import { TouchableSeerrRouter } from "@/components/common/SeerrItemRouter";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Tag, Tags } from "@/components/GenreTags";
|
import { Tag, Tags } from "@/components/GenreTags";
|
||||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
|
||||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
import SeerrMediaIcon from "@/components/seerr/SeerrMediaIcon";
|
||||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
|
||||||
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
|
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
|
||||||
@@ -34,13 +34,13 @@ interface Props extends ViewProps {
|
|||||||
mediaRequest?: MediaRequest;
|
mediaRequest?: MediaRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JellyseerrPoster: React.FC<Props> = ({
|
const SeerrPoster: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
horizontal,
|
horizontal,
|
||||||
showDownloadInfo,
|
showDownloadInfo,
|
||||||
mediaRequest,
|
mediaRequest,
|
||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
|
const { seerrApi, getTitle, getYear, getMediaType } = useSeerr();
|
||||||
const loadingOpacity = useSharedValue(1);
|
const loadingOpacity = useSharedValue(1);
|
||||||
const imageOpacity = useSharedValue(0);
|
const imageOpacity = useSharedValue(0);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -56,16 +56,13 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
|
|
||||||
const backdropSrc = useMemo(
|
const backdropSrc = useMemo(
|
||||||
() =>
|
() =>
|
||||||
jellyseerrApi?.imageProxy(
|
seerrApi?.imageProxy(item?.backdropPath, "w1920_and_h800_multi_faces"),
|
||||||
item?.backdropPath,
|
[item, seerrApi, horizontal],
|
||||||
"w1920_and_h800_multi_faces",
|
|
||||||
),
|
|
||||||
[item, jellyseerrApi, horizontal],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const posterSrc = useMemo(
|
const posterSrc = useMemo(
|
||||||
() => jellyseerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
|
() => seerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
|
||||||
[item, jellyseerrApi, horizontal],
|
[item, seerrApi, horizontal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const title = useMemo(() => getTitle(item), [item]);
|
const title = useMemo(() => getTitle(item), [item]);
|
||||||
@@ -75,7 +72,7 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
const size = useMemo(() => (horizontal ? "h-28" : "w-28"), [horizontal]);
|
const size = useMemo(() => (horizontal ? "h-28" : "w-28"), [horizontal]);
|
||||||
const ratio = useMemo(() => (horizontal ? "15/10" : "10/15"), [horizontal]);
|
const ratio = useMemo(() => (horizontal ? "15/10" : "10/15"), [horizontal]);
|
||||||
|
|
||||||
const [canRequest] = useJellyseerrCanRequest(item);
|
const [canRequest] = useSeerrCanRequest(item);
|
||||||
|
|
||||||
const is4k = useMemo(() => mediaRequest?.is4k === true, [mediaRequest]);
|
const is4k = useMemo(() => mediaRequest?.is4k === true, [mediaRequest]);
|
||||||
|
|
||||||
@@ -109,7 +106,7 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
second,
|
second,
|
||||||
third,
|
third,
|
||||||
fourth,
|
fourth,
|
||||||
t("home.settings.plugins.jellyseerr.plus_n_more", { n: rest.length }),
|
t("home.settings.plugins.seerr.plus_n_more", { n: rest.length }),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return seasons;
|
return seasons;
|
||||||
@@ -121,7 +118,7 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
}, [mediaRequest, is4k]);
|
}, [mediaRequest, is4k]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableJellyseerrRouter
|
<TouchableSeerrRouter
|
||||||
result={item}
|
result={item}
|
||||||
mediaTitle={title}
|
mediaTitle={title}
|
||||||
releaseYear={releaseYear}
|
releaseYear={releaseYear}
|
||||||
@@ -173,7 +170,7 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
className='absolute right-1 top-1 text-right bg-black border border-neutral-800/50'
|
className='absolute right-1 top-1 text-right bg-black border border-neutral-800/50'
|
||||||
text={mediaRequest?.requestedBy.displayName}
|
text={mediaRequest?.requestedBy.displayName}
|
||||||
/>
|
/>
|
||||||
{requestedSeasons.length > 0 && (
|
{(requestedSeasons?.length ?? 0) > 0 && (
|
||||||
<Tags
|
<Tags
|
||||||
className='absolute bottom-1 left-0.5 w-32'
|
className='absolute bottom-1 left-0.5 w-32'
|
||||||
tagProps={{
|
tagProps={{
|
||||||
@@ -184,12 +181,12 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<JellyseerrStatusIcon
|
<SeerrStatusIcon
|
||||||
className='absolute bottom-1 right-1'
|
className='absolute bottom-1 right-1'
|
||||||
showRequestIcon={canRequest}
|
showRequestIcon={canRequest}
|
||||||
mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
|
mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
|
||||||
/>
|
/>
|
||||||
<JellyseerrMediaIcon
|
<SeerrMediaIcon
|
||||||
className='absolute top-1 left-1'
|
className='absolute top-1 left-1'
|
||||||
mediaType={mediaType}
|
mediaType={mediaType}
|
||||||
/>
|
/>
|
||||||
@@ -201,8 +198,8 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
{releaseYear || ""}
|
{releaseYear || ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableJellyseerrRouter>
|
</TouchableSeerrRouter>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default JellyseerrPoster;
|
export default SeerrPoster;
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
import { SeerrSearchSort } from "@/components/seerr/SeerrIndexPage";
|
||||||
|
|
||||||
interface DiscoverFiltersProps {
|
interface DiscoverFiltersProps {
|
||||||
searchFilterId: string;
|
searchFilterId: string;
|
||||||
orderFilterId: string;
|
orderFilterId: string;
|
||||||
jellyseerrOrderBy: JellyseerrSearchSort;
|
seerrOrderBy: SeerrSearchSort;
|
||||||
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
|
setSeerrOrderBy: (value: SeerrSearchSort) => void;
|
||||||
jellyseerrSortOrder: "asc" | "desc";
|
seerrSortOrder: "asc" | "desc";
|
||||||
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
|
setSeerrSortOrder: (value: "asc" | "desc") => void;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
|
const sortOptions = Object.keys(SeerrSearchSort).filter((v) =>
|
||||||
Number.isNaN(Number(v)),
|
Number.isNaN(Number(v)),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -22,10 +22,10 @@ const orderOptions = ["asc", "desc"] as const;
|
|||||||
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||||
searchFilterId,
|
searchFilterId,
|
||||||
orderFilterId,
|
orderFilterId,
|
||||||
jellyseerrOrderBy,
|
seerrOrderBy,
|
||||||
setJellyseerrOrderBy,
|
setSeerrOrderBy,
|
||||||
jellyseerrSortOrder,
|
seerrSortOrder,
|
||||||
setJellyseerrSortOrder,
|
setSeerrSortOrder,
|
||||||
t,
|
t,
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
@@ -52,16 +52,16 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
|||||||
<Picker
|
<Picker
|
||||||
label={t("library.filters.sort_by")}
|
label={t("library.filters.sort_by")}
|
||||||
options={sortOptions.map((item) =>
|
options={sortOptions.map((item) =>
|
||||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
|
t(`home.settings.plugins.seerr.order_by.${item}`),
|
||||||
)}
|
)}
|
||||||
variant='menu'
|
variant='menu'
|
||||||
selectedIndex={sortOptions.indexOf(
|
selectedIndex={sortOptions.indexOf(
|
||||||
jellyseerrOrderBy as unknown as string,
|
seerrOrderBy as unknown as string,
|
||||||
)}
|
)}
|
||||||
onOptionSelected={(event: any) => {
|
onOptionSelected={(event: any) => {
|
||||||
const index = event.nativeEvent.index;
|
const index = event.nativeEvent.index;
|
||||||
setJellyseerrOrderBy(
|
setSeerrOrderBy(
|
||||||
sortOptions[index] as unknown as JellyseerrSearchSort,
|
sortOptions[index] as unknown as SeerrSearchSort,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -69,10 +69,10 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
|||||||
label={t("library.filters.sort_order")}
|
label={t("library.filters.sort_order")}
|
||||||
options={orderOptions.map((item) => t(`library.filters.${item}`))}
|
options={orderOptions.map((item) => t(`library.filters.${item}`))}
|
||||||
variant='menu'
|
variant='menu'
|
||||||
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
|
selectedIndex={orderOptions.indexOf(seerrSortOrder)}
|
||||||
onOptionSelected={(event: any) => {
|
onOptionSelected={(event: any) => {
|
||||||
const index = event.nativeEvent.index;
|
const index = event.nativeEvent.index;
|
||||||
setJellyseerrSortOrder(orderOptions[index]);
|
setSeerrSortOrder(orderOptions[index]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ContextMenu.Items>
|
</ContextMenu.Items>
|
||||||
@@ -86,17 +86,15 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
|||||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={searchFilterId}
|
id={searchFilterId}
|
||||||
queryKey='jellyseerr_search'
|
queryKey='seerr_search'
|
||||||
queryFn={async () =>
|
queryFn={async () =>
|
||||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
Object.keys(SeerrSearchSort).filter((v) => Number.isNaN(Number(v)))
|
||||||
Number.isNaN(Number(v)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
set={(value) => setSeerrOrderBy(value[0])}
|
||||||
values={[jellyseerrOrderBy]}
|
values={[seerrOrderBy]}
|
||||||
title={t("library.filters.sort_by")}
|
title={t("library.filters.sort_by")}
|
||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
t(`home.settings.plugins.seerr.order_by.${item}`)
|
||||||
}
|
}
|
||||||
disableSearch={true}
|
disableSearch={true}
|
||||||
/>
|
/>
|
||||||
@@ -104,8 +102,8 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
|||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
queryKey='jellysearr_search'
|
queryKey='jellysearr_search'
|
||||||
queryFn={async () => ["asc", "desc"]}
|
queryFn={async () => ["asc", "desc"]}
|
||||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
set={(value) => setSeerrSortOrder(value[0])}
|
||||||
values={[jellyseerrSortOrder]}
|
values={[seerrSortOrder]}
|
||||||
title={t("library.filters.sort_order")}
|
title={t("library.filters.sort_order")}
|
||||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||||
disableSearch={true}
|
disableSearch={true}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
import PersonPoster from "@/components/seerr/PersonPoster";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
|
||||||
@@ -15,19 +15,17 @@ const CastSlide: React.FC<
|
|||||||
details?.credits?.cast &&
|
details?.credits?.cast &&
|
||||||
details?.credits?.cast?.length > 0 && (
|
details?.credits?.cast?.length > 0 && (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className='text-lg font-bold mb-2 px-4'>
|
<Text className='text-lg font-bold mb-2 px-4'>{t("seerr.cast")}</Text>
|
||||||
{t("jellyseerr.cast")}
|
|
||||||
</Text>
|
|
||||||
<FlashList
|
<FlashList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
data={details?.credits.cast}
|
data={details?.credits.cast}
|
||||||
ItemSeparatorComponent={() => <View className='w-2' />}
|
ItemSeparatorComponent={() => <View className='w-2' />}
|
||||||
keyExtractor={(item) => item?.id?.toString()}
|
keyExtractor={(item) => item?.id?.toString() ?? ""}
|
||||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<PersonPoster
|
<PersonPoster
|
||||||
id={item.id.toString()}
|
id={item?.id?.toString() ?? ""}
|
||||||
posterPath={item.profilePath}
|
posterPath={item.profilePath}
|
||||||
name={item.name}
|
name={item.name}
|
||||||
subName={item.character}
|
subName={item.character}
|
||||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import CountryFlag from "react-native-country-flag";
|
import CountryFlag from "react-native-country-flag";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
import type { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
|
import type { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
@@ -50,8 +50,7 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
|||||||
const DetailFacts: React.FC<
|
const DetailFacts: React.FC<
|
||||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||||
> = ({ details, className, ...props }) => {
|
> = ({ details, className, ...props }) => {
|
||||||
const { jellyseerrRegion: region, jellyseerrLocale: locale } =
|
const { seerrRegion: region, seerrLocale: locale } = useSeerr();
|
||||||
useJellyseerr();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const releases = useMemo(
|
const releases = useMemo(
|
||||||
@@ -59,7 +58,7 @@ const DetailFacts: React.FC<
|
|||||||
(details as MovieDetails)?.releases?.results.find(
|
(details as MovieDetails)?.releases?.results.find(
|
||||||
(r: TmdbRelease) => r.iso_3166_1 === region,
|
(r: TmdbRelease) => r.iso_3166_1 === region,
|
||||||
)?.release_dates as TmdbRelease["release_dates"],
|
)?.release_dates as TmdbRelease["release_dates"],
|
||||||
[details],
|
[details, region],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Release date types:
|
// Release date types:
|
||||||
@@ -81,40 +80,34 @@ const DetailFacts: React.FC<
|
|||||||
const firstAirDate = useMemo(() => {
|
const firstAirDate = useMemo(() => {
|
||||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||||
if (firstAirDate) {
|
if (firstAirDate) {
|
||||||
return new Date(firstAirDate).toLocaleDateString(
|
return new Date(firstAirDate).toLocaleDateString(locale, dateOpts);
|
||||||
`${locale}-${region}`,
|
|
||||||
dateOpts,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [details]);
|
}, [details, locale]);
|
||||||
|
|
||||||
const nextAirDate = useMemo(() => {
|
const nextAirDate = useMemo(() => {
|
||||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||||
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
|
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
|
||||||
if (nextAirDate && firstAirDate !== nextAirDate) {
|
if (nextAirDate && firstAirDate !== nextAirDate) {
|
||||||
return new Date(nextAirDate).toLocaleDateString(
|
return new Date(nextAirDate).toLocaleDateString(locale, dateOpts);
|
||||||
`${locale}-${region}`,
|
|
||||||
dateOpts,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [details]);
|
}, [details, locale]);
|
||||||
|
|
||||||
const revenue = useMemo(
|
const revenue = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(details as MovieDetails)?.revenue?.toLocaleString?.(
|
(details as MovieDetails)?.revenue?.toLocaleString?.(locale, {
|
||||||
`${locale}-${region}`,
|
style: "currency",
|
||||||
{ style: "currency", currency: "USD" },
|
currency: "USD",
|
||||||
),
|
}),
|
||||||
[details],
|
[details, locale],
|
||||||
);
|
);
|
||||||
|
|
||||||
const budget = useMemo(
|
const budget = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(details as MovieDetails)?.budget?.toLocaleString?.(
|
(details as MovieDetails)?.budget?.toLocaleString?.(locale, {
|
||||||
`${locale}-${region}`,
|
style: "currency",
|
||||||
{ style: "currency", currency: "USD" },
|
currency: "USD",
|
||||||
),
|
}),
|
||||||
[details],
|
[details, locale],
|
||||||
);
|
);
|
||||||
|
|
||||||
const streamingProviders = useMemo(
|
const streamingProviders = useMemo(
|
||||||
@@ -122,7 +115,7 @@ const DetailFacts: React.FC<
|
|||||||
details?.watchProviders?.find(
|
details?.watchProviders?.find(
|
||||||
(provider) => provider.iso_3166_1 === region,
|
(provider) => provider.iso_3166_1 === region,
|
||||||
)?.flatrate,
|
)?.flatrate,
|
||||||
[details],
|
[details, region],
|
||||||
);
|
);
|
||||||
|
|
||||||
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
|
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
|
||||||
@@ -138,21 +131,21 @@ const DetailFacts: React.FC<
|
|||||||
return (
|
return (
|
||||||
details && (
|
details && (
|
||||||
<View className='p-4'>
|
<View className='p-4'>
|
||||||
<Text className='text-lg font-bold'>{t("jellyseerr.details")}</Text>
|
<Text className='text-lg font-bold'>{t("seerr.details")}</Text>
|
||||||
<View
|
<View
|
||||||
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
|
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Fact title={t("jellyseerr.status")} fact={details?.status} />
|
<Fact title={t("seerr.status")} fact={details?.status} />
|
||||||
<Fact
|
<Fact
|
||||||
title={t("jellyseerr.original_title")}
|
title={t("seerr.original_title")}
|
||||||
fact={(details as TvDetails)?.originalName}
|
fact={(details as TvDetails)?.originalName}
|
||||||
/>
|
/>
|
||||||
{details.keywords.some(
|
{details.keywords.some(
|
||||||
(keyword) => keyword.id === ANIME_KEYWORD_ID,
|
(keyword) => keyword.id === ANIME_KEYWORD_ID,
|
||||||
) && <Fact title={t("jellyseerr.series_type")} fact='Anime' />}
|
) && <Fact title={t("seerr.series_type")} fact='Anime' />}
|
||||||
<Facts
|
<Facts
|
||||||
title={t("jellyseerr.release_dates")}
|
title={t("seerr.release_dates")}
|
||||||
facts={filteredReleases?.map?.((r: Release, idx) => (
|
facts={filteredReleases?.map?.((r: Release, idx) => (
|
||||||
<View key={idx} className='flex flex-row space-x-2 items-center'>
|
<View key={idx} className='flex flex-row space-x-2 items-center'>
|
||||||
{r.type === 3 ? (
|
{r.type === 3 ? (
|
||||||
@@ -171,23 +164,20 @@ const DetailFacts: React.FC<
|
|||||||
)}
|
)}
|
||||||
<Text>
|
<Text>
|
||||||
{new Date(r.release_date).toLocaleDateString(
|
{new Date(r.release_date).toLocaleDateString(
|
||||||
`${locale}-${region}`,
|
locale,
|
||||||
dateOpts,
|
dateOpts,
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
/>
|
/>
|
||||||
<Fact title={t("jellyseerr.first_air_date")} fact={firstAirDate} />
|
<Fact title={t("seerr.first_air_date")} fact={firstAirDate} />
|
||||||
<Fact title={t("jellyseerr.next_air_date")} fact={nextAirDate} />
|
<Fact title={t("seerr.next_air_date")} fact={nextAirDate} />
|
||||||
<Fact title={t("jellyseerr.revenue")} fact={revenue} />
|
<Fact title={t("seerr.revenue")} fact={revenue} />
|
||||||
<Fact title={t("jellyseerr.budget")} fact={budget} />
|
<Fact title={t("seerr.budget")} fact={budget} />
|
||||||
<Fact
|
<Fact title={t("seerr.original_language")} fact={spokenLanguage} />
|
||||||
title={t("jellyseerr.original_language")}
|
|
||||||
fact={spokenLanguage}
|
|
||||||
/>
|
|
||||||
<Facts
|
<Facts
|
||||||
title={t("jellyseerr.production_country")}
|
title={t("seerr.production_country")}
|
||||||
facts={details?.productionCountries?.map((n, idx) => (
|
facts={details?.productionCountries?.map((n, idx) => (
|
||||||
<View key={idx} className='flex flex-row items-center space-x-2'>
|
<View key={idx} className='flex flex-row items-center space-x-2'>
|
||||||
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
||||||
@@ -196,17 +186,17 @@ const DetailFacts: React.FC<
|
|||||||
))}
|
))}
|
||||||
/>
|
/>
|
||||||
<Facts
|
<Facts
|
||||||
title={t("jellyseerr.studios")}
|
title={t("seerr.studios")}
|
||||||
facts={uniqBy(details?.productionCompanies, "name")?.map(
|
facts={uniqBy(details?.productionCompanies, "name")?.map(
|
||||||
(n) => n.name,
|
(n) => n.name,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Facts
|
<Facts
|
||||||
title={t("jellyseerr.network")}
|
title={t("seerr.network")}
|
||||||
facts={networks?.map((n) => n.name)}
|
facts={networks?.map((n) => n.name)}
|
||||||
/>
|
/>
|
||||||
<Facts
|
<Facts
|
||||||
title={t("jellyseerr.currently_streaming_on")}
|
title={t("seerr.currently_streaming_on")}
|
||||||
facts={streamingProviders?.map((s) => s.name)}
|
facts={streamingProviders?.map((s) => s.name)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
import { View } from "react-native";
|
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.
|
// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
|
||||||
export const GridSkeleton: React.FC<Props> = ({ index }) => {
|
export const GridSkeleton = React.memo(() => {
|
||||||
return (
|
return (
|
||||||
<View
|
<View className='flex flex-col mr-2 h-auto' style={{ width: "30.5%" }}>
|
||||||
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='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='mt-2 flex flex-col w-full'>
|
||||||
<View className='h-4 bg-neutral-800 rounded mb-1' />
|
<View className='h-4 bg-neutral-800 rounded mb-1' />
|
||||||
@@ -18,4 +12,4 @@ export const GridSkeleton: React.FC<Props> = ({ index }) => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
@@ -133,7 +133,7 @@ const ParallaxSlideShow = <T,>({
|
|||||||
<View className='px-4'>
|
<View className='px-4'>
|
||||||
<View className='flex flex-row flex-wrap'>
|
<View className='flex flex-row flex-wrap'>
|
||||||
{Array.from({ length: 9 }, (_, i) => (
|
{Array.from({ length: 9 }, (_, i) => (
|
||||||
<GridSkeleton key={i} index={i} />
|
<GridSkeleton key={i} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -4,7 +4,7 @@ import { TouchableOpacity, View, type ViewProps } from "react-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import Poster from "@/components/posters/Poster";
|
import Poster from "@/components/posters/Poster";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,7 +20,7 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
|
|||||||
subName,
|
subName,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { seerrApi } = useSeerr();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
@@ -28,20 +28,20 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
|
|||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() => router.push(`/(auth)/(tabs)/${from}/seerr/person/${id}`)}
|
||||||
router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<View className='flex flex-col w-28' {...props}>
|
<View className='flex flex-col w-28' {...props}>
|
||||||
<Poster
|
<Poster
|
||||||
id={id}
|
id={id}
|
||||||
url={jellyseerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
|
url={seerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
|
||||||
/>
|
/>
|
||||||
<Text className='mt-2'>{name}</Text>
|
<Text className='mt-2'>{name}</Text>
|
||||||
{subName && <Text className='text-xs opacity-50'>{subName}</Text>}
|
{subName && <Text className='text-xs opacity-50'>{subName}</Text>}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PersonPoster;
|
export default PersonPoster;
|
||||||
@@ -12,7 +12,7 @@ import { View, type ViewProps } from "react-native";
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import type {
|
import type {
|
||||||
QualityProfile,
|
QualityProfile,
|
||||||
RootFolder,
|
RootFolder,
|
||||||
@@ -38,14 +38,23 @@ const RequestModal = forwardRef<
|
|||||||
Props & Omit<ViewProps, "id">
|
Props & Omit<ViewProps, "id">
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
|
{
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
requestBody,
|
||||||
|
type,
|
||||||
|
isAnime = false,
|
||||||
|
is4k,
|
||||||
|
onRequested,
|
||||||
|
onDismiss,
|
||||||
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { seerrApi, seerrUser, requestMedia } = useSeerr();
|
||||||
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||||
mediaId: Number(id),
|
mediaId: Number(id),
|
||||||
mediaType: type,
|
mediaType: type,
|
||||||
userId: jellyseerrUser?.id,
|
userId: seerrUser?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
|
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
|
||||||
@@ -65,18 +74,17 @@ const RequestModal = forwardRef<
|
|||||||
}, [onDismiss]);
|
}, [onDismiss]);
|
||||||
|
|
||||||
const { data: serviceSettings } = useQuery({
|
const { data: serviceSettings } = useQuery({
|
||||||
queryKey: ["jellyseerr", "request", type, "service"],
|
queryKey: ["seerr", "request", type, "service"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
jellyseerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
|
seerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
|
||||||
enabled: !!jellyseerrApi && !!jellyseerrUser,
|
enabled: !!seerrApi && !!seerrUser,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: users } = useQuery({
|
const { data: users } = useQuery({
|
||||||
queryKey: ["jellyseerr", "users"],
|
queryKey: ["seerr", "users"],
|
||||||
queryFn: async () =>
|
queryFn: async () => seerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||||
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
|
enabled: !!seerrApi && !!seerrUser,
|
||||||
enabled: !!jellyseerrApi && !!jellyseerrUser,
|
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +95,7 @@ const RequestModal = forwardRef<
|
|||||||
|
|
||||||
const { data: defaultServiceDetails } = useQuery({
|
const { data: defaultServiceDetails } = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"jellyseerr",
|
"seerr",
|
||||||
"request",
|
"request",
|
||||||
type,
|
type,
|
||||||
"service",
|
"service",
|
||||||
@@ -99,12 +107,12 @@ const RequestModal = forwardRef<
|
|||||||
...prev,
|
...prev,
|
||||||
serverId: defaultService?.id,
|
serverId: defaultService?.id,
|
||||||
}));
|
}));
|
||||||
return jellyseerrApi?.serviceDetails(
|
return seerrApi?.serviceDetails(
|
||||||
type === "movie" ? "radarr" : "sonarr",
|
type === "movie" ? "radarr" : "sonarr",
|
||||||
defaultService!.id,
|
defaultService!.id,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
|
enabled: !!seerrApi && !!seerrUser && !!defaultService,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,9 +156,9 @@ const RequestModal = forwardRef<
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (requestBody.seasons.length > 1) {
|
if (requestBody.seasons.length > 1) {
|
||||||
return t("jellyseerr.season_all");
|
return t("seerr.season_all");
|
||||||
}
|
}
|
||||||
return t("jellyseerr.season_number", {
|
return t("seerr.season_number", {
|
||||||
season_number: requestBody.seasons[0],
|
season_number: requestBody.seasons[0],
|
||||||
});
|
});
|
||||||
}, [requestBody?.seasons]);
|
}, [requestBody?.seasons]);
|
||||||
@@ -245,8 +253,7 @@ const RequestModal = forwardRef<
|
|||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: user.displayName,
|
label: user.displayName,
|
||||||
value: user.id.toString(),
|
value: user.id.toString(),
|
||||||
selected:
|
selected: (requestOverrides.userId || seerrUser?.id) === user.id,
|
||||||
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
|
||||||
onPress: () =>
|
onPress: () =>
|
||||||
setRequestOverrides((prev) => ({
|
setRequestOverrides((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -255,12 +262,13 @@ const RequestModal = forwardRef<
|
|||||||
})) || [],
|
})) || [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[users, jellyseerrUser, requestOverrides.userId],
|
[users, seerrUser, requestOverrides.userId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const request = useCallback(() => {
|
const request = useCallback(() => {
|
||||||
const body = {
|
const body = {
|
||||||
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
is4k:
|
||||||
|
is4k ?? defaultService?.is4k ?? defaultServiceDetails?.server.is4k,
|
||||||
profileId: defaultProfile?.id,
|
profileId: defaultProfile?.id,
|
||||||
rootFolder: defaultFolder?.path,
|
rootFolder: defaultFolder?.path,
|
||||||
tags: defaultTags.map((t) => t.id),
|
tags: defaultTags.map((t) => t.id),
|
||||||
@@ -268,7 +276,7 @@ const RequestModal = forwardRef<
|
|||||||
...requestOverrides,
|
...requestOverrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
writeDebugLog("Sending Jellyseerr advanced request", body);
|
writeDebugLog("Sending Seerr advanced request", body);
|
||||||
|
|
||||||
requestMedia(
|
requestMedia(
|
||||||
seasonTitle ? `${title}, ${seasonTitle}` : title,
|
seasonTitle ? `${title}, ${seasonTitle}` : title,
|
||||||
@@ -276,11 +284,18 @@ const RequestModal = forwardRef<
|
|||||||
onRequested,
|
onRequested,
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
|
is4k,
|
||||||
|
defaultService?.is4k,
|
||||||
|
defaultServiceDetails?.server.is4k,
|
||||||
requestBody,
|
requestBody,
|
||||||
requestOverrides,
|
requestOverrides,
|
||||||
defaultProfile,
|
defaultProfile,
|
||||||
defaultFolder,
|
defaultFolder,
|
||||||
defaultTags,
|
defaultTags,
|
||||||
|
requestMedia,
|
||||||
|
seasonTitle,
|
||||||
|
title,
|
||||||
|
onRequested,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -308,7 +323,7 @@ const RequestModal = forwardRef<
|
|||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
{t("jellyseerr.advanced")}
|
{t("seerr.advanced")}
|
||||||
</Text>
|
</Text>
|
||||||
{seasonTitle && (
|
{seasonTitle && (
|
||||||
<Text className='text-neutral-300'>{seasonTitle}</Text>
|
<Text className='text-neutral-300'>{seasonTitle}</Text>
|
||||||
@@ -319,7 +334,7 @@ const RequestModal = forwardRef<
|
|||||||
<>
|
<>
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col'>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
{t("jellyseerr.quality_profile")}
|
{t("seerr.quality_profile")}
|
||||||
</Text>
|
</Text>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={qualityProfileOptions}
|
groups={qualityProfileOptions}
|
||||||
@@ -335,7 +350,7 @@ const RequestModal = forwardRef<
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title={t("jellyseerr.quality_profile")}
|
title={t("seerr.quality_profile")}
|
||||||
open={qualityProfileOpen}
|
open={qualityProfileOpen}
|
||||||
onOpenChange={setQualityProfileOpen}
|
onOpenChange={setQualityProfileOpen}
|
||||||
/>
|
/>
|
||||||
@@ -343,7 +358,7 @@ const RequestModal = forwardRef<
|
|||||||
|
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col'>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
{t("jellyseerr.root_folder")}
|
{t("seerr.root_folder")}
|
||||||
</Text>
|
</Text>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={rootFolderOptions}
|
groups={rootFolderOptions}
|
||||||
@@ -368,42 +383,45 @@ const RequestModal = forwardRef<
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title={t("jellyseerr.root_folder")}
|
title={t("seerr.root_folder")}
|
||||||
open={rootFolderOpen}
|
open={rootFolderOpen}
|
||||||
onOpenChange={setRootFolderOpen}
|
onOpenChange={setRootFolderOpen}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='flex flex-col'>
|
{defaultServiceDetails?.tags &&
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
defaultServiceDetails.tags.length > 0 && (
|
||||||
{t("jellyseerr.tags")}
|
<View className='flex flex-col'>
|
||||||
</Text>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
<PlatformDropdown
|
{t("seerr.tags")}
|
||||||
groups={tagsOptions}
|
</Text>
|
||||||
trigger={
|
<PlatformDropdown
|
||||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
groups={tagsOptions}
|
||||||
<Text numberOfLines={1}>
|
trigger={
|
||||||
{requestOverrides.tags
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
? defaultServiceDetails.tags
|
<Text numberOfLines={1}>
|
||||||
.filter((t) =>
|
{requestOverrides.tags
|
||||||
requestOverrides.tags!.includes(t.id),
|
? defaultServiceDetails.tags
|
||||||
)
|
.filter((t) =>
|
||||||
.map((t) => t.label)
|
requestOverrides.tags!.includes(t.id),
|
||||||
.join(", ") ||
|
)
|
||||||
defaultTags.map((t) => t.label).join(", ")
|
.map((t) => t.label)
|
||||||
: defaultTags.map((t) => t.label).join(", ")}
|
.join(", ") ||
|
||||||
</Text>
|
defaultTags.map((t) => t.label).join(", ")
|
||||||
</View>
|
: defaultTags.map((t) => t.label).join(", ")}
|
||||||
}
|
</Text>
|
||||||
title={t("jellyseerr.tags")}
|
</View>
|
||||||
open={tagsOpen}
|
}
|
||||||
onOpenChange={setTagsOpen}
|
title={t("seerr.tags")}
|
||||||
/>
|
open={tagsOpen}
|
||||||
</View>
|
onOpenChange={setTagsOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col'>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
{t("jellyseerr.request_as")}
|
{t("seerr.request_as")}
|
||||||
</Text>
|
</Text>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={usersOptions}
|
groups={usersOptions}
|
||||||
@@ -413,12 +431,12 @@ const RequestModal = forwardRef<
|
|||||||
{users.find(
|
{users.find(
|
||||||
(u) =>
|
(u) =>
|
||||||
u.id ===
|
u.id ===
|
||||||
(requestOverrides.userId || jellyseerrUser?.id),
|
(requestOverrides.userId || seerrUser?.id),
|
||||||
)?.displayName || jellyseerrUser!.displayName}
|
)?.displayName || seerrUser!.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title={t("jellyseerr.request_as")}
|
title={t("seerr.request_as")}
|
||||||
open={usersOpen}
|
open={usersOpen}
|
||||||
onOpenChange={setUsersOpen}
|
onOpenChange={setUsersOpen}
|
||||||
/>
|
/>
|
||||||
@@ -427,7 +445,7 @@ const RequestModal = forwardRef<
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Button className='mt-auto' onPress={request} color='purple'>
|
<Button className='mt-auto' onPress={request} color='purple'>
|
||||||
{t("jellyseerr.request_button")}
|
{t("seerr.request_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import Discover from "@/components/jellyseerr/discover/Discover";
|
import Discover from "@/components/seerr/discover/Discover";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
@@ -18,57 +18,57 @@ import type {
|
|||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import JellyseerrPoster from "../posters/JellyseerrPoster";
|
import SeerrPoster from "../posters/SeerrPoster";
|
||||||
import { LoadingSkeleton } from "../search/LoadingSkeleton";
|
import { LoadingSkeleton } from "../search/LoadingSkeleton";
|
||||||
import { SearchItemWrapper } from "../search/SearchItemWrapper";
|
import { SearchItemWrapper } from "../search/SearchItemWrapper";
|
||||||
import PersonPoster from "./PersonPoster";
|
import PersonPoster from "./PersonPoster";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
sortType?: JellyseerrSearchSort;
|
sortType?: SeerrSearchSort;
|
||||||
order?: "asc" | "desc";
|
order?: "asc" | "desc";
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum JellyseerrSearchSort {
|
export enum SeerrSearchSort {
|
||||||
DEFAULT = 0,
|
DEFAULT = 0,
|
||||||
VOTE_COUNT_AND_AVERAGE = 1,
|
VOTE_COUNT_AND_AVERAGE = 1,
|
||||||
POPULARITY = 2,
|
POPULARITY = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JellyserrIndexPage: React.FC<Props> = ({
|
export const SeerrIndexPage: React.FC<Props> = ({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
sortType,
|
sortType,
|
||||||
order,
|
order,
|
||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { seerrApi } = useSeerr();
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: jellyseerrDiscoverSettings,
|
data: seerrDiscoverSettings,
|
||||||
isFetching: f1,
|
isFetching: f1,
|
||||||
isLoading: l1,
|
isLoading: l1,
|
||||||
} = useReactNavigationQuery({
|
} = useReactNavigationQuery({
|
||||||
queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
|
queryKey: ["search", "seerr", "discoverSettings", searchQuery],
|
||||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
queryFn: async () => seerrApi?.discoverSettings(),
|
||||||
enabled: !!jellyseerrApi && searchQuery.length === 0,
|
enabled: !!seerrApi && searchQuery.length === 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: jellyseerrResults,
|
data: seerrResults,
|
||||||
isFetching: f2,
|
isFetching: f2,
|
||||||
isLoading: l2,
|
isLoading: l2,
|
||||||
} = useReactNavigationQuery({
|
} = useReactNavigationQuery({
|
||||||
queryKey: ["search", "jellyseerr", "results", searchQuery],
|
queryKey: ["search", "seerr", "results", searchQuery],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = {
|
const params = {
|
||||||
query: new URLSearchParams(searchQuery || "").toString(),
|
query: new URLSearchParams(searchQuery || "").toString(),
|
||||||
};
|
};
|
||||||
return await Promise.all([
|
return await Promise.all([
|
||||||
jellyseerrApi?.search({ ...params, page: 1 }),
|
seerrApi?.search({ ...params, page: 1 }),
|
||||||
jellyseerrApi?.search({ ...params, page: 2 }),
|
seerrApi?.search({ ...params, page: 2 }),
|
||||||
jellyseerrApi?.search({ ...params, page: 3 }),
|
seerrApi?.search({ ...params, page: 3 }),
|
||||||
jellyseerrApi?.search({ ...params, page: 4 }),
|
seerrApi?.search({ ...params, page: 4 }),
|
||||||
]).then((all) =>
|
]).then((all) =>
|
||||||
uniqBy(
|
uniqBy(
|
||||||
all.flatMap((v) => v?.results || []),
|
all.flatMap((v) => v?.results || []),
|
||||||
@@ -76,7 +76,7 @@ export const JellyserrIndexPage: React.FC<Props> = ({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi && searchQuery.length > 0,
|
enabled: !!seerrApi && searchQuery.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
@@ -92,20 +92,20 @@ export const JellyserrIndexPage: React.FC<Props> = ({
|
|||||||
|
|
||||||
const sortingType = useMemo(() => {
|
const sortingType = useMemo(() => {
|
||||||
if (!sortType) return;
|
if (!sortType) return;
|
||||||
switch (Number(JellyseerrSearchSort[sortType])) {
|
switch (Number(SeerrSearchSort[sortType])) {
|
||||||
case JellyseerrSearchSort.VOTE_COUNT_AND_AVERAGE:
|
case SeerrSearchSort.VOTE_COUNT_AND_AVERAGE:
|
||||||
return ["voteCount", "voteAverage"];
|
return ["voteCount", "voteAverage"];
|
||||||
case JellyseerrSearchSort.POPULARITY:
|
case SeerrSearchSort.POPULARITY:
|
||||||
return ["voteCount", "popularity"];
|
return ["voteCount", "popularity"];
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}, [sortType, order]);
|
}, [sortType, order]);
|
||||||
|
|
||||||
const jellyseerrMovieResults = useMemo(
|
const seerrMovieResults = useMemo(
|
||||||
() =>
|
() =>
|
||||||
orderBy(
|
orderBy(
|
||||||
jellyseerrResults?.filter(
|
seerrResults?.filter(
|
||||||
(r) => r.mediaType === MediaType.MOVIE,
|
(r) => r.mediaType === MediaType.MOVIE,
|
||||||
) as MovieResult[],
|
) as MovieResult[],
|
||||||
sortingType || [
|
sortingType || [
|
||||||
@@ -113,41 +113,37 @@ export const JellyserrIndexPage: React.FC<Props> = ({
|
|||||||
],
|
],
|
||||||
order || "desc",
|
order || "desc",
|
||||||
),
|
),
|
||||||
[jellyseerrResults, sortingType, order],
|
[seerrResults, sortingType, order, searchQuery],
|
||||||
);
|
);
|
||||||
|
|
||||||
const jellyseerrTvResults = useMemo(
|
const seerrTvResults = useMemo(
|
||||||
() =>
|
() =>
|
||||||
orderBy(
|
orderBy(
|
||||||
jellyseerrResults?.filter(
|
seerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[],
|
||||||
(r) => r.mediaType === MediaType.TV,
|
|
||||||
) as TvResult[],
|
|
||||||
sortingType || [
|
sortingType || [
|
||||||
(t) => t.name.toLowerCase() === searchQuery.toLowerCase(),
|
(t) => t.name.toLowerCase() === searchQuery.toLowerCase(),
|
||||||
],
|
],
|
||||||
order || "desc",
|
order || "desc",
|
||||||
),
|
),
|
||||||
[jellyseerrResults, sortingType, order],
|
[seerrResults, sortingType, order, searchQuery],
|
||||||
);
|
);
|
||||||
|
|
||||||
const jellyseerrPersonResults = useMemo(
|
const seerrPersonResults = useMemo(
|
||||||
() =>
|
() =>
|
||||||
orderBy(
|
orderBy(
|
||||||
jellyseerrResults?.filter(
|
seerrResults?.filter((r) => r.mediaType === "person") as PersonResult[],
|
||||||
(r) => r.mediaType === "person",
|
|
||||||
) as PersonResult[],
|
|
||||||
sortingType || [
|
sortingType || [
|
||||||
(p) => p.name.toLowerCase() === searchQuery.toLowerCase(),
|
(p) => p.name.toLowerCase() === searchQuery.toLowerCase(),
|
||||||
],
|
],
|
||||||
order || "desc",
|
order || "desc",
|
||||||
),
|
),
|
||||||
[jellyseerrResults, sortingType, order],
|
[seerrResults, sortingType, order, searchQuery],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!searchQuery.length)
|
if (!searchQuery.length)
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col'>
|
||||||
<Discover sliders={jellyseerrDiscoverSettings} />
|
<Discover sliders={seerrDiscoverSettings} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -155,9 +151,9 @@ export const JellyserrIndexPage: React.FC<Props> = ({
|
|||||||
<View>
|
<View>
|
||||||
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
|
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
|
||||||
|
|
||||||
{!jellyseerrMovieResults?.length &&
|
{!seerrMovieResults?.length &&
|
||||||
!jellyseerrTvResults?.length &&
|
!seerrTvResults?.length &&
|
||||||
!jellyseerrPersonResults?.length &&
|
!seerrPersonResults?.length &&
|
||||||
!f1 &&
|
!f1 &&
|
||||||
!f2 &&
|
!f2 &&
|
||||||
!l1 &&
|
!l1 &&
|
||||||
@@ -175,21 +171,21 @@ export const JellyserrIndexPage: React.FC<Props> = ({
|
|||||||
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
|
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
header={t("search.request_movies")}
|
header={t("search.request_movies")}
|
||||||
items={jellyseerrMovieResults}
|
items={seerrMovieResults}
|
||||||
renderItem={(item: MovieResult) => (
|
renderItem={(item: MovieResult) => (
|
||||||
<JellyseerrPoster item={item} key={item.id} />
|
<SeerrPoster item={item} key={item.id} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
header={t("search.request_series")}
|
header={t("search.request_series")}
|
||||||
items={jellyseerrTvResults}
|
items={seerrTvResults}
|
||||||
renderItem={(item: TvResult) => (
|
renderItem={(item: TvResult) => (
|
||||||
<JellyseerrPoster item={item} key={item.id} />
|
<SeerrPoster item={item} key={item.id} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
header={t("search.actors")}
|
header={t("search.actors")}
|
||||||
items={jellyseerrPersonResults}
|
items={seerrPersonResults}
|
||||||
renderItem={(item: PersonResult) => (
|
renderItem={(item: PersonResult) => (
|
||||||
<PersonPoster
|
<PersonPoster
|
||||||
className='mr-2'
|
className='mr-2'
|
||||||
@@ -3,9 +3,11 @@ import { useMemo } from "react";
|
|||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
|
||||||
const JellyseerrMediaIcon: React.FC<
|
const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
|
||||||
{ mediaType: "tv" | "movie" } & ViewProps
|
mediaType,
|
||||||
> = ({ mediaType, className, ...props }) => {
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
const style = useMemo(
|
const style = useMemo(
|
||||||
() =>
|
() =>
|
||||||
mediaType === MediaType.MOVIE
|
mediaType === MediaType.MOVIE
|
||||||
@@ -29,4 +31,4 @@ const JellyseerrMediaIcon: React.FC<
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default JellyseerrMediaIcon;
|
export default SeerrMediaIcon;
|
||||||
@@ -9,7 +9,7 @@ interface Props {
|
|||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
|
const SeerrStatusIcon: React.FC<Props & ViewProps> = ({
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
showRequestIcon,
|
showRequestIcon,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -74,4 +74,4 @@ const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default JellyseerrStatusIcon;
|
export default SeerrStatusIcon;
|
||||||
@@ -2,10 +2,10 @@ import { useSegments } from "expo-router";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
|
||||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import {
|
import {
|
||||||
COMPANY_LOGO_IMAGE_FILTER,
|
COMPANY_LOGO_IMAGE_FILTER,
|
||||||
type Network,
|
type Network,
|
||||||
@@ -16,17 +16,17 @@ const CompanySlide: React.FC<
|
|||||||
{ data: Network[] | Studio[] } & SlideProps & ViewProps
|
{ data: Network[] | Studio[] } & SlideProps & ViewProps
|
||||||
> = ({ slide, data, ...props }) => {
|
> = ({ slide, data, ...props }) => {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { seerrApi } = useSeerr();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
({ id, image, name }: Network | Studio) =>
|
({ id, image, name }: Network | Studio) =>
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any,
|
pathname: `/(auth)/(tabs)/${from}/seerr/company/${id}` as any,
|
||||||
params: { id, image, name, type: slide.type },
|
params: { id, image, name, type: slide.type },
|
||||||
}),
|
}),
|
||||||
[slide],
|
[router, from, slide.type],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,10 +40,7 @@ const CompanySlide: React.FC<
|
|||||||
<GenericSlideCard
|
<GenericSlideCard
|
||||||
className='w-28 rounded-lg overflow-hidden border border-neutral-900 p-4'
|
className='w-28 rounded-lg overflow-hidden border border-neutral-900 p-4'
|
||||||
id={item.id.toString()}
|
id={item.id.toString()}
|
||||||
url={jellyseerrApi?.imageProxy(
|
url={seerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
|
||||||
item.image,
|
|
||||||
COMPANY_LOGO_IMAGE_FILTER,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
@@ -2,10 +2,10 @@ import { sortBy } from "lodash";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
|
import CompanySlide from "@/components/seerr/discover/CompanySlide";
|
||||||
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
|
import GenreSlide from "@/components/seerr/discover/GenreSlide";
|
||||||
import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
|
import MovieTvSlide from "@/components/seerr/discover/MovieTvSlide";
|
||||||
import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
|
import RecentRequestsSlide from "@/components/seerr/discover/RecentRequestsSlide";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
@@ -23,7 +23,6 @@ const Discover: React.FC<Props> = ({ sliders }) => {
|
|||||||
sortBy(
|
sortBy(
|
||||||
(sliders ?? []).filter((s) => s.enabled),
|
(sliders ?? []).filter((s) => s.enabled),
|
||||||
"order",
|
"order",
|
||||||
"asc",
|
|
||||||
),
|
),
|
||||||
[sliders],
|
[sliders],
|
||||||
);
|
);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Image, type ImageContentFit } from "expo-image";
|
import { Image, type ImageContentFit } from "expo-image";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import type React from "react";
|
import React from "react";
|
||||||
import { StyleSheet, View, type ViewProps } from "react-native";
|
import { StyleSheet, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
@@ -67,4 +67,4 @@ const GenericSlideCard: React.FC<
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default GenericSlideCard;
|
export default React.memo(GenericSlideCard);
|
||||||
@@ -3,39 +3,38 @@ import { useSegments } from "expo-router";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
|
||||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||||
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
|
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
|
||||||
|
|
||||||
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { seerrApi } = useSeerr();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
(genre: GenreSliderItem) =>
|
(genre: GenreSliderItem) =>
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
|
pathname: `/(auth)/(tabs)/${from}/seerr/genre/${genre.id}` as any,
|
||||||
params: { type: slide.type, name: genre.name },
|
params: { type: slide.type, name: genre.name },
|
||||||
}),
|
}),
|
||||||
[slide],
|
[router, from, slide.type],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
|
queryKey: ["seerr", "discover", slide.type, slide.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return jellyseerrApi?.getGenreSliders(
|
return seerrApi?.getGenreSliders(
|
||||||
slide.type === DiscoverSliderType.MOVIE_GENRES
|
slide.type === DiscoverSliderType.MOVIE_GENRES
|
||||||
? Endpoints.MOVIE
|
? Endpoints.MOVIE
|
||||||
: Endpoints.TV,
|
: Endpoints.TV,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi,
|
enabled: !!seerrApi,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,7 +52,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
|||||||
title={item.name}
|
title={item.name}
|
||||||
colors={["transparent", "transparent"]}
|
colors={["transparent", "transparent"]}
|
||||||
contentFit={"cover"}
|
contentFit={"cover"}
|
||||||
url={jellyseerrApi?.imageProxy(
|
url={seerrApi?.imageProxy(
|
||||||
item.backdrops?.[0],
|
item.backdrops?.[0],
|
||||||
`w780_filter(duotone,${
|
`w780_filter(duotone,${
|
||||||
genreColorMap[item.id] ?? genreColorMap[0]
|
genreColorMap[item.id] ?? genreColorMap[0]
|
||||||
@@ -3,23 +3,19 @@ import { uniqBy } from "lodash";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||||
import {
|
import { type DiscoverEndpoint, Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||||
type DiscoverEndpoint,
|
|
||||||
Endpoints,
|
|
||||||
useJellyseerr,
|
|
||||||
} from "@/hooks/useJellyseerr";
|
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
|
||||||
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||||
slide,
|
slide,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "discover", slide.id],
|
queryKey: ["seerr", "discover", slide.id],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
let endpoint: DiscoverEndpoint | undefined;
|
let endpoint: DiscoverEndpoint | undefined;
|
||||||
let params: any = {
|
let params: any = {
|
||||||
@@ -50,13 +46,13 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
|
return endpoint ? seerrApi?.discover(endpoint, params) : null;
|
||||||
},
|
},
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getNextPageParam: (lastPage, pages) =>
|
getNextPageParam: (lastPage, pages) =>
|
||||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
1,
|
1,
|
||||||
enabled: !!jellyseerrApi,
|
enabled: !!seerrApi,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,12 +61,10 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
|||||||
uniqBy(
|
uniqBy(
|
||||||
data?.pages
|
data?.pages
|
||||||
?.filter((p) => p?.results.length)
|
?.filter((p) => p?.results.length)
|
||||||
.flatMap((p) =>
|
.flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))),
|
||||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
|
|
||||||
),
|
|
||||||
"id",
|
"id",
|
||||||
),
|
),
|
||||||
[data],
|
[data, isSeerrMovieOrTvResult],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,7 +78,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
|||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) fetchNextPage();
|
if (hasNextPage) fetchNextPage();
|
||||||
}}
|
}}
|
||||||
renderItem={(item) => <JellyseerrPoster item={item} key={item?.id} />}
|
renderItem={(item) => <SeerrPoster item={item} key={item?.id} />}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useSeerr } from "@/hooks/useSeerr";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
|
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
|
||||||
@@ -16,36 +16,36 @@ type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
|
|||||||
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
|
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { seerrApi } = useSeerr();
|
||||||
|
|
||||||
const { data: details } = useQuery({
|
const { data: details } = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"jellyseerr",
|
"seerr",
|
||||||
"detail",
|
"detail",
|
||||||
request.media.mediaType,
|
request.media.mediaType,
|
||||||
request.media.tmdbId,
|
request.media.tmdbId,
|
||||||
],
|
],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return request.media.mediaType === MediaType.MOVIE
|
return request.media.mediaType === MediaType.MOVIE
|
||||||
? jellyseerrApi?.movieDetails(request.media.tmdbId)
|
? seerrApi?.movieDetails(request.media.tmdbId)
|
||||||
: jellyseerrApi?.tvDetails(request.media.tmdbId);
|
: seerrApi?.tvDetails(request.media.tmdbId);
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi,
|
enabled: !!seerrApi,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: refreshedRequest } = useQuery({
|
const { data: refreshedRequest } = useQuery({
|
||||||
queryKey: ["jellyseerr", "requests", request.media.mediaType, request.id],
|
queryKey: ["seerr", "requests", request.media.mediaType, request.id],
|
||||||
queryFn: async () => jellyseerrApi?.getRequest(request.id),
|
queryFn: async () => seerrApi?.getRequest(request.id),
|
||||||
enabled: !!jellyseerrApi,
|
enabled: !!seerrApi,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JellyseerrPoster
|
<SeerrPoster
|
||||||
horizontal
|
horizontal
|
||||||
showDownloadInfo
|
showDownloadInfo
|
||||||
item={details}
|
item={details}
|
||||||
@@ -58,12 +58,12 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
|
|||||||
slide,
|
slide,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { seerrApi } = useSeerr();
|
||||||
|
|
||||||
const { data: requests } = useQuery({
|
const { data: requests } = useQuery({
|
||||||
queryKey: ["jellyseerr", "recent_requests"],
|
queryKey: ["seerr", "recent_requests"],
|
||||||
queryFn: async () => jellyseerrApi?.requests(),
|
queryFn: async () => seerrApi?.requests(),
|
||||||
enabled: !!jellyseerrApi,
|
enabled: !!seerrApi,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
@@ -14,10 +14,7 @@ export interface SlideProps {
|
|||||||
|
|
||||||
interface Props<T> extends SlideProps {
|
interface Props<T> extends SlideProps {
|
||||||
data: T[];
|
data: T[];
|
||||||
renderItem: (
|
renderItem: (item: T, index: number) => React.ReactElement | null;
|
||||||
item: T,
|
|
||||||
index: number,
|
|
||||||
) => React.ComponentType<any> | React.ReactElement | null | undefined;
|
|
||||||
keyExtractor: (item: T) => string;
|
keyExtractor: (item: T) => string;
|
||||||
onEndReached?: (() => void) | null | undefined;
|
onEndReached?: (() => void) | null | undefined;
|
||||||
}
|
}
|
||||||
@@ -47,7 +44,6 @@ const Slide = <T,>({
|
|||||||
data={data}
|
data={data}
|
||||||
onEndReachedThreshold={1}
|
onEndReachedThreshold={1}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
//@ts-expect-error
|
|
||||||
renderItem={({ item, index }) =>
|
renderItem={({ item, index }) =>
|
||||||
item ? renderItem(item, index) : null
|
item ? renderItem(item, index) : null
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
@@ -53,7 +52,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
loading={loading}
|
loading={loading}
|
||||||
keyExtractor={(i, _idx) => i.Id?.toString() || ""}
|
keyExtractor={(i, _idx) => i.Id?.toString() || ""}
|
||||||
height={POSTER_CAROUSEL_HEIGHT}
|
height={220}
|
||||||
data={destinctPeople}
|
data={destinctPeople}
|
||||||
renderItem={(i) => (
|
renderItem={(i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
@@ -27,7 +26,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={[item]}
|
data={[item]}
|
||||||
height={POSTER_CAROUSEL_HEIGHT}
|
height={220}
|
||||||
renderItem={(item, _index) => (
|
renderItem={(item, _index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item?.Id}
|
key={item?.Id}
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { Alert, TouchableOpacity, View } from "react-native";
|
|||||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Tags } from "@/components/GenreTags";
|
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 { RoundButton } from "@/components/RoundButton";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
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 {
|
import {
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
MediaType,
|
MediaType,
|
||||||
@@ -30,15 +30,15 @@ import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
|||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
|
|
||||||
const JellyseerrSeasonEpisodes: React.FC<{
|
const SeerrSeasonEpisodes: React.FC<{
|
||||||
details: TvDetails;
|
details: TvDetails;
|
||||||
seasonNumber: number;
|
seasonNumber: number;
|
||||||
}> = ({ details, seasonNumber }) => {
|
}> = ({ details, seasonNumber }) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { seerrApi } = useSeerr();
|
||||||
|
|
||||||
const { data: seasonWithEpisodes, isLoading } = useQuery({
|
const { data: seasonWithEpisodes, isLoading } = useQuery({
|
||||||
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
|
queryKey: ["seerr", details.id, "season", seasonNumber],
|
||||||
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
|
queryFn: async () => seerrApi?.tvSeason(details.id, seasonNumber),
|
||||||
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
|
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,11 +57,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RenderItem = ({ item }: any) => {
|
const RenderItem = ({ item }: any) => {
|
||||||
const {
|
const { seerrApi, seerrRegion: region, seerrLocale: locale } = useSeerr();
|
||||||
jellyseerrApi,
|
|
||||||
jellyseerrRegion: region,
|
|
||||||
jellyseerrLocale: locale,
|
|
||||||
} = useJellyseerr();
|
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
const upcomingAirDate = useMemo(() => {
|
const upcomingAirDate = useMemo(() => {
|
||||||
@@ -69,7 +65,7 @@ const RenderItem = ({ item }: any) => {
|
|||||||
if (airDate) {
|
if (airDate) {
|
||||||
const airDateObj = new Date(airDate);
|
const airDateObj = new Date(airDate);
|
||||||
if (new Date() < airDateObj) {
|
if (new Date() < airDateObj) {
|
||||||
return airDateObj.toLocaleDateString(`${locale}-${region}`, dateOpts);
|
return airDateObj.toLocaleDateString(locale, dateOpts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [item, locale, region]);
|
}, [item, locale, region]);
|
||||||
@@ -83,7 +79,7 @@ const RenderItem = ({ item }: any) => {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
uri: seerrApi?.imageProxy(item.stillPath),
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
@@ -131,7 +127,7 @@ const RenderItem = ({ item }: any) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const JellyseerrSeasons: React.FC<{
|
const SeerrSeasons: React.FC<{
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
details?: TvDetails;
|
details?: TvDetails;
|
||||||
hasAdvancedRequest?: boolean;
|
hasAdvancedRequest?: boolean;
|
||||||
@@ -148,7 +144,7 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
hasAdvancedRequest,
|
hasAdvancedRequest,
|
||||||
onAdvancedRequest,
|
onAdvancedRequest,
|
||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { seerrApi, requestMedia } = useSeerr();
|
||||||
const [seasonStates, setSeasonStates] = useState<{ [key: number]: boolean }>(
|
const [seasonStates, setSeasonStates] = useState<{ [key: number]: boolean }>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@@ -181,7 +177,7 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
const requestAll = useCallback(() => {
|
const requestAll = useCallback(() => {
|
||||||
if (details && jellyseerrApi) {
|
if (details && seerrApi) {
|
||||||
const body: MediaRequestBody = {
|
const body: MediaRequestBody = {
|
||||||
mediaId: details.id,
|
mediaId: details.id,
|
||||||
mediaType: MediaType.TV,
|
mediaType: MediaType.TV,
|
||||||
@@ -198,7 +194,7 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
requestMedia(details.name, body, refetch);
|
requestMedia(details.name, body, refetch);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
jellyseerrApi,
|
seerrApi,
|
||||||
seasons,
|
seasons,
|
||||||
details,
|
details,
|
||||||
hasAdvancedRequest,
|
hasAdvancedRequest,
|
||||||
@@ -210,15 +206,15 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
const promptRequestAll = useCallback(
|
const promptRequestAll = useCallback(
|
||||||
() =>
|
() =>
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("jellyseerr.confirm"),
|
t("seerr.confirm"),
|
||||||
t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"),
|
t("seerr.are_you_sure_you_want_to_request_all_seasons"),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: t("jellyseerr.cancel"),
|
text: t("seerr.cancel"),
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t("jellyseerr.yes"),
|
text: t("seerr.yes"),
|
||||||
onPress: requestAll,
|
onPress: requestAll,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -301,10 +297,10 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
<Tags
|
<Tags
|
||||||
textClass=''
|
textClass=''
|
||||||
tags={[
|
tags={[
|
||||||
t("jellyseerr.season_number", {
|
t("seerr.season_number", {
|
||||||
season_number: season.seasonNumber,
|
season_number: season.seasonNumber,
|
||||||
}),
|
}),
|
||||||
t("jellyseerr.number_episodes", {
|
t("seerr.number_episodes", {
|
||||||
episode_number: season.episodeCount,
|
episode_number: season.episodeCount,
|
||||||
}),
|
}),
|
||||||
]}
|
]}
|
||||||
@@ -312,7 +308,7 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
{[0].map(() => {
|
{[0].map(() => {
|
||||||
const canRequest = season.status === MediaStatus.UNKNOWN;
|
const canRequest = season.status === MediaStatus.UNKNOWN;
|
||||||
return (
|
return (
|
||||||
<JellyseerrStatusIcon
|
<SeerrStatusIcon
|
||||||
key={0}
|
key={0}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
requestSeason(canRequest, season.seasonNumber)
|
requestSeason(canRequest, season.seasonNumber)
|
||||||
@@ -326,7 +322,7 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{seasonStates?.[season.seasonNumber] && (
|
{seasonStates?.[season.seasonNumber] && (
|
||||||
<JellyseerrSeasonEpisodes
|
<SeerrSeasonEpisodes
|
||||||
key={season.seasonNumber}
|
key={season.seasonNumber}
|
||||||
details={details}
|
details={details}
|
||||||
seasonNumber={season.seasonNumber}
|
seasonNumber={season.seasonNumber}
|
||||||
@@ -338,4 +334,4 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default JellyseerrSeasons;
|
export default SeerrSeasons;
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function DownloadSettings() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function DownloadSettings() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,181 +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 { 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>
|
||||||
|
|
||||||
<OptionGroup title='Options'>
|
<OptionGroup title={t("library.options.options_title")}>
|
||||||
<ToggleItem
|
<ToggleItem
|
||||||
label={t("library.options.show_titles")}
|
label={t("library.options.show_titles")}
|
||||||
value={settings.showTitles}
|
value={settings.showTitles}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -17,20 +18,21 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||||
|
|
||||||
const alignXLabels: Record<AlignX, string> = {
|
const alignXLabels: Record<AlignX, string> = {
|
||||||
left: "Left",
|
left: t("player.alignment_left"),
|
||||||
center: "Center",
|
center: t("player.alignment_center"),
|
||||||
right: "Right",
|
right: t("player.alignment_right"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignYLabels: Record<AlignY, string> = {
|
const alignYLabels: Record<AlignY, string> = {
|
||||||
top: "Top",
|
top: t("player.alignment_top"),
|
||||||
center: "Center",
|
center: t("player.alignment_center"),
|
||||||
bottom: "Bottom",
|
bottom: t("player.alignment_bottom"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignXOptionGroups = useMemo(() => {
|
const alignXOptionGroups = useMemo(() => {
|
||||||
@@ -61,14 +63,14 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
title='MPV Subtitle Settings'
|
title={t("player.mpv_subtitle_settings_title")}
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
Advanced subtitle customization for MPV player
|
{t("player.mpv_subtitle_settings_description")}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ListItem title='Subtitle Scale'>
|
<ListItem title={t("player.subtitle_scale")}>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleScale ?? 1.0}
|
value={settings.mpvSubtitleScale ?? 1.0}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
@@ -80,7 +82,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title='Vertical Margin'>
|
<ListItem title={t("player.vertical_margin")}>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -90,7 +92,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title='Horizontal Alignment'>
|
<ListItem title={t("player.horizontal_alignment")}>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignXOptionGroups}
|
groups={alignXOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -105,11 +107,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title='Horizontal Alignment'
|
title={t("player.horizontal_alignment")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title='Vertical Alignment'>
|
<ListItem title={t("player.vertical_alignment")}>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignYOptionGroups}
|
groups={alignYOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -124,7 +126,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title='Vertical Alignment'
|
title={t("player.vertical_alignment")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|||||||
@@ -19,23 +19,23 @@ export const PluginSettings = () => {
|
|||||||
className='mb-4'
|
className='mb-4'
|
||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
onPress={() => router.push("/settings/plugins/seerr/page")}
|
||||||
title={"Jellyseerr"}
|
title={"Seerr"}
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
|
||||||
title='Marlin Search'
|
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||||
title='Streamystats'
|
title={"Streamystats"}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
||||||
|
title={"Marlin Search"}
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||||
title='KefinTweaks'
|
title={"KefinTweaks"}
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|||||||
174
components/settings/Seerr.tsx
Normal file
174
components/settings/Seerr.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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,7 +40,6 @@ import { useVideoTime } from "./hooks/useVideoTime";
|
|||||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||||
import { useControlsTimeout } from "./useControlsTimeout";
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -58,7 +57,6 @@ interface Props {
|
|||||||
startPictureInPicture?: () => Promise<void>;
|
startPictureInPicture?: () => Promise<void>;
|
||||||
play: () => void;
|
play: () => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
aspectRatio?: AspectRatio;
|
|
||||||
isZoomedToFill?: boolean;
|
isZoomedToFill?: boolean;
|
||||||
onZoomToggle?: () => void;
|
onZoomToggle?: () => void;
|
||||||
api?: Api | null;
|
api?: Api | null;
|
||||||
@@ -89,7 +87,6 @@ export const Controls: FC<Props> = ({
|
|||||||
showControls,
|
showControls,
|
||||||
setShowControls,
|
setShowControls,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
aspectRatio = "default",
|
|
||||||
isZoomedToFill = false,
|
isZoomedToFill = false,
|
||||||
onZoomToggle,
|
onZoomToggle,
|
||||||
api = null,
|
api = null,
|
||||||
@@ -498,7 +495,6 @@ export const Controls: FC<Props> = ({
|
|||||||
goToNextItem={goToNextItem}
|
goToNextItem={goToNextItem}
|
||||||
previousItem={previousItem}
|
previousItem={previousItem}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
aspectRatio={aspectRatio}
|
|
||||||
isZoomedToFill={isZoomedToFill}
|
isZoomedToFill={isZoomedToFill}
|
||||||
onZoomToggle={onZoomToggle}
|
onZoomToggle={onZoomToggle}
|
||||||
playbackSpeed={playbackSpeed}
|
playbackSpeed={playbackSpeed}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||||
import DropdownView from "./dropdown/DropdownView";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
|
||||||
import { ZoomToggle } from "./ZoomToggle";
|
import { ZoomToggle } from "./ZoomToggle";
|
||||||
|
|
||||||
interface HeaderControlsProps {
|
interface HeaderControlsProps {
|
||||||
@@ -28,7 +27,6 @@ interface HeaderControlsProps {
|
|||||||
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
||||||
previousItem?: BaseItemDto | null;
|
previousItem?: BaseItemDto | null;
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
aspectRatio?: AspectRatio;
|
|
||||||
isZoomedToFill?: boolean;
|
isZoomedToFill?: boolean;
|
||||||
onZoomToggle?: () => void;
|
onZoomToggle?: () => void;
|
||||||
// Playback speed props
|
// Playback speed props
|
||||||
@@ -50,7 +48,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
goToNextItem,
|
goToNextItem,
|
||||||
previousItem,
|
previousItem,
|
||||||
nextItem,
|
nextItem,
|
||||||
aspectRatio: _aspectRatio = "default",
|
|
||||||
isZoomedToFill = false,
|
isZoomedToFill = false,
|
||||||
onZoomToggle,
|
onZoomToggle,
|
||||||
playbackSpeed = 1.0,
|
playbackSpeed = 1.0,
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
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,6 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,7 @@ const DropdownView = ({
|
|||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||||
useLocalSearchParams<{
|
useLocalSearchParams<{
|
||||||
@@ -215,7 +217,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title='Playback Options'
|
title={t("player.playback_options_title")}
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
expoUIConfig={{}}
|
expoUIConfig={{}}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
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" },
|
|
||||||
];
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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 };
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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 };
|
|
||||||
};
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
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 { atom } from "jotai";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai/index";
|
||||||
import { inRange } from "lodash";
|
import { inRange } from "lodash";
|
||||||
import type { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
|
import type { User as SeerrUser } from "@/utils/jellyseerr/server/entity/User";
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
Results,
|
Results,
|
||||||
@@ -62,12 +62,12 @@ interface SearchResults {
|
|||||||
results: Results[];
|
results: Results[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const JELLYSEERR_USER = "JELLYSEERR_USER";
|
const SEERR_USER = "SEERR_USER";
|
||||||
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
|
const SEERR_COOKIES = "SEERR_COOKIES";
|
||||||
|
|
||||||
export const clearJellyseerrStorageData = () => {
|
export const clearSeerrStorageData = () => {
|
||||||
storage.remove(JELLYSEERR_USER);
|
storage.remove(SEERR_USER);
|
||||||
storage.remove(JELLYSEERR_COOKIES);
|
storage.remove(SEERR_COOKIES);
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum Endpoints {
|
export enum Endpoints {
|
||||||
@@ -111,12 +111,27 @@ export type TestResult =
|
|||||||
isValid: false;
|
isValid: false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class JellyseerrApi {
|
/**
|
||||||
|
* 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 {
|
||||||
axios: AxiosInstance;
|
axios: AxiosInstance;
|
||||||
|
|
||||||
constructor(baseUrl: string) {
|
constructor(baseUrl: string) {
|
||||||
|
const normalizedUrl = normalizeUrl(baseUrl);
|
||||||
this.axios = axios.create({
|
this.axios = axios.create({
|
||||||
baseURL: baseUrl,
|
baseURL: normalizedUrl,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
withXSRFToken: true,
|
withXSRFToken: true,
|
||||||
xsrfHeaderName: "XSRF-TOKEN",
|
xsrfHeaderName: "XSRF-TOKEN",
|
||||||
@@ -126,8 +141,8 @@ export class JellyseerrApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async test(): Promise<TestResult> {
|
async test(): Promise<TestResult> {
|
||||||
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
const user = storage.get<SeerrUser>(SEERR_USER);
|
||||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
const cookies = storage.get<string[]>(SEERR_COOKIES);
|
||||||
|
|
||||||
if (user && cookies) {
|
if (user && cookies) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -142,15 +157,13 @@ export class JellyseerrApi {
|
|||||||
const { status, headers, data } = response;
|
const { status, headers, data } = response;
|
||||||
if (inRange(status, 200, 299)) {
|
if (inRange(status, 200, 299)) {
|
||||||
if (data.version < "2.0.0") {
|
if (data.version < "2.0.0") {
|
||||||
const error = t(
|
const error = t("seerr.toasts.seer_does_not_meet_requirements");
|
||||||
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
|
||||||
);
|
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
throw Error(error);
|
throw Error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.setAny(
|
storage.setAny(
|
||||||
JELLYSEERR_COOKIES,
|
SEERR_COOKIES,
|
||||||
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? [],
|
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? [],
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -158,9 +171,9 @@ export class JellyseerrApi {
|
|||||||
requiresPass: true,
|
requiresPass: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
toast.error(t("jellyseerr.toasts.jellyseerr_test_failed"));
|
toast.error(t("seerr.toasts.seerr_test_failed"));
|
||||||
writeErrorLog(
|
writeErrorLog(
|
||||||
`Jellyseerr returned a ${status} for url:\n${response.config.url}`,
|
`Seerr returned a ${status} for url:\n${response.config.url}`,
|
||||||
response.data,
|
response.data,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -169,7 +182,7 @@ export class JellyseerrApi {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
const msg = t("jellyseerr.toasts.failed_to_test_jellyseerr_server_url");
|
const msg = t("seerr.toasts.failed_to_test_seerr_server_url");
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
console.error(msg, e);
|
console.error(msg, e);
|
||||||
return {
|
return {
|
||||||
@@ -179,9 +192,9 @@ export class JellyseerrApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(username: string, password: string): Promise<JellyseerrUser> {
|
async login(username: string, password: string): Promise<SeerrUser> {
|
||||||
return this.axios
|
return this.axios
|
||||||
?.post<JellyseerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
|
?.post<SeerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
email: username,
|
email: username,
|
||||||
@@ -189,7 +202,7 @@ export class JellyseerrApi {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
const user = response?.data;
|
const user = response?.data;
|
||||||
if (!user) throw Error("Login failed");
|
if (!user) throw Error("Login failed");
|
||||||
storage.setAny(JELLYSEERR_USER, user);
|
storage.setAny(SEERR_USER, user);
|
||||||
return user;
|
return user;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -364,7 +377,7 @@ export class JellyseerrApi {
|
|||||||
const issue = response.data;
|
const issue = response.data;
|
||||||
|
|
||||||
if (issue.status === IssueStatus.OPEN) {
|
if (issue.status === IssueStatus.OPEN) {
|
||||||
toast.success(t("jellyseerr.toasts.issue_submitted"));
|
toast.success(t("seerr.toasts.issue_submitted"));
|
||||||
}
|
}
|
||||||
return issue;
|
return issue;
|
||||||
});
|
});
|
||||||
@@ -392,7 +405,7 @@ export class JellyseerrApi {
|
|||||||
const cookies = response.headers["set-cookie"];
|
const cookies = response.headers["set-cookie"];
|
||||||
if (cookies) {
|
if (cookies) {
|
||||||
storage.setAny(
|
storage.setAny(
|
||||||
JELLYSEERR_COOKIES,
|
SEERR_COOKIES,
|
||||||
response.headers["set-cookie"]?.flatMap((c) => c.split("; ")),
|
response.headers["set-cookie"]?.flatMap((c) => c.split("; ")),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -400,11 +413,11 @@ export class JellyseerrApi {
|
|||||||
},
|
},
|
||||||
(error: AxiosError) => {
|
(error: AxiosError) => {
|
||||||
writeErrorLog(
|
writeErrorLog(
|
||||||
`Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
|
`Seerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
|
||||||
error.response?.data,
|
error.response?.data,
|
||||||
);
|
);
|
||||||
if (error.response?.status === 403) {
|
if (error.response?.status === 403) {
|
||||||
clearJellyseerrStorageData();
|
clearSeerrStorageData();
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
@@ -412,7 +425,7 @@ export class JellyseerrApi {
|
|||||||
|
|
||||||
this.axios.interceptors.request.use(
|
this.axios.interceptors.request.use(
|
||||||
async (config) => {
|
async (config) => {
|
||||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
const cookies = storage.get<string[]>(SEERR_COOKIES);
|
||||||
if (cookies) {
|
if (cookies) {
|
||||||
const headerName = this.axios.defaults.xsrfHeaderName!;
|
const headerName = this.axios.defaults.xsrfHeaderName!;
|
||||||
const xsrfToken = cookies
|
const xsrfToken = cookies
|
||||||
@@ -425,78 +438,77 @@ export class JellyseerrApi {
|
|||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error("Jellyseerr request error", error);
|
console.error("Seerr request error", error);
|
||||||
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
|
const seerrUserAtom = atom(storage.get<SeerrUser>(SEERR_USER));
|
||||||
|
|
||||||
export const useJellyseerr = () => {
|
export const useSeerr = () => {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
|
const [seerrUser, setSeerrUser] = useAtom(seerrUserAtom);
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
const jellyseerrApi = useMemo(() => {
|
const seerrApi = useMemo(() => {
|
||||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
const cookies = storage.get<string[]>(SEERR_COOKIES);
|
||||||
if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) {
|
if (settings?.seerrServerUrl && cookies && seerrUser) {
|
||||||
return new JellyseerrApi(settings?.jellyseerrServerUrl);
|
return new SeerrApi(settings?.seerrServerUrl);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [settings?.jellyseerrServerUrl, jellyseerrUser]);
|
}, [settings?.seerrServerUrl, seerrUser]);
|
||||||
|
|
||||||
const clearAllJellyseerData = useCallback(async () => {
|
const clearAllSeerrData = useCallback(async () => {
|
||||||
clearJellyseerrStorageData();
|
clearSeerrStorageData();
|
||||||
setJellyseerrUser(undefined);
|
setSeerrUser(undefined);
|
||||||
updateSettings({ jellyseerrServerUrl: undefined });
|
updateSettings({ seerrServerUrl: undefined });
|
||||||
}, []);
|
}, [setSeerrUser, updateSettings]);
|
||||||
|
|
||||||
const requestMedia = useCallback(
|
const requestMedia = useCallback(
|
||||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||||
jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => {
|
seerrApi?.request?.(request)?.then(async (mediaRequest) => {
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["search", "jellyseerr"],
|
queryKey: ["search", "seerr"],
|
||||||
});
|
});
|
||||||
|
|
||||||
switch (mediaRequest.status) {
|
switch (mediaRequest.status) {
|
||||||
case MediaRequestStatus.PENDING:
|
case MediaRequestStatus.PENDING:
|
||||||
case MediaRequestStatus.APPROVED:
|
case MediaRequestStatus.APPROVED:
|
||||||
toast.success(
|
toast.success(t("seerr.toasts.requested_item", { item: title }));
|
||||||
t("jellyseerr.toasts.requested_item", { item: title }),
|
|
||||||
);
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
break;
|
break;
|
||||||
case MediaRequestStatus.DECLINED:
|
case MediaRequestStatus.DECLINED:
|
||||||
toast.error(
|
toast.error(t("seerr.toasts.you_dont_have_permission_to_request"));
|
||||||
t("jellyseerr.toasts.you_dont_have_permission_to_request"),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case MediaRequestStatus.FAILED:
|
case MediaRequestStatus.FAILED:
|
||||||
toast.error(
|
toast.error(
|
||||||
t("jellyseerr.toasts.something_went_wrong_requesting_media"),
|
t("seerr.toasts.something_went_wrong_requesting_media"),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[jellyseerrApi],
|
[seerrApi, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isJellyseerrMovieOrTvResult = (
|
const isSeerrMovieOrTvResult = useCallback(
|
||||||
items: any | null | undefined,
|
(items: any | null | undefined): items is MovieResult | TvResult => {
|
||||||
): items is MovieResult | TvResult => {
|
return (
|
||||||
return (
|
items &&
|
||||||
items &&
|
Object.hasOwn(items, "mediaType") &&
|
||||||
Object.hasOwn(items, "mediaType") &&
|
(items.mediaType === MediaType.MOVIE ||
|
||||||
(items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
|
items.mediaType === MediaType.TV)
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const getTitle = (
|
const getTitle = (
|
||||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||||
) => {
|
) => {
|
||||||
return isJellyseerrMovieOrTvResult(item)
|
return isSeerrMovieOrTvResult(item)
|
||||||
? item.mediaType === MediaType.MOVIE
|
? item.mediaType === MediaType.MOVIE
|
||||||
? item?.title
|
? item?.title
|
||||||
: item?.name
|
: item?.name
|
||||||
@@ -509,7 +521,7 @@ export const useJellyseerr = () => {
|
|||||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||||
) => {
|
) => {
|
||||||
return new Date(
|
return new Date(
|
||||||
(isJellyseerrMovieOrTvResult(item)
|
(isSeerrMovieOrTvResult(item)
|
||||||
? item.mediaType === MediaType.MOVIE
|
? item.mediaType === MediaType.MOVIE
|
||||||
? item?.releaseDate
|
? item?.releaseDate
|
||||||
: item?.firstAirDate
|
: item?.firstAirDate
|
||||||
@@ -522,32 +534,35 @@ export const useJellyseerr = () => {
|
|||||||
const getMediaType = (
|
const getMediaType = (
|
||||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||||
): MediaType => {
|
): MediaType => {
|
||||||
return isJellyseerrMovieOrTvResult(item)
|
return isSeerrMovieOrTvResult(item)
|
||||||
? (item.mediaType as MediaType)
|
? (item.mediaType as MediaType)
|
||||||
: item?.mediaInfo?.mediaType;
|
: item?.mediaInfo?.mediaType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const jellyseerrRegion = useMemo(
|
const seerrRegion = useMemo(
|
||||||
// streamingRegion and discoverRegion exists. region doesn't
|
// streamingRegion and discoverRegion exists. region doesn't
|
||||||
() => jellyseerrUser?.settings?.discoverRegion || "US",
|
() => seerrUser?.settings?.discoverRegion || "US",
|
||||||
[jellyseerrUser],
|
[seerrUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
const jellyseerrLocale = useMemo(() => {
|
const seerrLocale = useMemo(() => {
|
||||||
return jellyseerrUser?.settings?.locale || "en";
|
const locale = seerrUser?.settings?.locale || "en";
|
||||||
}, [jellyseerrUser]);
|
// 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]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jellyseerrApi,
|
seerrApi,
|
||||||
jellyseerrUser,
|
seerrUser,
|
||||||
setJellyseerrUser,
|
setSeerrUser,
|
||||||
clearAllJellyseerData,
|
clearAllSeerrData,
|
||||||
isJellyseerrMovieOrTvResult,
|
isSeerrMovieOrTvResult,
|
||||||
getTitle,
|
getTitle,
|
||||||
getYear,
|
getYear,
|
||||||
getMediaType,
|
getMediaType,
|
||||||
jellyseerrRegion,
|
seerrRegion,
|
||||||
jellyseerrLocale,
|
seerrLocale,
|
||||||
requestMedia,
|
requestMedia,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -43,12 +43,6 @@ class MpvPlayerModule : Module() {
|
|||||||
view.loadVideo(config)
|
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
|
// Async function to play video
|
||||||
AsyncFunction("play") { view: MpvPlayerView ->
|
AsyncFunction("play") { view: MpvPlayerView ->
|
||||||
view.play()
|
view.play()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ protocol MPVLayerRendererDelegate: AnyObject {
|
|||||||
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MPV player using vo_avfoundation for video output.
|
/// MPV player using vo_avfoundation for video output.
|
||||||
@@ -348,8 +347,7 @@ final class MPVLayerRenderer {
|
|||||||
("pause", MPV_FORMAT_FLAG),
|
("pause", MPV_FORMAT_FLAG),
|
||||||
("track-list/count", MPV_FORMAT_INT64),
|
("track-list/count", MPV_FORMAT_INT64),
|
||||||
("paused-for-cache", MPV_FORMAT_FLAG),
|
("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 {
|
for (name, format) in properties {
|
||||||
mpv_observe_property(handle, 0, name, format)
|
mpv_observe_property(handle, 0, name, format)
|
||||||
@@ -554,15 +552,6 @@ final class MPVLayerRenderer {
|
|||||||
self.delegate?.renderer(self, didBecomeTracksReady: true)
|
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:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
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,21 +43,6 @@ public class MpvPlayerModule: Module {
|
|||||||
view.loadVideo(config: config)
|
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
|
// Async function to play video
|
||||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||||
view.play()
|
view.play()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import CoreMedia
|
import CoreMedia
|
||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
import MediaPlayer
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// Configuration for loading a video
|
/// Configuration for loading a video
|
||||||
@@ -42,6 +41,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
private var renderer: MPVLayerRenderer?
|
private var renderer: MPVLayerRenderer?
|
||||||
private var videoContainer: UIView!
|
private var videoContainer: UIView!
|
||||||
private var pipController: PiPController?
|
private var pipController: PiPController?
|
||||||
|
|
||||||
let onLoad = EventDispatcher()
|
let onLoad = EventDispatcher()
|
||||||
let onPlaybackStateChange = EventDispatcher()
|
let onPlaybackStateChange = EventDispatcher()
|
||||||
let onProgress = EventDispatcher()
|
let onProgress = EventDispatcher()
|
||||||
@@ -54,13 +54,10 @@ class MpvPlayerView: ExpoView {
|
|||||||
private var intendedPlayState: Bool = false
|
private var intendedPlayState: Bool = false
|
||||||
private var _isZoomedToFill: Bool = false
|
private var _isZoomedToFill: Bool = false
|
||||||
|
|
||||||
// Reference to now playing manager
|
|
||||||
private let nowPlayingManager = MPVNowPlayingManager.shared
|
|
||||||
|
|
||||||
required init(appContext: AppContext? = nil) {
|
required init(appContext: AppContext? = nil) {
|
||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
setupNotifications()
|
|
||||||
setupView()
|
setupView()
|
||||||
|
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupView() {
|
private func setupView() {
|
||||||
@@ -112,77 +109,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
CATransaction.commit()
|
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) {
|
func loadVideo(config: VideoLoadConfig) {
|
||||||
// Skip reload if same URL is already playing
|
// Skip reload if same URL is already playing
|
||||||
if currentURL == config.url {
|
if currentURL == config.url {
|
||||||
@@ -223,7 +149,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
setupRemoteCommands()
|
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
pipController?.updatePlaybackState()
|
pipController?.updatePlaybackState()
|
||||||
@@ -237,17 +162,10 @@ class MpvPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func seekTo(position: Double) {
|
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)
|
renderer?.seek(to: position)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seekBy(offset: Double) {
|
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)
|
renderer?.seek(by: offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,28 +292,19 @@ class MpvPlayerView: ExpoView {
|
|||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
displayLayer.removeFromSuperlayer()
|
displayLayer.removeFromSuperlayer()
|
||||||
clearNowPlayingInfo()
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - MPVLayerRendererDelegate
|
// MARK: - MPVLayerRendererDelegate
|
||||||
|
|
||||||
extension MpvPlayerView: 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) {
|
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
|
||||||
cachedPosition = position
|
cachedPosition = position
|
||||||
cachedDuration = duration
|
cachedDuration = duration
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
// Update PiP current time for progress bar
|
||||||
if self.pipController?.isPictureInPictureActive == true {
|
if self.pipController?.isPictureInPictureActive == true {
|
||||||
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
|
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
|
||||||
}
|
}
|
||||||
@@ -412,10 +321,12 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
|
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
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
|
||||||
|
|
||||||
print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)")
|
// Sync timebase rate with actual playback state
|
||||||
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
|
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
|
||||||
self.syncNowPlaying(isPlaying: !isPaused)
|
|
||||||
self.onPlaybackStateChange([
|
self.onPlaybackStateChange([
|
||||||
"isPaused": isPaused,
|
"isPaused": isPaused,
|
||||||
"isPlaying": !isPaused,
|
"isPlaying": !isPaused,
|
||||||
@@ -447,13 +358,6 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
self.onTracksReady([:])
|
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
|
// MARK: - PiPControllerDelegate
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ export type OnErrorEventPayload = {
|
|||||||
|
|
||||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||||
|
|
||||||
export type NowPlayingMetadata = {
|
|
||||||
title?: string;
|
|
||||||
artist?: string;
|
|
||||||
albumTitle?: string;
|
|
||||||
artworkUri?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MpvPlayerModuleEvents = {
|
export type MpvPlayerModuleEvents = {
|
||||||
onChange: (params: ChangeEventPayload) => void;
|
onChange: (params: ChangeEventPayload) => void;
|
||||||
};
|
};
|
||||||
@@ -55,8 +48,6 @@ export type VideoSource = {
|
|||||||
export type MpvPlayerViewProps = {
|
export type MpvPlayerViewProps = {
|
||||||
source?: VideoSource;
|
source?: VideoSource;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
/** Metadata for iOS Control Center and Lock Screen now playing info */
|
|
||||||
nowPlayingMetadata?: NowPlayingMetadata;
|
|
||||||
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
|
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
|
||||||
onPlaybackStateChange?: (event: {
|
onPlaybackStateChange?: (event: {
|
||||||
nativeEvent: OnPlaybackStateChangePayload;
|
nativeEvent: OnPlaybackStateChangePayload;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { MpvPlayerViewProps } from "./MpvPlayer.types";
|
import { MpvPlayerViewProps } from "./MpvPlayer.types";
|
||||||
|
|
||||||
export default function MpvPlayerView(props: MpvPlayerViewProps) {
|
export default function MpvPlayerView(props: MpvPlayerViewProps) {
|
||||||
const url = props.source?.url ?? "";
|
const url = props.source?.url ?? "";
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<iframe
|
<iframe
|
||||||
title='MPV Player'
|
title={t("player.mpv_player_title")}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
src={url}
|
src={url}
|
||||||
onLoad={() => props.onLoad?.({ nativeEvent: { url } })}
|
onLoad={() => props.onLoad?.({ nativeEvent: { url } })}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.90.20",
|
"@tanstack/react-query": "5.90.17",
|
||||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~54.0.31",
|
"expo": "~54.0.31",
|
||||||
@@ -72,12 +72,12 @@
|
|||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"i18next": "^25.0.0",
|
"i18next": "^25.0.0",
|
||||||
"jotai": "2.16.2",
|
"jotai": "2.16.2",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "16.5.4",
|
"react-i18next": "16.5.3",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.1.0",
|
"react-native-bottom-tabs": "1.1.0",
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.6",
|
"@babel/core": "7.28.6",
|
||||||
"@biomejs/biome": "2.3.11",
|
"@biomejs/biome": "2.3.11",
|
||||||
"@react-native-community/cli": "20.1.1",
|
"@react-native-community/cli": "20.1.0",
|
||||||
"@react-native-tvos/config-tv": "0.1.4",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.23",
|
"@types/lodash": "4.17.23",
|
||||||
|
|||||||
@@ -5,13 +5,10 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { BackHandler, Platform } from "react-native";
|
|
||||||
|
|
||||||
interface ModalOptions {
|
interface ModalOptions {
|
||||||
enableDynamicSizing?: boolean;
|
enableDynamicSizing?: boolean;
|
||||||
snapPoints?: (string | number)[];
|
snapPoints?: (string | number)[];
|
||||||
@@ -76,25 +73,6 @@ export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS !== "android") return;
|
|
||||||
|
|
||||||
const onBackPress = () => {
|
|
||||||
if (isVisible) {
|
|
||||||
hideModal();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const subscription = BackHandler.addEventListener(
|
|
||||||
"hardwareBackPress",
|
|
||||||
onBackPress,
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => subscription.remove();
|
|
||||||
}, [isVisible, hideModal]);
|
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
showModal,
|
showModal,
|
||||||
hideModal,
|
hideModal,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { getDeviceName } from "react-native-device-info";
|
|||||||
import uuid from "react-native-uuid";
|
import uuid from "react-native-uuid";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useInterval } from "@/hooks/useInterval";
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { SeerrApi, useSeerr } from "@/hooks/useSeerr";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
@@ -113,7 +113,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const [isPolling, setIsPolling] = useState<boolean>(false);
|
const [isPolling, setIsPolling] = useState<boolean>(false);
|
||||||
const [secret, setSecret] = useState<string | null>(null);
|
const [secret, setSecret] = useState<string | null>(null);
|
||||||
const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
|
const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
|
||||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
const { clearAllSeerrData, setSeerrUser } = useSeerr();
|
||||||
|
|
||||||
const headers = useMemo(() => {
|
const headers = useMemo(() => {
|
||||||
if (!deviceId) return {};
|
if (!deviceId) return {};
|
||||||
@@ -290,13 +290,13 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
||||||
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
if (recentPluginSettings?.seerrServerUrl?.value) {
|
||||||
const jellyseerrApi = new JellyseerrApi(
|
const seerrApi = new SeerrApi(
|
||||||
recentPluginSettings.jellyseerrServerUrl.value,
|
recentPluginSettings.seerrServerUrl.value,
|
||||||
);
|
);
|
||||||
await jellyseerrApi.test().then((result) => {
|
await seerrApi.test().then((result) => {
|
||||||
if (result.isValid && result.requiresPass) {
|
if (result.isValid && result.requiresPass) {
|
||||||
jellyseerrApi.login(username, password).then(setJellyseerrUser);
|
seerrApi.login(username, password).then(setSeerrUser);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -349,7 +349,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
setApi(null);
|
setApi(null);
|
||||||
setPluginSettings(undefined);
|
setPluginSettings(undefined);
|
||||||
await clearAllJellyseerData();
|
await clearAllSeerrData();
|
||||||
// Note: We keep saved credentials for quick switching back
|
// Note: We keep saved credentials for quick switching back
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|||||||
121
scripts/check-unused-translations.js
Normal file
121
scripts/check-unused-translations.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/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) {
|
} catch (error) {
|
||||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
||||||
|
|
||||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
// Filter out seerr utils errors - this is a third-party git submodule
|
||||||
// that generates a large volume of known type errors
|
// that generates a large volume of known type errors
|
||||||
const filteredLines = errorOutput.split("\n").filter((line) => {
|
const filteredLines = errorOutput.split("\n").filter((line) => {
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
@@ -227,7 +227,7 @@ function runTypeCheck() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log(
|
log(
|
||||||
`✅ ${colors.bold}TypeScript check passed${colors.reset} ${colors.gray}(jellyseerr utils errors ignored)${colors.reset}`,
|
`✅ ${colors.bold}TypeScript check passed${colors.reset} ${colors.gray}(seerr utils errors ignored)${colors.reset}`,
|
||||||
colors.green,
|
colors.green,
|
||||||
);
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -30,48 +30,48 @@
|
|||||||
"connect_button": "اتصل",
|
"connect_button": "اتصل",
|
||||||
"previous_servers": "الخوادم السابقة",
|
"previous_servers": "الخوادم السابقة",
|
||||||
"clear_button": "مسح",
|
"clear_button": "مسح",
|
||||||
"swipe_to_remove": "مرر للإزالة",
|
"swipe_to_remove": "Swipe to remove",
|
||||||
"search_for_local_servers": "البحث عن الخوادم المحلية",
|
"search_for_local_servers": "البحث عن الخوادم المحلية",
|
||||||
"searching": "جاري البحث...",
|
"searching": "جاري البحث...",
|
||||||
"servers": "الخوادم",
|
"servers": "الخوادم",
|
||||||
"saved": "تم الحفظ",
|
"saved": "Saved",
|
||||||
"session_expired": "انتهت الجلسة",
|
"session_expired": "Session Expired",
|
||||||
"please_login_again": "انتهت مدة صلاحية جلستك. الرجاء تسجيل الدخول مرة أخرى.",
|
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||||
"remove_saved_login": "إزالة تسجيل دخول محفوظ",
|
"remove_saved_login": "Remove Saved Login",
|
||||||
"remove_saved_login_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول الخاص بك المحفوظة لهذا الخادم. ستحتاج إلى إدخال اسم المستخدم وكلمة المرور مرة أخرى في المرة القادمة.",
|
"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_count": "{{count}} accounts",
|
||||||
"select_account": "اختر الحساب",
|
"select_account": "Select Account",
|
||||||
"add_account": "إضافة حساب",
|
"add_account": "Add Account",
|
||||||
"remove_account_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول لـ {{username}}."
|
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "حفظ الحساب",
|
"title": "Save Account",
|
||||||
"save_for_later": "حفظ هذا الحساب",
|
"save_for_later": "Save this account",
|
||||||
"security_option": "خيارات الأمان",
|
"security_option": "Security Option",
|
||||||
"no_protection": "بدون حماية",
|
"no_protection": "No protection",
|
||||||
"no_protection_desc": "تسجيل دخول سريع بدون مصادقة",
|
"no_protection_desc": "Quick login without authentication",
|
||||||
"pin_code": "رمز PIN",
|
"pin_code": "PIN code",
|
||||||
"pin_code_desc": "رمز PIN مكون من 4 أرقام مطلوب عند التبديل",
|
"pin_code_desc": "4-digit PIN required when switching",
|
||||||
"password": "أعد إدخال كلمة المرور",
|
"password": "Re-enter password",
|
||||||
"password_desc": "كلمة المرور مطلوبة عند التبديل",
|
"password_desc": "Password required when switching",
|
||||||
"save_button": "حفظ",
|
"save_button": "Save",
|
||||||
"cancel_button": "إلغاء"
|
"cancel_button": "Cancel"
|
||||||
},
|
},
|
||||||
"pin": {
|
"pin": {
|
||||||
"enter_pin": "أدخل رمز PIN",
|
"enter_pin": "Enter PIN",
|
||||||
"enter_pin_for": "أدخل رمز PIN لـ {{username}}",
|
"enter_pin_for": "Enter PIN for {{username}}",
|
||||||
"enter_4_digits": "ادخل 4 أرقام",
|
"enter_4_digits": "Enter 4 digits",
|
||||||
"invalid_pin": "PIN غير صالح",
|
"invalid_pin": "Invalid PIN",
|
||||||
"setup_pin": "تعيين رمز PIN",
|
"setup_pin": "Set Up PIN",
|
||||||
"confirm_pin": "تأكيد رمز PIN",
|
"confirm_pin": "Confirm PIN",
|
||||||
"pins_dont_match": "رموز PIN غير متطابقة",
|
"pins_dont_match": "PINs don't match",
|
||||||
"forgot_pin": "نسيت رمز PIN؟",
|
"forgot_pin": "Forgot PIN?",
|
||||||
"forgot_pin_desc": "سيتم إزالة بيانات تسجيل الدخول المحفوظة الخاصة بك"
|
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"enter_password": "أدخل كلمة المرور",
|
"enter_password": "Enter Password",
|
||||||
"enter_password_for": "أدخل كلمة المرور لـ {{username}}",
|
"enter_password_for": "Enter password for {{username}}",
|
||||||
"invalid_password": "كلمة المرور غير صحيحة"
|
"invalid_password": "Invalid password"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"checking_server_connection": "التحقق من اتصال الخادم...",
|
"checking_server_connection": "التحقق من اتصال الخادم...",
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
|
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
|
||||||
"continue_watching": "متابعة المشاهدة",
|
"continue_watching": "متابعة المشاهدة",
|
||||||
"next_up": "التالي",
|
"next_up": "التالي",
|
||||||
"continue_and_next_up": "تابع و التالي",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
||||||
"suggested_movies": "أفلام مقترحة",
|
"suggested_movies": "أفلام مقترحة",
|
||||||
"suggested_episodes": "حلقات مقترحة",
|
"suggested_episodes": "حلقات مقترحة",
|
||||||
@@ -120,36 +120,36 @@
|
|||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "المظهر",
|
"title": "المظهر",
|
||||||
"merge_next_up_continue_watching": "دمج تابع المشاهدة والتالي",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
"hide_remote_session_button": "إخفاء زر جلسة البث عن بُعد"
|
"hide_remote_session_button": "Hide Remote Session Button"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "الشبكة",
|
"title": "Network",
|
||||||
"local_network": "الشبكة المحلية",
|
"local_network": "Local Network",
|
||||||
"auto_switch_enabled": "التبديل التلقائي عند المنزل",
|
"auto_switch_enabled": "Auto-switch when at home",
|
||||||
"auto_switch_description": "التبديل تلقائياً إلى رابط URL محلي عند الاتصال بشبكة WiFi المنزلية",
|
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||||
"local_url": "رابط محلي",
|
"local_url": "Local URL",
|
||||||
"local_url_hint": "أدخل عنوان الخادم المحلي الخاص بك (على سبيل المثال http://192.168.1.100:8096)",
|
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||||
"home_wifi_networks": "شبكات WiFi المنزل",
|
"home_wifi_networks": "Home WiFi Networks",
|
||||||
"add_current_network": "إضافة \"{{ssid}}\"",
|
"add_current_network": "Add \"{{ssid}}\"",
|
||||||
"not_connected_to_wifi": "غير متصل بشبكة WiFi",
|
"not_connected_to_wifi": "Not connected to WiFi",
|
||||||
"no_networks_configured": "لا توجد شبكات مكونة",
|
"no_networks_configured": "No networks configured",
|
||||||
"add_network_hint": "إضافة شبكة WiFi المنزلية الخاصة بك لتمكين التبديل التلقائي",
|
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||||
"current_wifi": "شبكة WiFi الحالية",
|
"current_wifi": "Current WiFi",
|
||||||
"using_url": "استخدام",
|
"using_url": "Using",
|
||||||
"local": "رابط محلي",
|
"local": "Local URL",
|
||||||
"remote": "الـ URL الخارجي",
|
"remote": "Remote URL",
|
||||||
"not_connected": "غير متصل",
|
"not_connected": "Not connected",
|
||||||
"current_server": "الخادم الحالي",
|
"current_server": "Current Server",
|
||||||
"remote_url": "الـ URL الخارجي",
|
"remote_url": "Remote URL",
|
||||||
"active_url": "الرابط النشط",
|
"active_url": "Active URL",
|
||||||
"not_configured": "لم يتم تكوينه",
|
"not_configured": "Not configured",
|
||||||
"network_added": "تمت إضافة الشبكة",
|
"network_added": "Network added",
|
||||||
"network_already_added": "الشبكة مضافة مسبقاً",
|
"network_already_added": "Network already added",
|
||||||
"no_wifi_connected": "غير متصل بشبكة WiFi",
|
"no_wifi_connected": "Not connected to WiFi",
|
||||||
"permission_denied": "تم رفض إذن الوصول إلى الموقع",
|
"permission_denied": "Location permission denied",
|
||||||
"permission_denied_explanation": "يتطلب التعرف على شبكة WiFi للتبديل التلقائي الحصول على إذن الوصول إلى الموقع. يرجى تفعيله من الإعدادات."
|
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||||
},
|
},
|
||||||
"user_info": {
|
"user_info": {
|
||||||
"user_info_title": "معلومات المستخدم",
|
"user_info_title": "معلومات المستخدم",
|
||||||
@@ -182,10 +182,10 @@
|
|||||||
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
|
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
|
||||||
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
|
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
|
||||||
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت",
|
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت",
|
||||||
"hide_volume_slider": "إخفاء شريط مستوى الصوت",
|
"hide_volume_slider": "Hide Volume Slider",
|
||||||
"hide_volume_slider_description": "إخفاء شريط التحكم في مستوى الصوت في مشغل الفيديو",
|
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
||||||
"hide_brightness_slider": "إخفاء شريط السطوع",
|
"hide_brightness_slider": "Hide Brightness Slider",
|
||||||
"hide_brightness_slider_description": "إخفاء شريط التحكم في السطوع في مشغل الفيديو"
|
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"audio_title": "الصوت",
|
"audio_title": "الصوت",
|
||||||
@@ -195,12 +195,12 @@
|
|||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
"language": "اللغة",
|
"language": "اللغة",
|
||||||
"transcode_mode": {
|
"transcode_mode": {
|
||||||
"title": "تحويل ترميز الصوت",
|
"title": "Audio Transcoding",
|
||||||
"description": "يتحكم في كيفية التعامل مع الصوت المحيطي (7.1، TrueHD، DTS-HD)",
|
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||||
"auto": "تلقائي",
|
"auto": "Auto",
|
||||||
"stereo": "إجبار تشغيل ستيريو",
|
"stereo": "Force Stereo",
|
||||||
"5_1": "السماح بـ 5.1",
|
"5_1": "Allow 5.1",
|
||||||
"passthrough": "تمرير الصوت"
|
"passthrough": "Passthrough"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
@@ -251,29 +251,29 @@
|
|||||||
"Normal": "عادي",
|
"Normal": "عادي",
|
||||||
"Thick": "سميك"
|
"Thick": "سميك"
|
||||||
},
|
},
|
||||||
"subtitle_color": "لون الترجمة",
|
"subtitle_color": "Subtitle Color",
|
||||||
"subtitle_background_color": "لون الخلفية",
|
"subtitle_background_color": "Background Color",
|
||||||
"subtitle_font": "خط الترجمة",
|
"subtitle_font": "Subtitle Font",
|
||||||
"ksplayer_title": "إعدادات KSPlayer",
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
"hardware_decode": "فك الترميز بواسطة الجهاز",
|
"hardware_decode": "Hardware Decoding",
|
||||||
"hardware_decode_description": "استخدم تسريع العتاد لفك ترميز الفيديو. قم بتعطيله إذا واجهت مشكلات في التشغيل."
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "إعدادات ترجمة VLC",
|
"title": "VLC Subtitle Settings",
|
||||||
"hint": "تخصيص مظهر الترجمة لمشغل VLC. تصبح التغييرات سارية المفعول عند التشغيل التالي.",
|
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||||
"text_color": "لون النص",
|
"text_color": "Text Color",
|
||||||
"background_color": "لون الخلفية",
|
"background_color": "Background Color",
|
||||||
"background_opacity": "شفافية الخلفية",
|
"background_opacity": "Background Opacity",
|
||||||
"outline_color": "لون إطار الخط",
|
"outline_color": "Outline Color",
|
||||||
"outline_opacity": "شفافية إطار الخط",
|
"outline_opacity": "Outline Opacity",
|
||||||
"outline_thickness": "سمك إطار الخط",
|
"outline_thickness": "Outline Thickness",
|
||||||
"bold": "خط عريض",
|
"bold": "Bold Text",
|
||||||
"margin": "الهامش السفلي"
|
"margin": "Bottom Margin"
|
||||||
},
|
},
|
||||||
"video_player": {
|
"video_player": {
|
||||||
"title": "مشغل الفيديو",
|
"title": "Video Player",
|
||||||
"video_player": "مشغل الفيديو",
|
"video_player": "Video Player",
|
||||||
"video_player_description": "اختر مشغل الفيديو الذي سيتم استخدامه على نظام iOS.",
|
"video_player_description": "Choose which video player to use on iOS.",
|
||||||
"ksplayer": "KSPlayer",
|
"ksplayer": "KSPlayer",
|
||||||
"vlc": "VLC"
|
"vlc": "VLC"
|
||||||
},
|
},
|
||||||
@@ -305,8 +305,8 @@
|
|||||||
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
||||||
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
||||||
"default_quality": "الجودة الافتراضية",
|
"default_quality": "الجودة الافتراضية",
|
||||||
"default_playback_speed": "سرعة التشغيل الافتراضية",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
"auto_play_next_episode": "تشغيل الحلقة التالية تلقائياً",
|
"auto_play_next_episode": "Auto-play Next Episode",
|
||||||
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
||||||
"disabled": "معطل"
|
"disabled": "معطل"
|
||||||
},
|
},
|
||||||
@@ -314,15 +314,15 @@
|
|||||||
"downloads_title": "التنزيلات"
|
"downloads_title": "التنزيلات"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "الموسيقى",
|
"title": "Music",
|
||||||
"playback_title": "التشغيل",
|
"playback_title": "Playback",
|
||||||
"playback_description": "ضبط كيفية تشغيل الموسيقى.",
|
"playback_description": "Configure how music is played.",
|
||||||
"prefer_downloaded": "تفضيل الأغاني التي تم تنزيلها",
|
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||||
"caching_title": "التخزين المؤقت",
|
"caching_title": "Caching",
|
||||||
"caching_description": "تخزين الأغاني التالية مؤقتاً تلقائياً لضمان تشغيل أكثر سلاسة.",
|
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||||
"lookahead_enabled": "تفعيل التخزين المؤقت الاستباقي",
|
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||||
"lookahead_count": "عدد الأغاني المراد تخزينها مسبقاً",
|
"lookahead_count": "Tracks to Pre-cache",
|
||||||
"max_cache_size": "الحد الأقصى لحجم التخزين المؤقت"
|
"max_cache_size": "Max Cache Size"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "الإضافات",
|
"plugins_title": "الإضافات",
|
||||||
@@ -357,39 +357,39 @@
|
|||||||
"save_button": "حفظ",
|
"save_button": "حفظ",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "تم الحفظ",
|
"saved": "تم الحفظ",
|
||||||
"refreshed": "تم تحديث الإعدادات من الخادم"
|
"refreshed": "Settings refreshed from server"
|
||||||
},
|
},
|
||||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "تفعيل Streamystats",
|
"enable_streamystats": "Enable Streamystats",
|
||||||
"disable_streamystats": "تعطيل Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "استخدم للبحث",
|
"enable_search": "Use for Search",
|
||||||
"url": "الرابط",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
|
"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": "اقرأ المزيد عن Streamystats.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "حفظ",
|
"save_button": "Save",
|
||||||
"save": "حفظ",
|
"save": "Save",
|
||||||
"features_title": "المميزات",
|
"features_title": "Features",
|
||||||
"home_sections_title": "أقسام الرئيسية",
|
"home_sections_title": "Home Sections",
|
||||||
"enable_movie_recommendations": "توصيات الأفلام",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "توصيات المسلسلات",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
"hide_watchlists_tab": "إخفاء تبويب قوائم المشاهدة",
|
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||||
"home_sections_hint": "إظهار التوصيات المخصصة وقوائم المشاهدة المختارة من Streamystats في الصفحة الرئيسية.",
|
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||||
"recommended_movies": "أفلام موصى بها",
|
"recommended_movies": "Recommended Movies",
|
||||||
"recommended_series": "مسلسلات موصى بها",
|
"recommended_series": "Recommended Series",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "تم الحفظ",
|
"saved": "Saved",
|
||||||
"refreshed": "تم تحديث الإعدادات من الخادم",
|
"refreshed": "Settings refreshed from server",
|
||||||
"disabled": "تم تعطيل Streamystats"
|
"disabled": "Streamystats disabled"
|
||||||
},
|
},
|
||||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
|
"watchlist_enabler": "Enable our Watchlist integration",
|
||||||
"watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
|
"watchlist_button": "Toggle Watchlist integration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -398,15 +398,15 @@
|
|||||||
"device_usage": "الجهاز {{availableSpace}}%",
|
"device_usage": "الجهاز {{availableSpace}}%",
|
||||||
"size_used": "تم استخدام {{used}} من {{total}}",
|
"size_used": "تم استخدام {{used}} من {{total}}",
|
||||||
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
||||||
"music_cache_title": "التخزين المؤقت للموسيقى",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
|
"enable_music_cache": "Enable Music Cache",
|
||||||
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
"delete_all_downloaded_songs": "حذف جميع الأغاني التي تم تنزيلها",
|
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||||
"downloaded_songs_size": "تم تنزيل {{size}}",
|
"downloaded_songs_size": "{{size}} downloaded",
|
||||||
"downloaded_songs_deleted": "تم حذف الأغاني التي تم تنزيلها"
|
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "المقدمة",
|
"title": "المقدمة",
|
||||||
@@ -499,15 +499,15 @@
|
|||||||
"subtitle": "الترجمة",
|
"subtitle": "الترجمة",
|
||||||
"play": "تشغيل",
|
"play": "تشغيل",
|
||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
"track": "أغنية",
|
"track": "Track",
|
||||||
"cancel": "إلغاء",
|
"cancel": "Cancel",
|
||||||
"delete": "حذف",
|
"delete": "Delete",
|
||||||
"ok": "حسناً",
|
"ok": "OK",
|
||||||
"remove": "إزالة",
|
"remove": "Remove",
|
||||||
"next": "التالي",
|
"next": "Next",
|
||||||
"back": "رجوع",
|
"back": "Back",
|
||||||
"continue": "متابعة",
|
"continue": "Continue",
|
||||||
"verifying": "جارٍ التحقق..."
|
"verifying": "Verifying..."
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "بحث...",
|
"search": "بحث...",
|
||||||
@@ -521,10 +521,10 @@
|
|||||||
"episodes": "حلقات",
|
"episodes": "حلقات",
|
||||||
"collections": "مجموعات",
|
"collections": "مجموعات",
|
||||||
"actors": "ممثلون",
|
"actors": "ممثلون",
|
||||||
"artists": "الفنانون",
|
"artists": "Artists",
|
||||||
"albums": "الألبومات",
|
"albums": "Albums",
|
||||||
"songs": "الأغاني",
|
"songs": "Songs",
|
||||||
"playlists": "قوائم التشغيل",
|
"playlists": "Playlists",
|
||||||
"request_movies": "طلب أفلام",
|
"request_movies": "طلب أفلام",
|
||||||
"request_series": "طلب مسلسلات",
|
"request_series": "طلب مسلسلات",
|
||||||
"recently_added": "أضيف مؤخرًا",
|
"recently_added": "أضيف مؤخرًا",
|
||||||
@@ -572,7 +572,7 @@
|
|||||||
"genres": "الأنواع",
|
"genres": "الأنواع",
|
||||||
"years": "السنوات",
|
"years": "السنوات",
|
||||||
"sort_by": "ترتيب حسب",
|
"sort_by": "ترتيب حسب",
|
||||||
"filter_by": "تصفية حسب",
|
"filter_by": "Filter By",
|
||||||
"sort_order": "اتجاه الترتيب",
|
"sort_order": "اتجاه الترتيب",
|
||||||
"tags": "الوسوم"
|
"tags": "الوسوم"
|
||||||
}
|
}
|
||||||
@@ -604,11 +604,11 @@
|
|||||||
"index": "الفِهْرِس:",
|
"index": "الفِهْرِس:",
|
||||||
"continue_watching": "متابعة المشاهدة",
|
"continue_watching": "متابعة المشاهدة",
|
||||||
"go_back": "رجوع",
|
"go_back": "رجوع",
|
||||||
"downloaded_file_title": "تم تنزيل هذا الملف",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
"downloaded_file_message": "هل تريد تشغيل الملف الذي تم تنزيله؟",
|
"downloaded_file_message": "هل تريد تشغيل الملف الذي تم تنزيله؟",
|
||||||
"downloaded_file_yes": "نعم",
|
"downloaded_file_yes": "نعم",
|
||||||
"downloaded_file_no": "لا",
|
"downloaded_file_no": "لا",
|
||||||
"downloaded_file_cancel": "إلغاء"
|
"downloaded_file_cancel": "Cancel"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "التالي",
|
"next_up": "التالي",
|
||||||
@@ -624,7 +624,7 @@
|
|||||||
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
|
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
|
||||||
"video": "فيديو",
|
"video": "فيديو",
|
||||||
"more_details": "المزيد من التفاصيل",
|
"more_details": "المزيد من التفاصيل",
|
||||||
"media_options": "خيارات الوسائط",
|
"media_options": "Media Options",
|
||||||
"quality": "الجودة",
|
"quality": "الجودة",
|
||||||
"audio": "الصوت",
|
"audio": "الصوت",
|
||||||
"subtitles": "الترجمة",
|
"subtitles": "الترجمة",
|
||||||
@@ -719,127 +719,127 @@
|
|||||||
"favorites": "المفضلة"
|
"favorites": "المفضلة"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "الموسيقى",
|
"title": "Music",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"suggestions": "الإقتراحات",
|
"suggestions": "Suggestions",
|
||||||
"albums": "الألبومات",
|
"albums": "Albums",
|
||||||
"artists": "الفنانون",
|
"artists": "Artists",
|
||||||
"playlists": "قوائم التشغيل",
|
"playlists": "Playlists",
|
||||||
"tracks": "الأغاني"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "الكل"
|
"all": "All"
|
||||||
},
|
},
|
||||||
"recently_added": "أضيف مؤخرًا",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "تم تشغيله مؤخرًا",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "الأكثر تشغيلاً",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "اكتشف",
|
"explore": "Explore",
|
||||||
"top_tracks": "أفضل الأغاني",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "تشغيل",
|
"play": "Play",
|
||||||
"shuffle": "ترتيب عشوائي",
|
"shuffle": "Shuffle",
|
||||||
"play_top_tracks": "تشغيل أفضل الأغاني",
|
"play_top_tracks": "Play Top Tracks",
|
||||||
"no_suggestions": "لا توجد مقترحات متاحة",
|
"no_suggestions": "No suggestions available",
|
||||||
"no_albums": "لا توجد ألبومات",
|
"no_albums": "No albums found",
|
||||||
"no_artists": "لا يوجد فنانون",
|
"no_artists": "No artists found",
|
||||||
"no_playlists": "لا توجد قوائم تشغيل",
|
"no_playlists": "No playlists found",
|
||||||
"album_not_found": "الألبوم غير موجود",
|
"album_not_found": "Album not found",
|
||||||
"artist_not_found": "الفنان غير موجود",
|
"artist_not_found": "Artist not found",
|
||||||
"playlist_not_found": "قائمة التشغيل غير موجودة",
|
"playlist_not_found": "Playlist not found",
|
||||||
"track_options": {
|
"track_options": {
|
||||||
"play_next": "تشغيل التالي",
|
"play_next": "Play Next",
|
||||||
"add_to_queue": "إضافة إلى قائمة الانتظار",
|
"add_to_queue": "Add to Queue",
|
||||||
"add_to_playlist": "أضف إلى قائمة التشغيل",
|
"add_to_playlist": "Add to Playlist",
|
||||||
"download": "تنزيل",
|
"download": "Download",
|
||||||
"downloaded": "تم التنزيل",
|
"downloaded": "Downloaded",
|
||||||
"downloading": "جارٍ التنزيل...",
|
"downloading": "Downloading...",
|
||||||
"cached": "تم التخزين مؤقتاً",
|
"cached": "Cached",
|
||||||
"delete_download": "حذف ملف التنزيل",
|
"delete_download": "Delete Download",
|
||||||
"delete_cache": "إزالة من التخزين المؤقت",
|
"delete_cache": "Remove from Cache",
|
||||||
"go_to_artist": "انتقال إلى الفنان",
|
"go_to_artist": "Go to Artist",
|
||||||
"go_to_album": "انتقال إلى الألبوم",
|
"go_to_album": "Go to Album",
|
||||||
"add_to_favorites": "إضافة إلى المفضلة",
|
"add_to_favorites": "Add to Favorites",
|
||||||
"remove_from_favorites": "إزالة من المفضلة",
|
"remove_from_favorites": "Remove from Favorites",
|
||||||
"remove_from_playlist": "إزالة من قائمة التشغيل"
|
"remove_from_playlist": "Remove from Playlist"
|
||||||
},
|
},
|
||||||
"playlists": {
|
"playlists": {
|
||||||
"create_playlist": "إنشاء قائمة التشغيل",
|
"create_playlist": "Create Playlist",
|
||||||
"playlist_name": "اسم قائمة التشغيل",
|
"playlist_name": "Playlist Name",
|
||||||
"enter_name": "أدخل اسم قائمة التشغيل",
|
"enter_name": "Enter playlist name",
|
||||||
"create": "إنشاء",
|
"create": "Create",
|
||||||
"search_playlists": "البحث عن قوائم التشغيل...",
|
"search_playlists": "Search playlists...",
|
||||||
"added_to": "تمت الإضافة إلى {{name}}",
|
"added_to": "Added to {{name}}",
|
||||||
"added": "تمت الإضافة إلى قائمة التشغيل",
|
"added": "Added to playlist",
|
||||||
"removed_from": "تمت الإزالة من {{name}}",
|
"removed_from": "Removed from {{name}}",
|
||||||
"removed": "تمت الازالة من قائمة التشغيل",
|
"removed": "Removed from playlist",
|
||||||
"created": "تم إنشاء قائمة التشغيل",
|
"created": "Playlist created",
|
||||||
"create_new": "إنشاء قائمة تشغيل جديدة",
|
"create_new": "Create New Playlist",
|
||||||
"failed_to_add": "فشلت الإضافة إلى قائمة التشغيل",
|
"failed_to_add": "Failed to add to playlist",
|
||||||
"failed_to_remove": "فشلت الإزالة من قائمة التشغيل",
|
"failed_to_remove": "Failed to remove from playlist",
|
||||||
"failed_to_create": "فشل إنشاء قائمة التشغيل",
|
"failed_to_create": "Failed to create playlist",
|
||||||
"delete_playlist": "حذف قائمة التشغيل",
|
"delete_playlist": "Delete Playlist",
|
||||||
"delete_confirm": "هل أنت متأكد من رغبتك في حذف {{name}}؟ لا يمكن التراجع عن هذا الإجراء.",
|
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||||
"deleted": "تم حذف قائمة التشغيل",
|
"deleted": "Playlist deleted",
|
||||||
"failed_to_delete": "فشل إنشاء قائمة التشغيل"
|
"failed_to_delete": "Failed to delete playlist"
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"title": "ترتيب حسب",
|
"title": "Sort By",
|
||||||
"alphabetical": "أبجدي",
|
"alphabetical": "Alphabetical",
|
||||||
"date_created": "تاريخ الإنشاء"
|
"date_created": "Date Created"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"watchlists": {
|
"watchlists": {
|
||||||
"title": "قوائم المشاهدة",
|
"title": "Watchlists",
|
||||||
"my_watchlists": "قوائم المشاهدة الخاصة بي",
|
"my_watchlists": "My Watchlists",
|
||||||
"public_watchlists": "قوائم مشاهدة عامة",
|
"public_watchlists": "Public Watchlists",
|
||||||
"create_title": "إنشاء قائمة مشاهدة",
|
"create_title": "Create Watchlist",
|
||||||
"edit_title": "تعديل قائمة المشاهدة",
|
"edit_title": "Edit Watchlist",
|
||||||
"create_button": "إنشاء قائمة مشاهدة",
|
"create_button": "Create Watchlist",
|
||||||
"save_button": "حفظ التغييرات",
|
"save_button": "Save Changes",
|
||||||
"delete_button": "حذف",
|
"delete_button": "Delete",
|
||||||
"remove_button": "إزالة",
|
"remove_button": "Remove",
|
||||||
"cancel_button": "إلغاء",
|
"cancel_button": "Cancel",
|
||||||
"name_label": "الاسم",
|
"name_label": "Name",
|
||||||
"name_placeholder": "أدخل اسم قائمة المشاهدة",
|
"name_placeholder": "Enter watchlist name",
|
||||||
"description_label": "الوصف",
|
"description_label": "Description",
|
||||||
"description_placeholder": "أدخل الوصف (اختياري)",
|
"description_placeholder": "Enter description (optional)",
|
||||||
"is_public_label": "قائمة مشاهدة عامة",
|
"is_public_label": "Public Watchlist",
|
||||||
"is_public_description": "السماح للآخرين بعرض قائمة المشاهدة هذه",
|
"is_public_description": "Allow others to view this watchlist",
|
||||||
"allowed_type_label": "نوع المحتوى",
|
"allowed_type_label": "Content Type",
|
||||||
"sort_order_label": "الترتيب الافتراضي",
|
"sort_order_label": "Default Sort Order",
|
||||||
"empty_title": "لا توجد قوائم مشاهدة",
|
"empty_title": "No Watchlists",
|
||||||
"empty_description": "قم بإنشاء أول قائمة مشاهدة لبدء تنظيم الوسائط الخاصة بك",
|
"empty_description": "Create your first watchlist to start organizing your media",
|
||||||
"empty_watchlist": "قائمة المشاهدة هذه فارغة",
|
"empty_watchlist": "This watchlist is empty",
|
||||||
"empty_watchlist_hint": "إضافة عناصر من مكتبتك إلى قائمة المشاهدة هذه",
|
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||||
"not_configured_title": "لم يتم ضبط Streamystats",
|
"not_configured_title": "Streamystats Not Configured",
|
||||||
"not_configured_description": "اضبط Streamystats في الإعدادات لاستخدام قوائم المشاهدة",
|
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||||
"go_to_settings": "الذهاب إلى الإعدادات",
|
"go_to_settings": "Go to Settings",
|
||||||
"add_to_watchlist": "إضافة إلى قائمة المشاهدة",
|
"add_to_watchlist": "Add to Watchlist",
|
||||||
"remove_from_watchlist": "إزالة من قائمة المشاهدة",
|
"remove_from_watchlist": "Remove from Watchlist",
|
||||||
"select_watchlist": "تحديد قائمة المشاهدة",
|
"select_watchlist": "Select Watchlist",
|
||||||
"create_new": "إنشاء قائمة مشاهدة جديدة",
|
"create_new": "Create New Watchlist",
|
||||||
"item": "عنصر",
|
"item": "item",
|
||||||
"items": "عناصر",
|
"items": "items",
|
||||||
"public": "عامة",
|
"public": "Public",
|
||||||
"private": "خاصة",
|
"private": "Private",
|
||||||
"you": "أنت",
|
"you": "You",
|
||||||
"by_owner": "بواسطة مستخدم آخر",
|
"by_owner": "By another user",
|
||||||
"not_found": "قائمة المشاهدة غير موجودة",
|
"not_found": "Watchlist not found",
|
||||||
"delete_confirm_title": "حذف قائمة المشاهدة",
|
"delete_confirm_title": "Delete Watchlist",
|
||||||
"delete_confirm_message": "هل أنت متأكد من رغبتك في حذف \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.",
|
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||||
"remove_item_title": "إزالة من قائمة المشاهدة",
|
"remove_item_title": "Remove from Watchlist",
|
||||||
"remove_item_message": "إزالة \"{{name}}\" من قائمة المشاهدة هذه؟",
|
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||||
"loading": "تحميل قوائم المشاهدة...",
|
"loading": "Loading watchlists...",
|
||||||
"no_compatible_watchlists": "لا توجد قوائم مشاهدة متوافقة",
|
"no_compatible_watchlists": "No compatible watchlists",
|
||||||
"create_one_first": "إنشاء قائمة مشاهدة تقبل نوع المحتوى هذا"
|
"create_one_first": "Create a watchlist that accepts this content type"
|
||||||
},
|
},
|
||||||
"playback_speed": {
|
"playback_speed": {
|
||||||
"title": "سرعة التشغيل",
|
"title": "Playback Speed",
|
||||||
"apply_to": "تطبيق على",
|
"apply_to": "Apply To",
|
||||||
"speed": "السرعة",
|
"speed": "Speed",
|
||||||
"scope": {
|
"scope": {
|
||||||
"media": "الوسائط هذه فقط",
|
"media": "This media only",
|
||||||
"show": "هذا المسلسل",
|
"show": "This show",
|
||||||
"all": "جميع الوسائط (الافتراضي)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user