Compare commits

..

17 Commits

Author SHA1 Message Date
Uruk
aadb5f9f6d refactor(app): rename default function components
Renames default function components to PascalCase for consistency.

This change improves code readability and adheres to React component naming conventions.
2026-02-24 14:58:45 +01:00
Uruk
bc3e8dd9ed chore: update biome to v2.4.4
updates the biome linter and formatter to the latest version

this ensures that the project benefits from the newest features
and bug fixes in biome.
2026-02-24 14:58:30 +01:00
Gallyam Biktashev
479e23f001 fix(i18n): Update transalation for Russian lang (#1427)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-21 18:51:01 +01:00
github-actions[bot]
019f863b3e feat: New Crowdin Translations (#1346)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-19 07:45:11 +01:00
renovate[bot]
54d8693999 chore(deps): Update crowdin/github-action action to v2.14.1 (#1453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 07:44:30 +01:00
quang-tran
8c0cbffd98 fix(logs): change the mimeType and UTI to the proper types for log ex… (#1424) 2026-02-19 07:39:29 +01:00
Alex
9f9d949891 feat: Re-add native apple controls for mpv (#1411) 2026-02-19 07:38:01 +01:00
renovate[bot]
8a782fb0c9 chore(deps): Update dependency @react-native-community/cli to v20.1.1 (#1413)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:26:38 +01:00
renovate[bot]
7d0b6c37b8 chore(deps): Update dependency react-i18next to v16.5.4 (#1414)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:26:01 +01:00
renovate[bot]
5443222e4e chore(deps): Update actions/cache action to v5.0.2 (#1397)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:31 +01:00
renovate[bot]
a77e86be21 chore(deps): Update github/codeql-action action to v4.32.0 (#1410)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:22 +01:00
renovate[bot]
9d82549cd6 chore(deps): Update dependency @tanstack/react-query to v5.90.20 (#1405)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:12 +01:00
renovate[bot]
cb5cb8bff7 chore(deps): Update dependency lodash to v4.17.23 [SECURITY] (#1394)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:02 +01:00
renovate[bot]
0b61fb7d98 chore(deps): Update peter-evans/create-pull-request action to v8.1.0 (#1401)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:53 +01:00
renovate[bot]
f762b9017f chore(deps): Update crowdin/github-action action to v2.14.0 (#1400)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:43 +01:00
renovate[bot]
b063fd6405 chore(deps): Update oven-sh/setup-bun action to v2.1.2 (#1399)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:35 +01:00
renovate[bot]
88ac94ebeb chore(deps): Update actions/checkout action to v6.0.2 (#1398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:24 +01:00
134 changed files with 4066 additions and 2591 deletions

View File

@@ -3,7 +3,7 @@
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
and provides seamless media streaming with offline capabilities and Chromecast support.
## Main Technologies
@@ -40,30 +40,9 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins
## Code Quality Standards
## Coding Standards
**CRITICAL: Code must be production-ready, reliable, and maintainable**
### Type Safety
- Use TypeScript for ALL files (no .js files)
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
- When facing type issues, create proper type definitions and helper functions instead of using `any`
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
- Enable and respect strict TypeScript compiler options
- Define explicit return types for functions
- Use discriminated unions for complex state
### Code Reliability
- Implement comprehensive error handling with try-catch blocks
- Validate all external inputs (API responses, user input, query params)
- Handle edge cases explicitly (empty arrays, null, undefined)
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
- Add runtime checks for critical operations
- Implement proper loading and error states in components
### Best Practices
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management
@@ -71,10 +50,8 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- Follow BiomeJS formatting and linting rules
- Use `const` over `let`, avoid `var` entirely
- Implement proper error boundaries
- Use React.memo() for performance optimization when needed
- Use React.memo() for performance optimization
- Handle both mobile and TV navigation patterns
- Write self-documenting code with clear intent
- Add comments only when code complexity requires explanation
## API Integration
@@ -108,18 +85,6 @@ Exemples:
- `fix(auth): handle expired JWT tokens`
- `chore(deps): update Jellyfin SDK`
## Internationalization (i18n)
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
- **NEVER add or remove keys** - Crowdin manages the key structure
- **Editing translation values is safe** - Bidirectional sync handles merges
- Prefer letting Crowdin translators update values, but direct edits work if needed
- **Crowdin workflow**:
- New keys added to `en.json` sync to Crowdin automatically
- Approved translations sync back to language files via GitHub integration
- The source of truth is `en.json` for structure, Crowdin for translations
## Special Instructions
- Prioritize cross-platform compatibility (mobile + TV)

View File

@@ -33,7 +33,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -41,12 +41,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -60,7 +60,7 @@ jobs:
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
~/.gradle/caches
@@ -73,7 +73,7 @@ jobs:
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -116,7 +116,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -124,12 +124,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -143,7 +143,7 @@ jobs:
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
~/.gradle/caches
@@ -156,7 +156,7 @@ jobs:
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -187,7 +187,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -195,12 +195,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -251,7 +251,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -259,12 +259,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
@@ -27,12 +27,12 @@ jobs:
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
~/.bun/install/cache

View File

@@ -24,16 +24,16 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0

View File

@@ -23,12 +23,12 @@ jobs:
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
uses: crowdin/github-action@5587c43063e52090026857d386174d2599ad323b # v2.14.1
with:
upload_sources: true
upload_translations: true

View File

@@ -51,7 +51,7 @@ jobs:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -69,14 +69,14 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
@@ -100,7 +100,7 @@ jobs:
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
@@ -112,7 +112,7 @@ jobs:
node-version: '24.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🟢 Setup Node.js"
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -54,7 +54,7 @@ jobs:
dry_run: no-push
- name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report

View File

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Seerr integration.
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
## Development Commands

101
README.md
View File

@@ -22,75 +22,58 @@
&nbsp;
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
&nbsp;
<img src="./assets/images/seerr.PNG" width="21%">
<img src="./assets/images/jellyseerr.PNG" width="21%">
</p>
## 🌟 Features
### 🎬 Media Playback
- 🚀 **Skip Intro / Credits**: Automatically skip intros and credits during playback
- 🖼️ **Trickplay Images**: Chapter previews with thumbnails when seeking
- 🎵 **Music Library**: Full support for music playback with playlists and queue management
- 📺 **Live TV**: Watch live television streams
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
- 📥 **Download media**: Save your media locally and watch it offline
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
- 🎥 **MPV Player**: Powerful open-source player with wide format support
### 📱 Media Management
- 📥 **Download Media**: Save movies, shows, and music locally for offline viewing
-**Favorites**: Quick access to your favorite content
- 📋 **Watchlists**: Create and manage custom watchlists with Streamystats integration
- 🔖 **Continue Watching**: Pick up right where you left off
- 🎯 **Next Up**: Suggestions for your next episode
## 🧪 Experimental Features
### ⚙️ Advanced Features
- 🤖 **Seerr Integration**: Request new media directly in the app
- 🔍 **Smart Search**: Powerful search with Marlin Search and Streamystats support
- 👁️ **Active Sessions**: View all active streams on your server
- 🌐 **Multi-Language**: Available in 20+ languages with Crowdin integration
- 🎨 **Customizable**: Personalize your home screen and settings
- 🔌 **Plugin System**: Centralized settings sync across all devices via Jellyfin plugin
Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better.
## 🧩 How It Works
### 📥 Downloads
### 📥 Downloading
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
### 🧩 Streamyfin Plugin
The Jellyfin Plugin for Streamyfin synchronizes settings across all your devices and users. Install it on your Jellyfin server to enable:
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
- Automatic Seerr login with no user input required
- Default language preferences for audio and subtitles
- Configure download settings and search providers (Marlin, Streamystats)
- Customize your home screen layout and sections
- Centralized configuration management
- Set your preferred default languages
- Configure download method and search provider
- Personalize your home screen
- And much more
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
### 📡 Chromecast
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
### 🎬 MPV Player
Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback.
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
### 🎵 Music Library
### 🔍 Jellysearch
Full music library support with playlists, queue management, background playback, and offline downloads.
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
### 🔍 Search Providers
Streamyfin supports multiple search providers:
- **Marlin Search**: Fast semantic search for your Jellyfin library
- **Streamystats**: Advanced statistics and personalized recommendations
- **Jellysearch**: Fast full-text search proxy ([Jellysearch](https://gitlab.com/DomiStyle/jellysearch))
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
## 🛣️ Roadmap
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## 📥 Download Streamyfin
@@ -130,12 +113,13 @@ You can contribute translations directly on our [Crowdin project page](https://c
### 👨‍💻 Development Info
1. Use Node.js `>20`
2. Install dependencies: `bun i && bun run submodule-reload`
3. Make sure you have Xcode and/or Android Studio installed ([Expo setup guide](https://docs.expo.dev/workflow/android-studio-emulator/))
4. Install the [BiomeJS extension](https://biomejs.dev/) in your IDE
5. Run `npm run prebuild`
6. Create an Expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
For the TV version suffix the npm commands with `:tv`.
@@ -153,20 +137,10 @@ Need assistance or have any questions?
## ❓ FAQ
1. **Q: Why can't I see my libraries in Streamyfin?**
A: Ensure your Jellyfin server is running a recent version (10.10.0+) and that you have proper permissions to access the libraries.
2. **Q: How do I enable downloads?**
A: Downloads use FFmpeg to convert HLS streams. Ensure your server has transcoding enabled and sufficient resources.
3. **Q: Does Streamyfin support subtitles?**
A: Yes, with full customization including size, color, position, and automatic language selection.
4. **Q: Can I use Streamyfin on Apple TV or Android TV?**
A: Yes, Streamyfin has dedicated TV builds, but they are currently in **early development** and may have stability issues.
5. **Q: How do I set up Seerr integration?**
A: Go to Settings → Plugins → Seerr, enter your server URL and Jellyfin credentials.
1. Q: Why can't I see my libraries in Streamyfin?
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
2. Q: Why can't I see my music library?
A: We don't currently support music and are unlikely to support music in the near future
## 📝 Credits
@@ -280,9 +254,7 @@ A special mention to the following people and projects for their contributions:
## 📄 License
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
Key points of the MPL-2.0:
- You can use the software for any purpose
@@ -291,13 +263,10 @@ Key points of the MPL-2.0:
- You must disclose your source code for any modifications to the covered files
- Larger works may combine MPL code with code under other licenses
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
For the full text of the license, please see the LICENSE file in this repository.
- For the full text of the license, please see the LICENSE file in this repository
## ⚠️ Disclaimer
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
## 🤝 Sponsorship
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster).
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)

View File

@@ -16,7 +16,7 @@ export interface MenuLink {
icon: string;
}
export default function menuLinks() {
export default function MenuLinks() {
const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets();
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);

View File

@@ -1,10 +1,9 @@
import { useCallback, useState } from "react";
import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Favorites } from "@/components/home/Favorites";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
export default function favorites() {
export default function Favorites() {
const invalidateCache = useInvalidatePlaybackProgressCache();
const [loading, setLoading] = useState(false);

View File

@@ -222,9 +222,9 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name='settings/plugins/seerr/page'
name='settings/plugins/jellyseerr/page'
options={{
title: "Seerr",
title: "Jellyseerr",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,

View File

@@ -20,7 +20,7 @@ import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log";
export default function page() {
export default function Page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [_queue, _setQueue] = useAtom(queueAtom);

View File

@@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString } from "@/utils/time";
export default function page() {
export default function Page() {
const { sessions, isLoading } = useSessions({} as useSessionsProps);
const { t } = useTranslation();

View File

@@ -14,7 +14,7 @@ import { UserInfo } from "@/components/settings/UserInfo";
import useRouter from "@/hooks/useAppRouter";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
export default function settings() {
export default function Settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom);

View File

@@ -12,7 +12,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function Page() {
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -71,7 +71,7 @@ export default function page() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")}
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
</ScrollView>

View File

@@ -11,7 +11,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function Page() {
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -60,7 +60,7 @@ export default function page() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")}
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
);

View File

@@ -61,7 +61,10 @@ export default function Page() {
setLoading(true);
try {
logsFile.write(JSON.stringify(filteredLogs));
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
await Sharing.shareAsync(logsFile.uri, {
mimeType: "text/plain",
UTI: "public.plain-text",
});
} catch (e: any) {
writeErrorLog("Something went wrong attempting to export", e);
} finally {

View File

@@ -1,7 +1,7 @@
import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { SeerrSettings } from "@/components/settings/Seerr";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function Page() {
@@ -17,10 +17,10 @@ export default function Page() {
}}
>
<DisabledSetting
disabled={pluginSettings?.seerrServerUrl?.locked === true}
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='px-4'
>
<SeerrSettings />
<JellyseerrSettings />
</DisabledSetting>
</ScrollView>
);

View File

@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function Page() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();

View File

@@ -18,7 +18,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function Page() {
const navigation = useNavigation();
const { t } = useTranslation();

View File

@@ -17,7 +17,7 @@ import { ListItem } from "@/components/list/ListItem";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function Page() {
const { t } = useTranslation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();

View File

@@ -3,9 +3,9 @@ import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import { useMemo } from "react";
import SeerrPoster from "@/components/posters/SeerrPoster";
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
@@ -13,9 +13,9 @@ import {
} from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
export default function CompanyPage() {
export default function Page() {
const local = useLocalSearchParams();
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { companyId, image, type } = local as unknown as {
companyId: string;
@@ -25,12 +25,12 @@ export default function CompanyPage() {
};
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
queryKey: ["seerr", "company", type, companyId],
queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
};
return seerrApi?.discover(
return jellyseerrApi?.discover(
`${
Number(type) === DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
@@ -39,7 +39,7 @@ export default function CompanyPage() {
params,
);
},
enabled: !!seerrApi && !!companyId,
enabled: !!jellyseerrApi && !!companyId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -53,24 +53,25 @@ export default function CompanyPage() {
data?.pages
?.filter((p) => p?.results.length)
.flatMap(
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
"id",
) ?? [],
[data, isSeerrMovieOrTvResult],
[data],
);
const backdrops = useMemo(
() =>
seerrApi
jellyseerrApi
? flatData.map((r) =>
seerrApi.imageProxy(
jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[seerrApi, flatData],
[jellyseerrApi, flatData],
);
return (
@@ -91,7 +92,7 @@ export default function CompanyPage() {
key={companyId}
className='bottom-1 w-1/2'
source={{
uri: seerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}}
cachePolicy={"memory-disk"}
contentFit='contain'
@@ -100,7 +101,7 @@ export default function CompanyPage() {
}}
/>
}
renderItem={(item, _index) => <SeerrPoster item={item} />}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/>
);
}

View File

@@ -3,15 +3,15 @@ import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import { useMemo } from "react";
import { Text } from "@/components/common/Text";
import SeerrPoster from "@/components/posters/SeerrPoster";
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
export default function GenrePage() {
export default function Page() {
const local = useLocalSearchParams();
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { genreId, name, type } = local as unknown as {
genreId: string;
@@ -20,21 +20,21 @@ export default function GenrePage() {
};
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["seerr", "genre", type, genreId],
queryKey: ["jellyseerr", "company", type, genreId],
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
genre: genreId,
};
return seerrApi?.discover(
return jellyseerrApi?.discover(
type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV,
params,
);
},
enabled: !!seerrApi && !!genreId,
enabled: !!jellyseerrApi && !!genreId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -48,7 +48,8 @@ export default function GenrePage() {
data?.pages
?.filter((p) => p?.results.length)
.flatMap(
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
"id",
) ?? [],
@@ -57,12 +58,15 @@ export default function GenrePage() {
const backdrops = useMemo(
() =>
seerrApi
jellyseerrApi
? flatData.map((r) =>
seerrApi.imageProxy(r.backdropPath, "w1920_and_h800_multi_faces"),
jellyseerrApi.imageProxy(
r.backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[seerrApi, flatData],
[jellyseerrApi, flatData],
);
return (
@@ -87,7 +91,7 @@ export default function GenrePage() {
{name}
</Text>
}
renderItem={(item, _index) => <SeerrPoster item={item} />}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/>
);
}

View File

@@ -18,18 +18,18 @@ import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import RequestModal from "@/components/jellyseerr/RequestModal";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { SeerrRatings } from "@/components/Ratings";
import Cast from "@/components/seerr/Cast";
import DetailFacts from "@/components/seerr/DetailFacts";
import RequestModal from "@/components/seerr/RequestModal";
import SeerrSeasons from "@/components/series/SeerrSeasons";
import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import useRouter from "@/hooks/useAppRouter";
import { useSeerr } from "@/hooks/useSeerr";
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import {
type IssueType,
@@ -68,7 +68,7 @@ const Page: React.FC = () => {
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation();
const { seerrApi, seerrUser, requestMedia } = useSeerr();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
@@ -83,8 +83,8 @@ const Page: React.FC = () => {
isLoading,
refetch,
} = useQuery({
enabled: !!seerrApi && !!result && !!result.id,
queryKey: ["seerr", "detail", mediaType, result.id],
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", mediaType, result.id],
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
@@ -93,18 +93,21 @@ const Page: React.FC = () => {
refetchInterval: 0,
queryFn: async () => {
return mediaType === MediaType.MOVIE
? seerrApi?.movieDetails(result.id!)
: seerrApi?.tvDetails(result.id!);
? jellyseerrApi?.movieDetails(result.id!)
: jellyseerrApi?.tvDetails(result.id!);
},
});
const [canRequest, hasAdvancedRequestPermission] =
useSeerrCanRequest(details);
useJellyseerrCanRequest(details);
const canManageRequests = useMemo(() => {
if (!seerrUser) return false;
return hasPermission(Permission.MANAGE_REQUESTS, seerrUser.permissions);
}, [seerrUser]);
if (!jellyseerrUser) return false;
return hasPermission(
Permission.MANAGE_REQUESTS,
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find(
@@ -116,27 +119,27 @@ const Page: React.FC = () => {
if (!pendingRequest?.id) return;
try {
await seerrApi?.approveRequest(pendingRequest.id);
toast.success(t("seerr.toasts.request_approved"));
await jellyseerrApi?.approveRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_approved"));
refetch();
} catch (error) {
toast.error(t("seerr.toasts.failed_to_approve_request"));
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
console.error("Failed to approve request:", error);
}
}, [seerrApi, pendingRequest, refetch, t]);
}, [jellyseerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await seerrApi?.declineRequest(pendingRequest.id);
toast.success(t("seerr.toasts.request_declined"));
await jellyseerrApi?.declineRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_declined"));
refetch();
} catch (error) {
toast.error(t("seerr.toasts.failed_to_decline_request"));
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
console.error("Failed to decline request:", error);
}
}, [seerrApi, pendingRequest, refetch, t]);
}, [jellyseerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -151,7 +154,7 @@ const Page: React.FC = () => {
const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) {
seerrApi
jellyseerrApi
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
.then(() => {
setIssueType(undefined);
@@ -159,7 +162,7 @@ const Page: React.FC = () => {
bottomSheetModalRef?.current?.close();
});
}
}, [seerrApi, details, result, issueType, issueMessage]);
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const handleIssueModalDismiss = useCallback(() => {
setIssueTypeDropdownOpen(false);
@@ -211,7 +214,7 @@ const Page: React.FC = () => {
const issueTypeOptionGroups = useMemo(
() => [
{
title: t("seerr.types"),
title: t("jellyseerr.types"),
options: Object.entries(IssueTypeName)
.reverse()
.map(([key, value]) => ({
@@ -262,7 +265,7 @@ const Page: React.FC = () => {
height: "100%",
}}
source={{
uri: seerrApi?.imageProxy(
uri: jellyseerrApi?.imageProxy(
result.backdropPath,
"w1920_and_h800_multi_faces",
),
@@ -292,7 +295,7 @@ const Page: React.FC = () => {
<View className='px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'>
<SeerrRatings
<JellyserrRatings
result={
result as
| MovieResult
@@ -327,7 +330,7 @@ const Page: React.FC = () => {
/>
) : canRequest ? (
<Button color='purple' onPress={request} className='mt-4'>
{t("seerr.request_button")}
{t("jellyseerr.request_button")}
</Button>
) : (
details?.mediaInfo?.jellyfinMediaId && (
@@ -350,7 +353,7 @@ const Page: React.FC = () => {
}}
>
<Text className='text-sm'>
{t("seerr.report_issue_button")}
{t("jellyseerr.report_issue_button")}
</Text>
</Button>
)}
@@ -386,12 +389,12 @@ const Page: React.FC = () => {
<View className='flex flex-row items-center space-x-2'>
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
<Text className='text-sm text-neutral-400'>
{t("seerr.requested_by", {
{t("jellyseerr.requested_by", {
user:
pendingRequest.requestedBy?.displayName ||
pendingRequest.requestedBy?.username ||
pendingRequest.requestedBy?.jellyfinUsername ||
t("seerr.unknown_user"),
t("jellyseerr.unknown_user"),
})}
</Text>
</View>
@@ -412,7 +415,7 @@ const Page: React.FC = () => {
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("seerr.approve")}</Text>
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
</Button>
<Button
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
@@ -430,7 +433,7 @@ const Page: React.FC = () => {
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("seerr.decline")}</Text>
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
</Button>
</View>
</View>
@@ -439,7 +442,7 @@ const Page: React.FC = () => {
</View>
{mediaType === MediaType.TV && (
<SeerrSeasons
<JellyseerrSeasons
isLoading={isLoading || isFetching}
details={details as TvDetails}
refetch={refetch}
@@ -488,13 +491,13 @@ const Page: React.FC = () => {
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("seerr.whats_wrong")}
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col w-full'>
<Text className='opacity-50 mb-1 text-xs'>
{t("seerr.issue_type")}
{t("jellyseerr.issue_type")}
</Text>
<PlatformDropdown
groups={issueTypeOptionGroups}
@@ -503,11 +506,11 @@ const Page: React.FC = () => {
<Text numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("seerr.select_an_issue")}
: t("jellyseerr.select_an_issue")}
</Text>
</View>
}
title={t("seerr.types")}
title={t("jellyseerr.types")}
open={issueTypeDropdownOpen}
onOpenChange={setIssueTypeDropdownOpen}
/>
@@ -519,7 +522,7 @@ const Page: React.FC = () => {
maxLength={254}
style={{ color: "white" }}
clearButtonMode='always'
placeholder={t("seerr.describe_the_issue")}
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
@@ -529,7 +532,7 @@ const Page: React.FC = () => {
</View>
</View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("seerr.submit_button")}
{t("jellyseerr.submit_button")}
</Button>
</View>
</BottomSheetView>

View File

@@ -5,27 +5,31 @@ import { orderBy, uniqBy } from "lodash";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@/components/common/Text";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import { OverviewText } from "@/components/OverviewText";
import SeerrPoster from "@/components/posters/SeerrPoster";
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
import { useSeerr } from "@/hooks/useSeerr";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
export default function PersonPage() {
export default function Page() {
const local = useLocalSearchParams();
const { t } = useTranslation();
const { seerrApi, seerrLocale: locale } = useSeerr();
const {
jellyseerrApi,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { personId } = local as { personId: string };
const { data } = useQuery({
queryKey: ["seerr", "person", personId],
queryKey: ["jellyseerr", "person", personId],
queryFn: async () => ({
details: await seerrApi?.personDetails(personId),
combinedCredits: await seerrApi?.personCombinedCredits(personId),
details: await jellyseerrApi?.personDetails(personId),
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
}),
enabled: !!seerrApi && !!personId,
enabled: !!jellyseerrApi && !!personId,
});
const castedRoles: PersonCreditCast[] = useMemo(
@@ -42,19 +46,22 @@ export default function PersonPage() {
);
const backdrops = useMemo(
() =>
seerrApi
jellyseerrApi
? castedRoles.map((c) =>
seerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"),
jellyseerrApi.imageProxy(
c.backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[seerrApi, data?.combinedCredits],
[jellyseerrApi, data?.combinedCredits],
);
return (
<ParallaxSlideShow
data={castedRoles}
images={backdrops}
listHeader={t("seerr.appearances")}
listHeader={t("jellyseerr.appearances")}
keyExtractor={(item) => item.id.toString()}
logo={
<Image
@@ -62,7 +69,7 @@ export default function PersonPage() {
id={data?.details?.id.toString()}
className='rounded-full bottom-1'
source={{
uri: seerrApi?.imageProxy(
uri: jellyseerrApi?.imageProxy(
data?.details?.profilePath,
"w600_and_h600_bestv2",
),
@@ -79,13 +86,16 @@ export default function PersonPage() {
<>
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
<Text className='opacity-50'>
{t("seerr.born")}{" "}
{t("jellyseerr.born")}{" "}
{data?.details?.birthday &&
new Date(data.details.birthday).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
})}{" "}
new Date(data.details.birthday).toLocaleDateString(
`${locale}-${region}`,
{
year: "numeric",
month: "long",
day: "numeric",
},
)}{" "}
| {data?.details?.placeOfBirth}
</Text>
</>
@@ -93,7 +103,7 @@ export default function PersonPage() {
MainContent={() => (
<OverviewText text={data?.details?.biography} className='mt-4' />
)}
renderItem={(item, _index) => <SeerrPoster item={item} />}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/>
);
}

View File

@@ -8,7 +8,7 @@ import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() {
export default function Page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const _insets = useSafeAreaInsets();

View File

@@ -17,7 +17,7 @@ const ITEMS_PER_PAGE = 20;
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
export default function page() {
export default function Page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();

View File

@@ -7,7 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() {
export default function Page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();

View File

@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
export default function page() {
export default function Page() {
const { t } = useTranslation();
return (
<View className='flex items-center justify-center h-full -mt-12'>

View File

@@ -15,7 +15,7 @@ import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function index() {
export default function Index() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();

View File

@@ -33,17 +33,17 @@ export default function SearchLayout() {
headerShadowVisible: false,
}}
/>
<Stack.Screen name='seerr/page' options={commonScreenOptions} />
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
<Stack.Screen
name='seerr/person/[personId]'
name='jellyseerr/person/[personId]'
options={commonScreenOptions}
/>
<Stack.Screen
name='seerr/company/[companyId]'
name='jellyseerr/company/[companyId]'
options={commonScreenOptions}
/>
<Stack.Screen
name='seerr/genre/[genreId]'
name='jellyseerr/genre/[genreId]'
options={commonScreenOptions}
/>
</Stack>

View File

@@ -26,18 +26,18 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import {
JellyseerrSearchSort,
JellyserrIndexPage,
} from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import {
SeerrIndexPage,
SeerrSearchSort,
} from "@/components/seerr/SeerrIndexPage";
import useRouter from "@/hooks/useAppRouter";
import { useSeerr } from "@/hooks/useSeerr";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -55,7 +55,7 @@ const exampleSearches = [
"The Mandalorian",
];
export default function SearchPage() {
export default function Search() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const router = useRouter();
@@ -93,11 +93,16 @@ export default function SearchPage() {
const [api] = useAtom(apiAtom);
const { settings } = useSettings();
const { seerrApi } = useSeerr();
const [seerrOrderBy, setSeerrOrderBy] = useState<SeerrSearchSort>(
SeerrSearchSort[SeerrSearchSort.DEFAULT] as unknown as SeerrSearchSort,
);
const [seerrSortOrder, setSeerrSortOrder] = useState<"asc" | "desc">("desc");
const { jellyseerrApi } = useJellyseerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(
JellyseerrSearchSort[
JellyseerrSearchSort.DEFAULT
] as unknown as JellyseerrSearchSort,
);
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
"asc" | "desc"
>("desc");
const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin";
@@ -469,7 +474,7 @@ export default function SearchPage() {
className='flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{seerrApi && (
{jellyseerrApi && (
<View className='pl-4 pr-4 flex flex-row'>
<SearchTabButtons
searchType={searchType}
@@ -483,10 +488,10 @@ export default function SearchPage() {
<DiscoverFilters
searchFilterId={searchFilterId}
orderFilterId={orderFilterId}
seerrOrderBy={seerrOrderBy}
setSeerrOrderBy={setSeerrOrderBy}
seerrSortOrder={seerrSortOrder}
setSeerrSortOrder={setSeerrSortOrder}
jellyseerrOrderBy={jellyseerrOrderBy}
setJellyseerrOrderBy={setJellyseerrOrderBy}
jellyseerrSortOrder={jellyseerrSortOrder}
setJellyseerrSortOrder={setJellyseerrSortOrder}
t={t}
/>
)}
@@ -749,10 +754,10 @@ export default function SearchPage() {
/>
</View>
) : (
<SeerrIndexPage
<JellyserrIndexPage
searchQuery={debouncedSearch}
sortType={seerrOrderBy}
order={seerrSortOrder}
sortType={jellyseerrOrderBy}
order={jellyseerrSortOrder}
/>
)}

View File

@@ -48,6 +48,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
getMpvAudioId,
@@ -57,7 +58,7 @@ import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function page() {
export default function Page() {
const videoRef = useRef<MpvPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -71,6 +72,9 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false);
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
"default",
);
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
@@ -501,6 +505,31 @@ export default function page() {
return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]);
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
const nowPlayingMetadata = useMemo(() => {
if (!item || !api) return undefined;
const artworkUri = getPrimaryImageUrl({
api,
item,
quality: 90,
width: 500,
});
return {
title: item.Name || "",
artist:
item.Type === "Episode"
? item.SeriesName || ""
: item.AlbumArtist || "",
albumTitle:
item.Type === "Episode" && item.SeasonName
? item.SeasonName
: undefined,
artworkUri: artworkUri || undefined,
};
}, [item, api]);
/** Build video source config for MPV */
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
if (!stream?.url) return undefined;
@@ -929,6 +958,7 @@ export default function page() {
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onLoad={() => setIsVideoLoaded(true)}
@@ -978,6 +1008,7 @@ export default function page() {
pause={pause}
seek={seek}
enableTrickplay={true}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,3 +1,4 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
export * from "./string";

View File

@@ -3,6 +3,7 @@ declare global {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {};

14
augmentations/string.ts Normal file
View File

@@ -0,0 +1,14 @@
declare global {
interface String {
toTitle(): string;
}
}
String.prototype.toTitle = function () {
return this.replaceAll("_", " ").replace(
/\w\S*/g,
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
);
};
export {};

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"files": {
"includes": [
"**/*",

View File

@@ -19,7 +19,7 @@
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.90.18",
"@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-persist-client": "^5.90.18",
"axios": "^1.7.9",
"expo": "~54.0.31",
@@ -52,12 +52,12 @@
"expo-web-browser": "~15.0.10",
"i18next": "^25.0.0",
"jotai": "2.16.2",
"lodash": "4.17.21",
"lodash": "4.17.23",
"nativewind": "^2.0.11",
"patch-package": "^8.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "16.5.3",
"react-i18next": "16.5.4",
"react-native": "0.81.5",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0",
@@ -96,8 +96,8 @@
},
"devDependencies": {
"@babel/core": "7.28.6",
"@biomejs/biome": "2.3.11",
"@react-native-community/cli": "20.1.0",
"@biomejs/biome": "2.4.4",
"@react-native-community/cli": "20.1.1",
"@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.23",
@@ -308,23 +308,23 @@
"@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="],
"@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="],
"@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="],
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.1.0", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-+4YppCodABcSNIgJiq95QUQ+3ClVBG+rLG3WmYI0+/nbxqKbCz6luFBep4KFOj98Iplj1JY2Ki6ix8CcOZVQ/Q=="],
@@ -512,29 +512,29 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@react-native-community/cli": ["@react-native-community/cli@20.1.0", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.0", "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-doctor": "20.1.0", "@react-native-community/cli-server-api": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "@react-native-community/cli-types": "20.1.0", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-441WsVtRe4nGJ9OzA+QMU1+22lA6Q2hRWqqIMKD0wjEMLqcSfOZyu2UL9a/yRpL/dRpyUsU4n7AxqKfTKO/Csg=="],
"@react-native-community/cli": ["@react-native-community/cli@20.1.1", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.1", "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-doctor": "20.1.1", "@react-native-community/cli-server-api": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "@react-native-community/cli-types": "20.1.1", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-aLPUx43+WSeTOaUepR2FBD5a1V0OAZ1QB2DOlRlW4fOEjtBXgv40eM/ho8g3WCvAOKfPvTvx4fZdcuovTyV81Q=="],
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-77L4DifWfxAT8ByHnkypge7GBMYpbJAjBGV+toowt5FQSGaTBDcBHCX+FFqFRukD5fH6i8sZ41Gtw+nbfCTTIA=="],
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-6nGQ08w2+EcDwTFC4JFiW/wI2pLwzMrk9thz4um7tKRNW8sADX0IyCsfM2F4rHS720C0UNKYBZE9nAsfp8Vkcw=="],
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-1x9rhLLR/dKKb92Lb5O0l0EmUG08FHf+ZVyVEf9M+tX+p5QIm52MRiy43R0UAZ2jJnFApxRk+N3sxoYK4Dtnag=="],
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-ajs2i56MANie/v0bMQ1BmRcrOb6MEvLT2rh/I1CA62NXGqF1Rxv6QwsN84LrADMXHRg8QiEMAIADkyDeQHt7Kg=="],
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-3A01ZDyFeCALzzPcwP/fleHoP3sGNq1UX7FzxkTrOFX8RRL9ntXNXQd27E56VU4BBxGAjAJT4Utw8pcOjJceIA=="],
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-1iUV2rPAyoWPo8EceAFC2vZTF+pEd9YqS87c0aqpbGOFE0gs1rHEB+auVR8CdjzftR4U9sq6m2jrdst0rvpIkg=="],
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-n6JVs8Q3yxRbtZQOy05ofeb1kGtspGN3SgwPmuaqvURF9fsuS7c4/9up2Kp9C+1D2J1remPJXiZLNGOcJvfpOA=="],
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-doepJgLJVqeJb5tNoP9hyFIcoZ1OMGO7QN/YMuCCIjbThUQe/J87XdwPol3Qrjr58KRt9xeBVz+kHeW5mtSutw=="],
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.0", "", { "dependencies": { "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", "@react-native-community/cli-platform-apple": "20.1.0", "@react-native-community/cli-platform-ios": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-QfJF1GVjA4PBrIT3SJ0vFFIu0km1vwOmLDlOYVqfojajZJ+Dnvl0f94GN1il/jT7fITAxom///XH3/URvi7YTQ=="],
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.1", "", { "dependencies": { "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-platform-android": "20.1.1", "@react-native-community/cli-platform-apple": "20.1.1", "@react-native-community/cli-platform-ios": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-eFpg5wWnV7uGqvLemshpgj2trPD8cckqxBuI4nT7sxKF/YpA/e3nnnyytHxPP5EnYfWbMcqfaq8hDJoOnJinGQ=="],
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-TeHPDThOwDppQRpndm9kCdRCBI8AMy3HSIQ+iy7VYQXL5BtZ5LfmGdusoj7nVN/ZGn0Lc6Gwts5qowyupXdeKg=="],
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-KPheizJQI0tVvBLy9owzpo+A9qDsDAa87e7a8xNaHnwqGpExnIzFPrbdvrltiZjstU2eB/+/UgNQxYIEd4Oc+g=="],
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-0ih1hrYezSM2cuOlVnwBEFtMwtd8YgpTLmZauDJCv50rIumtkI1cQoOgLoS4tbPCj9U/Vn2a9BFH0DLFOOIacg=="],
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-mQEjOzRFCcQTrCt73Q/+5WWTfUg6U2vLZv5rPuFiNrLbrwRqxVH3OLaXg5gilJkDTJC80z8iOSsdd8MRxONOig=="],
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.0", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.0" } }, "sha512-XN7Da9z4WsJxtqVtEzY8q2bv22OsvzaFP5zy5+phMWNoJlU4lf7IvBSxqGYMpQ9XhYP7arDw5vmW4W34s06rnA=="],
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.1", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.1" } }, "sha512-6vr10/oSjKkZO/BBgfFJNQTC/0CDF4WrN8iW9ss+Kt6ZL2QrBXLYz7fobrrboOlHwqqs5EyQadlEaNii7gKRJg=="],
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "ws": "^6.2.3" } }, "sha512-Tb415Oh8syXNT2zOzLzFkBXznzGaqKCiaichxKzGCDKg6JGHp3jSuCmcTcaPeYC7oc32n/S3Psw7798r4Q/7lA=="],
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" } }, "sha512-phHfiCa4WqfKfaoV2vGVR3ZrYQDQTpI1k+C+i6rXAxFGxPuy8IgFFVOSL543qjKPpHBVwLcA+/xAJCVpdyCtVQ=="],
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.0", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-/YmzHGOkY6Bgrv4OaA1L8rFqsBlQd1EB2/ipAoKPiieV0EcB5PUamUSuNeFU3sBZZTYQCUENwX4wgOHgFUlDnQ=="],
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.1", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-j+zX/H2X+6ZGneIDj56tZ1Hbnip5nSfnq7yGlMyF/zm3U1hKp3G1jN5v0YEfnz/zEmjr7zruh4Y06KmZrF1lrA=="],
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.0", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-D0kDspcwgbVXyNjwicT7Bb1JgXjijTw1JJd+qxyF/a9+sHv7TU4IchV+gN38QegeXqVyM4Ym7YZIvXMFBmyJqA=="],
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.1", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-Tp+s27I/RDONrGvWVj4IzEmga2HhJhXi8ZlZTfycMMyAcv4LG/CTPira+BUZs8nzLAJNrlJ79pVVPJPqQAe+aw=="],
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
@@ -602,7 +602,7 @@
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.17", "", { "dependencies": { "@tanstack/query-core": "5.90.17" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
@@ -1376,7 +1376,7 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
@@ -1640,7 +1640,7 @@
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
"react-i18next": ["react-i18next@16.5.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw=="],
"react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="],
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
@@ -1854,6 +1854,8 @@
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"strict-url-sanitise": ["strict-url-sanitise@0.0.1", "", {}, "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -2242,7 +2244,7 @@
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.17", "", {}, "sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ=="],
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],

View File

View File

@@ -0,0 +1,203 @@
/**
* Example Usage of Global Modal
*
* This file demonstrates how to use the global modal system from anywhere in your app.
* You can delete this file after understanding how it works.
*/
import { Ionicons } from "@expo/vector-icons";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* Example 1: Simple Content Modal
*/
export const SimpleModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
<Text className='text-white mb-4'>
This is a simple modal with just some text content.
</Text>
<Text className='text-neutral-400'>
Swipe down or tap outside to close.
</Text>
</View>,
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-purple-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Open Simple Modal</Text>
</TouchableOpacity>
);
};
/**
* Example 2: Modal with Custom Snap Points
*/
export const CustomSnapPointsExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6' style={{ minHeight: 400 }}>
<Text className='text-2xl font-bold mb-4 text-white'>
Custom Snap Points
</Text>
<Text className='text-white mb-4'>
This modal has custom snap points (25%, 50%, 90%).
</Text>
<View className='bg-neutral-800 p-4 rounded-lg'>
<Text className='text-white'>
Try dragging the modal to different heights!
</Text>
</View>
</View>,
{
snapPoints: ["25%", "50%", "90%"],
enableDynamicSizing: false,
},
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-blue-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Custom Snap Points</Text>
</TouchableOpacity>
);
};
/**
* Example 3: Complex Component in Modal
*/
const SettingsModalContent = () => {
const { hideModal } = useGlobalModal();
const settings = [
{
id: 1,
title: "Notifications",
icon: "notifications-outline" as const,
enabled: true,
},
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
{
id: 3,
title: "Auto-play",
icon: "play-outline" as const,
enabled: false,
},
];
return (
<View className='p-6'>
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
{settings.map((setting, index) => (
<View
key={setting.id}
className={`flex-row items-center justify-between py-4 ${
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='flex-row items-center gap-3'>
<Ionicons name={setting.icon} size={24} color='white' />
<Text className='text-white text-lg'>{setting.title}</Text>
</View>
<View
className={`w-12 h-7 rounded-full ${
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
}`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
setting.enabled ? "translate-x-6" : "translate-x-1"
}`}
style={{ marginTop: 4 }}
/>
</View>
</View>
))}
<TouchableOpacity
onPress={hideModal}
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
>
<Text className='text-white font-semibold text-center'>Close</Text>
</TouchableOpacity>
</View>
);
};
export const ComplexModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(<SettingsModalContent />);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-green-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Complex Component</Text>
</TouchableOpacity>
);
};
/**
* Example 4: Modal Triggered from Function (e.g., API response)
*/
export const useShowSuccessModal = () => {
const { showModal } = useGlobalModal();
return (message: string) => {
showModal(
<View className='p-6 items-center'>
<View className='bg-green-500 rounded-full p-4 mb-4'>
<Ionicons name='checkmark' size={48} color='white' />
</View>
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
<Text className='text-white text-center'>{message}</Text>
</View>,
);
};
};
/**
* Main Demo Component
*/
export const GlobalModalDemo = () => {
const showSuccess = useShowSuccessModal();
return (
<View className='p-6 gap-4'>
<Text className='text-2xl font-bold mb-4 text-white'>
Global Modal Examples
</Text>
<SimpleModalExample />
<CustomSnapPointsExample />
<ComplexModalExample />
<TouchableOpacity
onPress={() => showSuccess("Operation completed successfully!")}
className='bg-orange-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Show Success Modal</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -89,16 +89,16 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
</Text>
<View className='flex flex-row items-center mt-4'>
<Image
source={require("@/assets/icons/seerr-logo.svg")}
source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{
width: 50,
height: 50,
}}
/>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Seerr</Text>
<Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className='shrink text-xs'>
{t("home.intro.seerr_feature_description")}
{t("home.intro.jellyseerr_feature_description")}
</Text>
</View>
</View>
@@ -158,12 +158,12 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.centralized_settings_plugin_title")}
{t("home.intro.centralised_settings_plugin_title")}
</Text>
<View className='flex-row flex-wrap items-baseline'>
<Text className='shrink text-xs'>
{t(
"home.intro.centralized_settings_plugin_description",
"home.intro.centralised_settings_plugin_description",
)}{" "}
</Text>
<TouchableOpacity

View File

@@ -11,11 +11,9 @@ import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/posters/MoviePoster";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
const POSTER_CAROUSEL_HEIGHT = 220;
interface Props extends ViewProps {
actorId: string;
actorName?: string | null;

View File

@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useMemo } from "react";
import { View, type ViewProps } from "react-native";
import { useSeerr } from "@/hooks/useSeerr";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
@@ -55,23 +55,23 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
);
};
export const SeerrRatings: React.FC<{
export const JellyserrRatings: React.FC<{
result: MovieResult | TvResult | TvDetails | MovieDetails;
}> = ({ result }) => {
const { seerrApi, getMediaType } = useSeerr();
const { jellyseerrApi, getMediaType } = useJellyseerr();
const mediaType = useMemo(() => getMediaType(result), [result]);
const { data, isLoading } = useQuery({
queryKey: ["seerr", result.id, mediaType, "ratings"],
queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
queryFn: async () => {
return mediaType === MediaType.MOVIE
? seerrApi?.movieRatings(result.id)
: seerrApi?.tvRatings(result.id);
? jellyseerrApi?.movieRatings(result.id)
: jellyseerrApi?.tvRatings(result.id);
},
staleTime: (5).minutesToMilliseconds(),
retry: false,
enabled: !!seerrApi,
enabled: !!jellyseerrApi,
});
return (

View File

@@ -6,11 +6,8 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import MoviePoster from "@/components/posters/MoviePoster";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
const POSTER_CAROUSEL_HEIGHT = 220;
import { HorizontalScroll } from "./common/HorizontalScroll";
import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter";

View File

@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source, streamType],
[source],
);
const selectedSteam = useMemo(

View File

@@ -21,7 +21,7 @@ interface Props extends TouchableOpacityProps {
mediaType: MediaType;
}
export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
result,
mediaTitle,
releaseYear,
@@ -42,24 +42,18 @@ export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({
onPress={() => {
if (!result) return;
// Build URL with query params - avoids Expo Router's strict type checking
const params = new URLSearchParams({
...Object.fromEntries(
Object.entries(result).map(([key, value]) => [
key,
String(value ?? ""),
]),
),
mediaTitle,
releaseYear: releaseYear.toString(),
canRequest: canRequest.toString(),
posterSrc,
mediaType: mediaType.toString(),
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
// @ts-expect-error
params: {
...result,
mediaTitle,
releaseYear,
canRequest: canRequest.toString(),
posterSrc,
mediaType,
},
});
router.push(
`/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/page?${params.toString()}`,
);
}}
{...props}
>

View File

@@ -0,0 +1,20 @@
import { Image } from "expo-image";
import { View } from "react-native";
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
if (!url)
return (
<View className='p-4 rounded-xl overflow-hidden '>
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
</View>
);
return (
<View className='p-4 rounded-xl overflow-hidden '>
<Image
source={{ uri: url }}
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
/>
</View>
);
};

View File

@@ -0,0 +1,28 @@
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
index: number;
}
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
return (
<View
key={index}
style={{
width: "32%",
}}
className='flex flex-col'
{...props}
>
<View
style={{
aspectRatio: "10/15",
}}
className='w-full bg-neutral-800 mb-2 rounded-lg'
/>
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
</View>
);
};

View File

@@ -569,31 +569,29 @@ export const HomeWithCarousel = () => {
settings.streamyStatsSeriesRecommendations ||
settings.streamyStatsPromotedWatchlists;
const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent
? [
settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations
key='movie-recommendations'
title={t(
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
/>
),
settings.streamyStatsSeriesRecommendations && (
<StreamystatsRecommendations
key='series-recommendations'
title={t(
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
/>
),
settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists key='promoted-watchlists' />
),
].filter(Boolean)
: null;
index === streamystatsIndex && hasStreamystatsContent ? (
<>
{settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
/>
)}
{settings.streamyStatsSeriesRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
/>
)}
{settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists />
)}
</>
) : null;
if (section.type === "InfiniteScrollingCollectionList") {
return (

View File

@@ -247,14 +247,15 @@ export const StreamystatsPromotedWatchlists: React.FC<
}
return (
<View {...props}>
<>
{watchlists?.map((watchlist) => (
<WatchlistSection
key={watchlist.id}
watchlist={watchlist}
jellyfinServerId={jellyfinServerId!}
{...props}
/>
))}
</View>
</>
);
};

View File

@@ -3,7 +3,7 @@ import type React from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import PersonPoster from "@/components/seerr/PersonPoster";
import PersonPoster from "@/components/jellyseerr/PersonPoster";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
@@ -15,17 +15,19 @@ const CastSlide: React.FC<
details?.credits?.cast &&
details?.credits?.cast?.length > 0 && (
<View {...props}>
<Text className='text-lg font-bold mb-2 px-4'>{t("seerr.cast")}</Text>
<Text className='text-lg font-bold mb-2 px-4'>
{t("jellyseerr.cast")}
</Text>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={details?.credits.cast}
ItemSeparatorComponent={() => <View className='w-2' />}
keyExtractor={(item) => item?.id?.toString() ?? ""}
keyExtractor={(item) => item?.id?.toString()}
contentContainerStyle={{ paddingHorizontal: 16 }}
renderItem={({ item }) => (
<PersonPoster
id={item?.id?.toString() ?? ""}
id={item.id.toString()}
posterPath={item.profilePath}
name={item.name}
subName={item.character}

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import CountryFlag from "react-native-country-flag";
import { Text } from "@/components/common/Text";
import { useSeerr } from "@/hooks/useSeerr";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
@@ -50,7 +50,8 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => {
const { seerrRegion: region, seerrLocale: locale } = useSeerr();
const { jellyseerrRegion: region, jellyseerrLocale: locale } =
useJellyseerr();
const { t } = useTranslation();
const releases = useMemo(
@@ -58,7 +59,7 @@ const DetailFacts: React.FC<
(details as MovieDetails)?.releases?.results.find(
(r: TmdbRelease) => r.iso_3166_1 === region,
)?.release_dates as TmdbRelease["release_dates"],
[details, region],
[details],
);
// Release date types:
@@ -80,34 +81,40 @@ const DetailFacts: React.FC<
const firstAirDate = useMemo(() => {
const firstAirDate = (details as TvDetails)?.firstAirDate;
if (firstAirDate) {
return new Date(firstAirDate).toLocaleDateString(locale, dateOpts);
return new Date(firstAirDate).toLocaleDateString(
`${locale}-${region}`,
dateOpts,
);
}
}, [details, locale]);
}, [details]);
const nextAirDate = useMemo(() => {
const firstAirDate = (details as TvDetails)?.firstAirDate;
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
if (nextAirDate && firstAirDate !== nextAirDate) {
return new Date(nextAirDate).toLocaleDateString(locale, dateOpts);
return new Date(nextAirDate).toLocaleDateString(
`${locale}-${region}`,
dateOpts,
);
}
}, [details, locale]);
}, [details]);
const revenue = useMemo(
() =>
(details as MovieDetails)?.revenue?.toLocaleString?.(locale, {
style: "currency",
currency: "USD",
}),
[details, locale],
(details as MovieDetails)?.revenue?.toLocaleString?.(
`${locale}-${region}`,
{ style: "currency", currency: "USD" },
),
[details],
);
const budget = useMemo(
() =>
(details as MovieDetails)?.budget?.toLocaleString?.(locale, {
style: "currency",
currency: "USD",
}),
[details, locale],
(details as MovieDetails)?.budget?.toLocaleString?.(
`${locale}-${region}`,
{ style: "currency", currency: "USD" },
),
[details],
);
const streamingProviders = useMemo(
@@ -115,7 +122,7 @@ const DetailFacts: React.FC<
details?.watchProviders?.find(
(provider) => provider.iso_3166_1 === region,
)?.flatrate,
[details, region],
[details],
);
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
@@ -131,21 +138,21 @@ const DetailFacts: React.FC<
return (
details && (
<View className='p-4'>
<Text className='text-lg font-bold'>{t("seerr.details")}</Text>
<Text className='text-lg font-bold'>{t("jellyseerr.details")}</Text>
<View
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
{...props}
>
<Fact title={t("seerr.status")} fact={details?.status} />
<Fact title={t("jellyseerr.status")} fact={details?.status} />
<Fact
title={t("seerr.original_title")}
title={t("jellyseerr.original_title")}
fact={(details as TvDetails)?.originalName}
/>
{details.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID,
) && <Fact title={t("seerr.series_type")} fact='Anime' />}
) && <Fact title={t("jellyseerr.series_type")} fact='Anime' />}
<Facts
title={t("seerr.release_dates")}
title={t("jellyseerr.release_dates")}
facts={filteredReleases?.map?.((r: Release, idx) => (
<View key={idx} className='flex flex-row space-x-2 items-center'>
{r.type === 3 ? (
@@ -164,20 +171,23 @@ const DetailFacts: React.FC<
)}
<Text>
{new Date(r.release_date).toLocaleDateString(
locale,
`${locale}-${region}`,
dateOpts,
)}
</Text>
</View>
))}
/>
<Fact title={t("seerr.first_air_date")} fact={firstAirDate} />
<Fact title={t("seerr.next_air_date")} fact={nextAirDate} />
<Fact title={t("seerr.revenue")} fact={revenue} />
<Fact title={t("seerr.budget")} fact={budget} />
<Fact title={t("seerr.original_language")} fact={spokenLanguage} />
<Fact title={t("jellyseerr.first_air_date")} fact={firstAirDate} />
<Fact title={t("jellyseerr.next_air_date")} fact={nextAirDate} />
<Fact title={t("jellyseerr.revenue")} fact={revenue} />
<Fact title={t("jellyseerr.budget")} fact={budget} />
<Fact
title={t("jellyseerr.original_language")}
fact={spokenLanguage}
/>
<Facts
title={t("seerr.production_country")}
title={t("jellyseerr.production_country")}
facts={details?.productionCountries?.map((n, idx) => (
<View key={idx} className='flex flex-row items-center space-x-2'>
<CountryFlag isoCode={n.iso_3166_1} size={10} />
@@ -186,17 +196,17 @@ const DetailFacts: React.FC<
))}
/>
<Facts
title={t("seerr.studios")}
title={t("jellyseerr.studios")}
facts={uniqBy(details?.productionCompanies, "name")?.map(
(n) => n.name,
)}
/>
<Facts
title={t("seerr.network")}
title={t("jellyseerr.network")}
facts={networks?.map((n) => n.name)}
/>
<Facts
title={t("seerr.currently_streaming_on")}
title={t("jellyseerr.currently_streaming_on")}
facts={streamingProviders?.map((s) => s.name)}
/>
</View>

View File

@@ -1,10 +1,16 @@
import React from "react";
import { View } from "react-native";
interface Props {
index: number;
}
// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
export const GridSkeleton = React.memo(() => {
export const GridSkeleton: React.FC<Props> = ({ index }) => {
return (
<View className='flex flex-col mr-2 h-auto' style={{ width: "30.5%" }}>
<View
key={index}
className='flex flex-col mr-2 h-auto'
style={{ width: "30.5%" }}
>
<View className='relative rounded-lg overflow-hidden border border-neutral-900 w-full mt-4 aspect-[10/15] bg-neutral-800' />
<View className='mt-2 flex flex-col w-full'>
<View className='h-4 bg-neutral-800 rounded mb-1' />
@@ -12,4 +18,4 @@ export const GridSkeleton = React.memo(() => {
</View>
</View>
);
});
};

View File

@@ -8,8 +8,8 @@ import {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import Discover from "@/components/seerr/discover/Discover";
import { useSeerr } from "@/hooks/useSeerr";
import Discover from "@/components/jellyseerr/discover/Discover";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
@@ -18,57 +18,57 @@ import type {
} from "@/utils/jellyseerr/server/models/Search";
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
import { Text } from "../common/Text";
import SeerrPoster from "../posters/SeerrPoster";
import JellyseerrPoster from "../posters/JellyseerrPoster";
import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster";
interface Props extends ViewProps {
searchQuery: string;
sortType?: SeerrSearchSort;
sortType?: JellyseerrSearchSort;
order?: "asc" | "desc";
}
export enum SeerrSearchSort {
export enum JellyseerrSearchSort {
DEFAULT = 0,
VOTE_COUNT_AND_AVERAGE = 1,
POPULARITY = 2,
}
export const SeerrIndexPage: React.FC<Props> = ({
export const JellyserrIndexPage: React.FC<Props> = ({
searchQuery,
sortType,
order,
}) => {
const { seerrApi } = useSeerr();
const { jellyseerrApi } = useJellyseerr();
const opacity = useSharedValue(1);
const { t } = useTranslation();
const {
data: seerrDiscoverSettings,
data: jellyseerrDiscoverSettings,
isFetching: f1,
isLoading: l1,
} = useReactNavigationQuery({
queryKey: ["search", "seerr", "discoverSettings", searchQuery],
queryFn: async () => seerrApi?.discoverSettings(),
enabled: !!seerrApi && searchQuery.length === 0,
queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled: !!jellyseerrApi && searchQuery.length === 0,
});
const {
data: seerrResults,
data: jellyseerrResults,
isFetching: f2,
isLoading: l2,
} = useReactNavigationQuery({
queryKey: ["search", "seerr", "results", searchQuery],
queryKey: ["search", "jellyseerr", "results", searchQuery],
queryFn: async () => {
const params = {
query: new URLSearchParams(searchQuery || "").toString(),
};
return await Promise.all([
seerrApi?.search({ ...params, page: 1 }),
seerrApi?.search({ ...params, page: 2 }),
seerrApi?.search({ ...params, page: 3 }),
seerrApi?.search({ ...params, page: 4 }),
jellyseerrApi?.search({ ...params, page: 1 }),
jellyseerrApi?.search({ ...params, page: 2 }),
jellyseerrApi?.search({ ...params, page: 3 }),
jellyseerrApi?.search({ ...params, page: 4 }),
]).then((all) =>
uniqBy(
all.flatMap((v) => v?.results || []),
@@ -76,7 +76,7 @@ export const SeerrIndexPage: React.FC<Props> = ({
),
);
},
enabled: !!seerrApi && searchQuery.length > 0,
enabled: !!jellyseerrApi && searchQuery.length > 0,
});
useAnimatedReaction(
@@ -92,20 +92,20 @@ export const SeerrIndexPage: React.FC<Props> = ({
const sortingType = useMemo(() => {
if (!sortType) return;
switch (Number(SeerrSearchSort[sortType])) {
case SeerrSearchSort.VOTE_COUNT_AND_AVERAGE:
switch (Number(JellyseerrSearchSort[sortType])) {
case JellyseerrSearchSort.VOTE_COUNT_AND_AVERAGE:
return ["voteCount", "voteAverage"];
case SeerrSearchSort.POPULARITY:
case JellyseerrSearchSort.POPULARITY:
return ["voteCount", "popularity"];
default:
return undefined;
}
}, [sortType, order]);
const seerrMovieResults = useMemo(
const jellyseerrMovieResults = useMemo(
() =>
orderBy(
seerrResults?.filter(
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE,
) as MovieResult[],
sortingType || [
@@ -113,37 +113,41 @@ export const SeerrIndexPage: React.FC<Props> = ({
],
order || "desc",
),
[seerrResults, sortingType, order, searchQuery],
[jellyseerrResults, sortingType, order],
);
const seerrTvResults = useMemo(
const jellyseerrTvResults = useMemo(
() =>
orderBy(
seerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[],
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV,
) as TvResult[],
sortingType || [
(t) => t.name.toLowerCase() === searchQuery.toLowerCase(),
],
order || "desc",
),
[seerrResults, sortingType, order, searchQuery],
[jellyseerrResults, sortingType, order],
);
const seerrPersonResults = useMemo(
const jellyseerrPersonResults = useMemo(
() =>
orderBy(
seerrResults?.filter((r) => r.mediaType === "person") as PersonResult[],
jellyseerrResults?.filter(
(r) => r.mediaType === "person",
) as PersonResult[],
sortingType || [
(p) => p.name.toLowerCase() === searchQuery.toLowerCase(),
],
order || "desc",
),
[seerrResults, sortingType, order, searchQuery],
[jellyseerrResults, sortingType, order],
);
if (!searchQuery.length)
return (
<View className='flex flex-col'>
<Discover sliders={seerrDiscoverSettings} />
<Discover sliders={jellyseerrDiscoverSettings} />
</View>
);
@@ -151,9 +155,9 @@ export const SeerrIndexPage: React.FC<Props> = ({
<View>
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
{!seerrMovieResults?.length &&
!seerrTvResults?.length &&
!seerrPersonResults?.length &&
{!jellyseerrMovieResults?.length &&
!jellyseerrTvResults?.length &&
!jellyseerrPersonResults?.length &&
!f1 &&
!f2 &&
!l1 &&
@@ -171,21 +175,21 @@ export const SeerrIndexPage: React.FC<Props> = ({
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header={t("search.request_movies")}
items={seerrMovieResults}
items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => (
<SeerrPoster item={item} key={item.id} />
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header={t("search.request_series")}
items={seerrTvResults}
items={jellyseerrTvResults}
renderItem={(item: TvResult) => (
<SeerrPoster item={item} key={item.id} />
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header={t("search.actors")}
items={seerrPersonResults}
items={jellyseerrPersonResults}
renderItem={(item: PersonResult) => (
<PersonPoster
className='mr-2'

View File

@@ -3,11 +3,9 @@ import { useMemo } from "react";
import { View, type ViewProps } from "react-native";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
mediaType,
className,
...props
}) => {
const JellyseerrMediaIcon: React.FC<
{ mediaType: "tv" | "movie" } & ViewProps
> = ({ mediaType, className, ...props }) => {
const style = useMemo(
() =>
mediaType === MediaType.MOVIE
@@ -31,4 +29,4 @@ const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
);
};
export default SeerrMediaIcon;
export default JellyseerrMediaIcon;

View File

@@ -9,7 +9,7 @@ interface Props {
onPress?: () => void;
}
const SeerrStatusIcon: React.FC<Props & ViewProps> = ({
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
mediaStatus,
showRequestIcon,
onPress,
@@ -74,4 +74,4 @@ const SeerrStatusIcon: React.FC<Props & ViewProps> = ({
);
};
export default SeerrStatusIcon;
export default JellyseerrStatusIcon;

View File

@@ -133,7 +133,7 @@ const ParallaxSlideShow = <T,>({
<View className='px-4'>
<View className='flex flex-row flex-wrap'>
{Array.from({ length: 9 }, (_, i) => (
<GridSkeleton key={i} />
<GridSkeleton key={i} index={i} />
))}
</View>
</View>

View File

@@ -4,7 +4,7 @@ import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import useRouter from "@/hooks/useAppRouter";
import { useSeerr } from "@/hooks/useSeerr";
import { useJellyseerr } from "@/hooks/useJellyseerr";
interface Props {
id: string;
@@ -20,7 +20,7 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
subName,
...props
}) => {
const { seerrApi } = useSeerr();
const { jellyseerrApi } = useJellyseerr();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
@@ -28,20 +28,20 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/(tabs)/${from}/seerr/person/${id}`)}
onPress={() =>
router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)
}
>
<View className='flex flex-col w-28' {...props}>
<Poster
id={id}
url={seerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
url={jellyseerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
/>
<Text className='mt-2'>{name}</Text>
{subName && <Text className='text-xs opacity-50'>{subName}</Text>}
</View>
</TouchableOpacity>
);
return null;
};
export default PersonPoster;

View File

@@ -12,7 +12,7 @@ import { View, type ViewProps } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useSeerr } from "@/hooks/useSeerr";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type {
QualityProfile,
RootFolder,
@@ -38,23 +38,14 @@ const RequestModal = forwardRef<
Props & Omit<ViewProps, "id">
>(
(
{
id,
title,
requestBody,
type,
isAnime = false,
is4k,
onRequested,
onDismiss,
},
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
ref,
) => {
const { seerrApi, seerrUser, requestMedia } = useSeerr();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
mediaId: Number(id),
mediaType: type,
userId: seerrUser?.id,
userId: jellyseerrUser?.id,
});
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
@@ -74,17 +65,18 @@ const RequestModal = forwardRef<
}, [onDismiss]);
const { data: serviceSettings } = useQuery({
queryKey: ["seerr", "request", type, "service"],
queryKey: ["jellyseerr", "request", type, "service"],
queryFn: async () =>
seerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
enabled: !!seerrApi && !!seerrUser,
jellyseerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: "always",
});
const { data: users } = useQuery({
queryKey: ["seerr", "users"],
queryFn: async () => seerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!seerrApi && !!seerrUser,
queryKey: ["jellyseerr", "users"],
queryFn: async () =>
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: "always",
});
@@ -95,7 +87,7 @@ const RequestModal = forwardRef<
const { data: defaultServiceDetails } = useQuery({
queryKey: [
"seerr",
"jellyseerr",
"request",
type,
"service",
@@ -107,12 +99,12 @@ const RequestModal = forwardRef<
...prev,
serverId: defaultService?.id,
}));
return seerrApi?.serviceDetails(
return jellyseerrApi?.serviceDetails(
type === "movie" ? "radarr" : "sonarr",
defaultService!.id,
);
},
enabled: !!seerrApi && !!seerrUser && !!defaultService,
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
refetchOnMount: "always",
});
@@ -156,9 +148,9 @@ const RequestModal = forwardRef<
return undefined;
}
if (requestBody.seasons.length > 1) {
return t("seerr.season_all");
return t("jellyseerr.season_all");
}
return t("seerr.season_number", {
return t("jellyseerr.season_number", {
season_number: requestBody.seasons[0],
});
}, [requestBody?.seasons]);
@@ -253,7 +245,8 @@ const RequestModal = forwardRef<
type: "radio" as const,
label: user.displayName,
value: user.id.toString(),
selected: (requestOverrides.userId || seerrUser?.id) === user.id,
selected:
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
@@ -262,13 +255,12 @@ const RequestModal = forwardRef<
})) || [],
},
],
[users, seerrUser, requestOverrides.userId],
[users, jellyseerrUser, requestOverrides.userId],
);
const request = useCallback(() => {
const body = {
is4k:
is4k ?? defaultService?.is4k ?? defaultServiceDetails?.server.is4k,
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
profileId: defaultProfile?.id,
rootFolder: defaultFolder?.path,
tags: defaultTags.map((t) => t.id),
@@ -276,7 +268,7 @@ const RequestModal = forwardRef<
...requestOverrides,
};
writeDebugLog("Sending Seerr advanced request", body);
writeDebugLog("Sending Jellyseerr advanced request", body);
requestMedia(
seasonTitle ? `${title}, ${seasonTitle}` : title,
@@ -284,18 +276,11 @@ const RequestModal = forwardRef<
onRequested,
);
}, [
is4k,
defaultService?.is4k,
defaultServiceDetails?.server.is4k,
requestBody,
requestOverrides,
defaultProfile,
defaultFolder,
defaultTags,
requestMedia,
seasonTitle,
title,
onRequested,
]);
return (
@@ -323,7 +308,7 @@ const RequestModal = forwardRef<
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("seerr.advanced")}
{t("jellyseerr.advanced")}
</Text>
{seasonTitle && (
<Text className='text-neutral-300'>{seasonTitle}</Text>
@@ -334,7 +319,7 @@ const RequestModal = forwardRef<
<>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("seerr.quality_profile")}
{t("jellyseerr.quality_profile")}
</Text>
<PlatformDropdown
groups={qualityProfileOptions}
@@ -350,7 +335,7 @@ const RequestModal = forwardRef<
</Text>
</View>
}
title={t("seerr.quality_profile")}
title={t("jellyseerr.quality_profile")}
open={qualityProfileOpen}
onOpenChange={setQualityProfileOpen}
/>
@@ -358,7 +343,7 @@ const RequestModal = forwardRef<
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("seerr.root_folder")}
{t("jellyseerr.root_folder")}
</Text>
<PlatformDropdown
groups={rootFolderOptions}
@@ -383,45 +368,42 @@ const RequestModal = forwardRef<
</Text>
</View>
}
title={t("seerr.root_folder")}
title={t("jellyseerr.root_folder")}
open={rootFolderOpen}
onOpenChange={setRootFolderOpen}
/>
</View>
{defaultServiceDetails?.tags &&
defaultServiceDetails.tags.length > 0 && (
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("seerr.tags")}
</Text>
<PlatformDropdown
groups={tagsOptions}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{requestOverrides.tags
? defaultServiceDetails.tags
.filter((t) =>
requestOverrides.tags!.includes(t.id),
)
.map((t) => t.label)
.join(", ") ||
defaultTags.map((t) => t.label).join(", ")
: defaultTags.map((t) => t.label).join(", ")}
</Text>
</View>
}
title={t("seerr.tags")}
open={tagsOpen}
onOpenChange={setTagsOpen}
/>
</View>
)}
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.tags")}
</Text>
<PlatformDropdown
groups={tagsOptions}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{requestOverrides.tags
? defaultServiceDetails.tags
.filter((t) =>
requestOverrides.tags!.includes(t.id),
)
.map((t) => t.label)
.join(", ") ||
defaultTags.map((t) => t.label).join(", ")
: defaultTags.map((t) => t.label).join(", ")}
</Text>
</View>
}
title={t("jellyseerr.tags")}
open={tagsOpen}
onOpenChange={setTagsOpen}
/>
</View>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("seerr.request_as")}
{t("jellyseerr.request_as")}
</Text>
<PlatformDropdown
groups={usersOptions}
@@ -431,12 +413,12 @@ const RequestModal = forwardRef<
{users.find(
(u) =>
u.id ===
(requestOverrides.userId || seerrUser?.id),
)?.displayName || seerrUser!.displayName}
(requestOverrides.userId || jellyseerrUser?.id),
)?.displayName || jellyseerrUser!.displayName}
</Text>
</View>
}
title={t("seerr.request_as")}
title={t("jellyseerr.request_as")}
open={usersOpen}
onOpenChange={setUsersOpen}
/>
@@ -445,7 +427,7 @@ const RequestModal = forwardRef<
)}
</View>
<Button className='mt-auto' onPress={request} color='purple'>
{t("seerr.request_button")}
{t("jellyseerr.request_button")}
</Button>
</View>
</BottomSheetView>

View File

@@ -2,10 +2,10 @@ import { useSegments } from "expo-router";
import type React from "react";
import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter";
import { useSeerr } from "@/hooks/useSeerr";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
COMPANY_LOGO_IMAGE_FILTER,
type Network,
@@ -16,17 +16,17 @@ const CompanySlide: React.FC<
{ data: Network[] | Studio[] } & SlideProps & ViewProps
> = ({ slide, data, ...props }) => {
const segments = useSegments();
const { seerrApi } = useSeerr();
const { jellyseerrApi } = useJellyseerr();
const router = useRouter();
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
({ id, image, name }: Network | Studio) =>
router.push({
pathname: `/(auth)/(tabs)/${from}/seerr/company/${id}` as any,
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any,
params: { id, image, name, type: slide.type },
}),
[router, from, slide.type],
[slide],
);
return (
@@ -40,7 +40,10 @@ const CompanySlide: React.FC<
<GenericSlideCard
className='w-28 rounded-lg overflow-hidden border border-neutral-900 p-4'
id={item.id.toString()}
url={seerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
url={jellyseerrApi?.imageProxy(
item.image,
COMPANY_LOGO_IMAGE_FILTER,
)}
/>
</TouchableOpacity>
)}

View File

@@ -2,10 +2,10 @@ import { sortBy } from "lodash";
import type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
import CompanySlide from "@/components/seerr/discover/CompanySlide";
import GenreSlide from "@/components/seerr/discover/GenreSlide";
import MovieTvSlide from "@/components/seerr/discover/MovieTvSlide";
import RecentRequestsSlide from "@/components/seerr/discover/RecentRequestsSlide";
import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
@@ -23,6 +23,7 @@ const Discover: React.FC<Props> = ({ sliders }) => {
sortBy(
(sliders ?? []).filter((s) => s.enabled),
"order",
"asc",
),
[sliders],
);

View File

@@ -1,6 +1,6 @@
import { Image, type ImageContentFit } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import React from "react";
import type React from "react";
import { StyleSheet, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
@@ -67,4 +67,4 @@ const GenericSlideCard: React.FC<
</>
);
export default React.memo(GenericSlideCard);
export default GenericSlideCard;

View File

@@ -3,38 +3,39 @@ import { useSegments } from "expo-router";
import type React from "react";
import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter";
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments();
const { seerrApi } = useSeerr();
const { jellyseerrApi } = useJellyseerr();
const router = useRouter();
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
(genre: GenreSliderItem) =>
router.push({
pathname: `/(auth)/(tabs)/${from}/seerr/genre/${genre.id}` as any,
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
params: { type: slide.type, name: genre.name },
}),
[router, from, slide.type],
[slide],
);
const { data } = useQuery({
queryKey: ["seerr", "discover", slide.type, slide.id],
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
queryFn: async () => {
return seerrApi?.getGenreSliders(
return jellyseerrApi?.getGenreSliders(
slide.type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.MOVIE
: Endpoints.TV,
);
},
enabled: !!seerrApi,
enabled: !!jellyseerrApi,
});
return (
@@ -52,7 +53,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
title={item.name}
colors={["transparent", "transparent"]}
contentFit={"cover"}
url={seerrApi?.imageProxy(
url={jellyseerrApi?.imageProxy(
item.backdrops?.[0],
`w780_filter(duotone,${
genreColorMap[item.id] ?? genreColorMap[0]

View File

@@ -3,19 +3,23 @@ import { uniqBy } from "lodash";
import type React from "react";
import { useMemo } from "react";
import type { ViewProps } from "react-native";
import SeerrPoster from "@/components/posters/SeerrPoster";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import { type DiscoverEndpoint, Endpoints, useSeerr } from "@/hooks/useSeerr";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {
type DiscoverEndpoint,
Endpoints,
useJellyseerr,
} from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
slide,
...props
}) => {
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["seerr", "discover", slide.id],
queryKey: ["jellyseerr", "discover", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined;
let params: any = {
@@ -46,13 +50,13 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
break;
}
return endpoint ? seerrApi?.discover(endpoint, params) : null;
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
},
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
enabled: !!seerrApi,
enabled: !!jellyseerrApi,
staleTime: 0,
});
@@ -61,10 +65,12 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))),
.flatMap((p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
),
"id",
),
[data, isSeerrMovieOrTvResult],
[data],
);
return (
@@ -78,7 +84,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
renderItem={(item) => <SeerrPoster item={item} key={item?.id} />}
renderItem={(item) => <JellyseerrPoster item={item} key={item?.id} />}
/>
)
);

View File

@@ -1,9 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import type React from "react";
import type { ViewProps } from "react-native";
import SeerrPoster from "@/components/posters/SeerrPoster";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import { useSeerr } from "@/hooks/useSeerr";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
@@ -16,36 +16,36 @@ type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
request,
}) => {
const { seerrApi } = useSeerr();
const { jellyseerrApi } = useJellyseerr();
const { data: details } = useQuery({
queryKey: [
"seerr",
"jellyseerr",
"detail",
request.media.mediaType,
request.media.tmdbId,
],
queryFn: async () => {
return request.media.mediaType === MediaType.MOVIE
? seerrApi?.movieDetails(request.media.tmdbId)
: seerrApi?.tvDetails(request.media.tmdbId);
? jellyseerrApi?.movieDetails(request.media.tmdbId)
: jellyseerrApi?.tvDetails(request.media.tmdbId);
},
enabled: !!seerrApi,
enabled: !!jellyseerrApi,
refetchOnMount: true,
staleTime: 0,
});
const { data: refreshedRequest } = useQuery({
queryKey: ["seerr", "requests", request.media.mediaType, request.id],
queryFn: async () => seerrApi?.getRequest(request.id),
enabled: !!seerrApi,
queryKey: ["jellyseerr", "requests", request.media.mediaType, request.id],
queryFn: async () => jellyseerrApi?.getRequest(request.id),
enabled: !!jellyseerrApi,
refetchOnMount: true,
refetchInterval: 5000,
staleTime: 0,
});
return (
<SeerrPoster
<JellyseerrPoster
horizontal
showDownloadInfo
item={details}
@@ -58,12 +58,12 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
slide,
...props
}) => {
const { seerrApi } = useSeerr();
const { jellyseerrApi } = useJellyseerr();
const { data: requests } = useQuery({
queryKey: ["seerr", "recent_requests"],
queryFn: async () => seerrApi?.requests(),
enabled: !!seerrApi,
queryKey: ["jellyseerr", "recent_requests"],
queryFn: async () => jellyseerrApi?.requests(),
enabled: !!jellyseerrApi,
refetchOnMount: true,
staleTime: 0,
});

View File

@@ -14,7 +14,10 @@ export interface SlideProps {
interface Props<T> extends SlideProps {
data: T[];
renderItem: (item: T, index: number) => React.ReactElement | null;
renderItem: (
item: T,
index: number,
) => React.ComponentType<any> | React.ReactElement | null | undefined;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined;
}
@@ -44,6 +47,7 @@ const Slide = <T,>({
data={data}
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-expect-error
renderItem={({ item, index }) =>
item ? renderItem(item, index) : null
}

View File

@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
api,
item: library,
}),
[api, library],
[library],
);
const itemType = useMemo(() => {

View File

@@ -23,8 +23,10 @@ export const MusicAlbumCard: React.FC<Props> = ({ album, width = 130 }) => {
);
const handlePress = useCallback(() => {
if (!album.Id) return;
router.push(`/music/album/${album.Id}`);
router.push({
pathname: "/music/album/[albumId]",
params: { albumId: album.Id! },
});
}, [router, album.Id]);
return (

View File

@@ -24,8 +24,10 @@ export const MusicAlbumRowCard: React.FC<Props> = ({ album }) => {
);
const handlePress = useCallback(() => {
if (!album.Id) return;
router.push(`/music/album/${album.Id}`);
router.push({
pathname: "/music/album/[albumId]",
params: { albumId: album.Id! },
});
}, [router, album.Id]);
return (

View File

@@ -25,8 +25,10 @@ export const MusicArtistCard: React.FC<Props> = ({ artist }) => {
);
const handlePress = useCallback(() => {
if (!artist.Id) return;
router.push(`/music/artist/${artist.Id}`);
router.push({
pathname: "/music/artist/[artistId]",
params: { artistId: artist.Id! },
});
}, [router, artist.Id]);
return (

View File

@@ -61,7 +61,10 @@ export const MusicPlaylistCard: React.FC<Props> = ({ playlist }) => {
const hasDownloads = downloadStatus.downloaded > 0;
const handlePress = useCallback(() => {
router.push(`/music/playlist/${playlist.Id}`);
router.push({
pathname: "/music/playlist/[playlistId]",
params: { playlistId: playlist.Id! },
});
}, [router, playlist.Id]);
return (

View File

@@ -197,7 +197,10 @@ export const TrackOptionsSheet: React.FC<Props> = ({
const artistId = track?.ArtistItems?.[0]?.Id;
if (artistId) {
setOpen(false);
router.push(`/music/artist/${artistId}`);
router.push({
pathname: "/music/artist/[artistId]",
params: { artistId },
});
}
}, [track?.ArtistItems, router, setOpen]);
@@ -205,7 +208,10 @@ export const TrackOptionsSheet: React.FC<Props> = ({
const albumId = track?.AlbumId || track?.ParentId;
if (albumId) {
setOpen(false);
router.push(`/music/album/${albumId}`);
router.push({
pathname: "/music/album/[albumId]",
params: { albumId },
});
}
}, [track?.AlbumId, track?.ParentId, router, setOpen]);

View File

@@ -0,0 +1,12 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
import Ionicons from "@expo/vector-icons/Ionicons";
import type { ComponentProps } from "react";
export function TabBarIcon({
style,
...rest
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
}

View File

@@ -0,0 +1,63 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
type MoviePosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
export const EpisodePoster: React.FC<MoviePosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
if (item.Type === "Episode") {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
}
}, [item]);
const [progress, _setProgress] = useState(
item.UserData?.PlayedPercentage || 0,
);
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
return (
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className='h-1 bg-red-600 w-full' />
)}
</View>
);
};

View File

@@ -7,15 +7,15 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { TouchableSeerrRouter } from "@/components/common/SeerrItemRouter";
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
import { Text } from "@/components/common/Text";
import { Tag, Tags } from "@/components/GenreTags";
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
import SeerrMediaIcon from "@/components/seerr/SeerrMediaIcon";
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { Colors } from "@/constants/Colors";
import { useSeerr } from "@/hooks/useSeerr";
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
@@ -34,13 +34,13 @@ interface Props extends ViewProps {
mediaRequest?: MediaRequest;
}
const SeerrPoster: React.FC<Props> = ({
const JellyseerrPoster: React.FC<Props> = ({
item,
horizontal,
showDownloadInfo,
mediaRequest,
}) => {
const { seerrApi, getTitle, getYear, getMediaType } = useSeerr();
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
const loadingOpacity = useSharedValue(1);
const imageOpacity = useSharedValue(0);
const { t } = useTranslation();
@@ -56,13 +56,16 @@ const SeerrPoster: React.FC<Props> = ({
const backdropSrc = useMemo(
() =>
seerrApi?.imageProxy(item?.backdropPath, "w1920_and_h800_multi_faces"),
[item, seerrApi, horizontal],
jellyseerrApi?.imageProxy(
item?.backdropPath,
"w1920_and_h800_multi_faces",
),
[item, jellyseerrApi, horizontal],
);
const posterSrc = useMemo(
() => seerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
[item, seerrApi, horizontal],
() => jellyseerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
[item, jellyseerrApi, horizontal],
);
const title = useMemo(() => getTitle(item), [item]);
@@ -72,7 +75,7 @@ const SeerrPoster: React.FC<Props> = ({
const size = useMemo(() => (horizontal ? "h-28" : "w-28"), [horizontal]);
const ratio = useMemo(() => (horizontal ? "15/10" : "10/15"), [horizontal]);
const [canRequest] = useSeerrCanRequest(item);
const [canRequest] = useJellyseerrCanRequest(item);
const is4k = useMemo(() => mediaRequest?.is4k === true, [mediaRequest]);
@@ -106,7 +109,7 @@ const SeerrPoster: React.FC<Props> = ({
second,
third,
fourth,
t("home.settings.plugins.seerr.plus_n_more", { n: rest.length }),
t("home.settings.plugins.jellyseerr.plus_n_more", { n: rest.length }),
];
}
return seasons;
@@ -118,7 +121,7 @@ const SeerrPoster: React.FC<Props> = ({
}, [mediaRequest, is4k]);
return (
<TouchableSeerrRouter
<TouchableJellyseerrRouter
result={item}
mediaTitle={title}
releaseYear={releaseYear}
@@ -170,7 +173,7 @@ const SeerrPoster: React.FC<Props> = ({
className='absolute right-1 top-1 text-right bg-black border border-neutral-800/50'
text={mediaRequest?.requestedBy.displayName}
/>
{(requestedSeasons?.length ?? 0) > 0 && (
{requestedSeasons.length > 0 && (
<Tags
className='absolute bottom-1 left-0.5 w-32'
tagProps={{
@@ -181,12 +184,12 @@ const SeerrPoster: React.FC<Props> = ({
)}
</>
)}
<SeerrStatusIcon
<JellyseerrStatusIcon
className='absolute bottom-1 right-1'
showRequestIcon={canRequest}
mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
/>
<SeerrMediaIcon
<JellyseerrMediaIcon
className='absolute top-1 left-1'
mediaType={mediaType}
/>
@@ -198,8 +201,8 @@ const SeerrPoster: React.FC<Props> = ({
{releaseYear || ""}
</Text>
</View>
</TouchableSeerrRouter>
</TouchableJellyseerrRouter>
);
};
export default SeerrPoster;
export default JellyseerrPoster;

View File

@@ -0,0 +1,48 @@
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
type PosterProps = {
id?: string;
showProgress?: boolean;
};
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() => `${api?.basePath}/Items/${id}/Images/Primary`,
[id],
);
if (!url || !id)
return (
<View
className='border border-neutral-900'
style={{
aspectRatio: "10/15",
}}
/>
);
return (
<View className='rounded-lg overflow-hidden border border-neutral-900'>
<Image
key={id}
id={id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
}}
/>
</View>
);
};
export default ParentPoster;

View File

@@ -1,19 +1,19 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton";
import { SeerrSearchSort } from "@/components/seerr/SeerrIndexPage";
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
interface DiscoverFiltersProps {
searchFilterId: string;
orderFilterId: string;
seerrOrderBy: SeerrSearchSort;
setSeerrOrderBy: (value: SeerrSearchSort) => void;
seerrSortOrder: "asc" | "desc";
setSeerrSortOrder: (value: "asc" | "desc") => void;
jellyseerrOrderBy: JellyseerrSearchSort;
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
jellyseerrSortOrder: "asc" | "desc";
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
t: (key: string) => string;
}
const sortOptions = Object.keys(SeerrSearchSort).filter((v) =>
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
);
@@ -22,10 +22,10 @@ const orderOptions = ["asc", "desc"] as const;
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
searchFilterId,
orderFilterId,
seerrOrderBy,
setSeerrOrderBy,
seerrSortOrder,
setSeerrSortOrder,
jellyseerrOrderBy,
setJellyseerrOrderBy,
jellyseerrSortOrder,
setJellyseerrSortOrder,
t,
}) => {
if (Platform.OS === "ios") {
@@ -52,16 +52,16 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
<Picker
label={t("library.filters.sort_by")}
options={sortOptions.map((item) =>
t(`home.settings.plugins.seerr.order_by.${item}`),
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
)}
variant='menu'
selectedIndex={sortOptions.indexOf(
seerrOrderBy as unknown as string,
jellyseerrOrderBy as unknown as string,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setSeerrOrderBy(
sortOptions[index] as unknown as SeerrSearchSort,
setJellyseerrOrderBy(
sortOptions[index] as unknown as JellyseerrSearchSort,
);
}}
/>
@@ -69,10 +69,10 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
label={t("library.filters.sort_order")}
options={orderOptions.map((item) => t(`library.filters.${item}`))}
variant='menu'
selectedIndex={orderOptions.indexOf(seerrSortOrder)}
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setSeerrSortOrder(orderOptions[index]);
setJellyseerrSortOrder(orderOptions[index]);
}}
/>
</ContextMenu.Items>
@@ -86,15 +86,17 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id={searchFilterId}
queryKey='seerr_search'
queryKey='jellyseerr_search'
queryFn={async () =>
Object.keys(SeerrSearchSort).filter((v) => Number.isNaN(Number(v)))
Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
)
}
set={(value) => setSeerrOrderBy(value[0])}
values={[seerrOrderBy]}
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.seerr.order_by.${item}`)
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
disableSearch={true}
/>
@@ -102,8 +104,8 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
id={orderFilterId}
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setSeerrSortOrder(value[0])}
values={[seerrSortOrder]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true}

View File

@@ -8,6 +8,7 @@ import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -52,7 +53,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
<HorizontalScroll
loading={loading}
keyExtractor={(i, _idx) => i.Id?.toString() || ""}
height={220}
height={POSTER_CAROUSEL_HEIGHT}
data={destinctPeople}
renderItem={(i) => (
<TouchableOpacity

View File

@@ -3,6 +3,7 @@ import { useAtom } from "jotai";
import type React from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
@@ -26,7 +27,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
</Text>
<HorizontalScroll
data={[item]}
height={220}
height={POSTER_CAROUSEL_HEIGHT}
renderItem={(item, _index) => (
<TouchableOpacity
key={item?.Id}

View File

@@ -14,11 +14,11 @@ import { Alert, TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Tags } from "@/components/GenreTags";
import { dateOpts } from "@/components/jellyseerr/DetailFacts";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { RoundButton } from "@/components/RoundButton";
import { dateOpts } from "@/components/seerr/DetailFacts";
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
import { useSeerr } from "@/hooks/useSeerr";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
MediaStatus,
MediaType,
@@ -30,15 +30,15 @@ import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Loader } from "../Loader";
const SeerrSeasonEpisodes: React.FC<{
const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails;
seasonNumber: number;
}> = ({ details, seasonNumber }) => {
const { seerrApi } = useSeerr();
const { jellyseerrApi } = useJellyseerr();
const { data: seasonWithEpisodes, isLoading } = useQuery({
queryKey: ["seerr", details.id, "season", seasonNumber],
queryFn: async () => seerrApi?.tvSeason(details.id, seasonNumber),
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
});
@@ -57,7 +57,11 @@ const SeerrSeasonEpisodes: React.FC<{
};
const RenderItem = ({ item }: any) => {
const { seerrApi, seerrRegion: region, seerrLocale: locale } = useSeerr();
const {
jellyseerrApi,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const [imageError, setImageError] = useState(false);
const upcomingAirDate = useMemo(() => {
@@ -65,7 +69,7 @@ const RenderItem = ({ item }: any) => {
if (airDate) {
const airDateObj = new Date(airDate);
if (new Date() < airDateObj) {
return airDateObj.toLocaleDateString(locale, dateOpts);
return airDateObj.toLocaleDateString(`${locale}-${region}`, dateOpts);
}
}
}, [item, locale, region]);
@@ -79,7 +83,7 @@ const RenderItem = ({ item }: any) => {
key={item.id}
id={item.id}
source={{
uri: seerrApi?.imageProxy(item.stillPath),
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit='cover'
@@ -127,7 +131,7 @@ const RenderItem = ({ item }: any) => {
);
};
const SeerrSeasons: React.FC<{
const JellyseerrSeasons: React.FC<{
isLoading: boolean;
details?: TvDetails;
hasAdvancedRequest?: boolean;
@@ -144,7 +148,7 @@ const SeerrSeasons: React.FC<{
hasAdvancedRequest,
onAdvancedRequest,
}) => {
const { seerrApi, requestMedia } = useSeerr();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [seasonStates, setSeasonStates] = useState<{ [key: number]: boolean }>(
{},
);
@@ -177,7 +181,7 @@ const SeerrSeasons: React.FC<{
);
const requestAll = useCallback(() => {
if (details && seerrApi) {
if (details && jellyseerrApi) {
const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
@@ -194,7 +198,7 @@ const SeerrSeasons: React.FC<{
requestMedia(details.name, body, refetch);
}
}, [
seerrApi,
jellyseerrApi,
seasons,
details,
hasAdvancedRequest,
@@ -206,15 +210,15 @@ const SeerrSeasons: React.FC<{
const promptRequestAll = useCallback(
() =>
Alert.alert(
t("seerr.confirm"),
t("seerr.are_you_sure_you_want_to_request_all_seasons"),
t("jellyseerr.confirm"),
t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"),
[
{
text: t("seerr.cancel"),
text: t("jellyseerr.cancel"),
style: "cancel",
},
{
text: t("seerr.yes"),
text: t("jellyseerr.yes"),
onPress: requestAll,
},
],
@@ -297,10 +301,10 @@ const SeerrSeasons: React.FC<{
<Tags
textClass=''
tags={[
t("seerr.season_number", {
t("jellyseerr.season_number", {
season_number: season.seasonNumber,
}),
t("seerr.number_episodes", {
t("jellyseerr.number_episodes", {
episode_number: season.episodeCount,
}),
]}
@@ -308,7 +312,7 @@ const SeerrSeasons: React.FC<{
{[0].map(() => {
const canRequest = season.status === MediaStatus.UNKNOWN;
return (
<SeerrStatusIcon
<JellyseerrStatusIcon
key={0}
onPress={() =>
requestSeason(canRequest, season.seasonNumber)
@@ -322,7 +326,7 @@ const SeerrSeasons: React.FC<{
</View>
</TouchableOpacity>
{seasonStates?.[season.seasonNumber] && (
<SeerrSeasonEpisodes
<JellyseerrSeasonEpisodes
key={season.seasonNumber}
details={details}
seasonNumber={season.seasonNumber}
@@ -334,4 +338,4 @@ const SeerrSeasons: React.FC<{
);
};
export default SeerrSeasons;
export default JellyseerrSeasons;

View File

@@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const { settings } = useSettings();
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
<ListItem
className={sessions.length !== 0 ? "bg-purple-900" : ""}
onPress={() => router.push("/settings/dashboard/sessions")}
title={t("home.settings.dashboard.sessions_title")}
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,3 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -0,0 +1,3 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -0,0 +1,181 @@
import { useMutation } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { toast } from "sonner-native";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const JellyseerrSettings = () => {
const { jellyseerrUser, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
const { t } = useTranslation();
const [user] = useAtom(userAtom);
const { settings, updateSettings } = useSettings();
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined
>(undefined);
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
string | undefined
>(settings?.jellyseerrServerUrl || undefined);
const loginToJellyseerrMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
throw new Error("Missing server url");
if (!user?.Name)
throw new Error("Missing required information for login");
const jellyseerrTempApi = new JellyseerrApi(
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
);
const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
},
onSuccess: (user) => {
setJellyseerrUser(user);
updateSettings({ jellyseerrServerUrl });
},
onError: () => {
toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword(undefined);
},
});
const clearData = () => {
clearAllJellyseerData().finally(() => {
setJellyseerrUser(undefined);
setJellyseerrPassword(undefined);
setjellyseerrServerUrl(undefined);
});
};
return (
<View className=''>
<View>
{jellyseerrUser ? (
<>
<ListGroup title={"Jellyseerr"}>
<ListItem
title={t(
"home.settings.plugins.jellyseerr.total_media_requests",
)}
value={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
value={
jellyseerrUser?.movieQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
value={
jellyseerrUser?.movieQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
value={
jellyseerrUser?.tvQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
value={
jellyseerrUser?.tvQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
</ListGroup>
<View className='p-4'>
<Button color='red' onPress={clearData}>
{t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
)}
</Button>
</View>
</>
) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>
<View className='flex flex-col shrink mb-2'>
<Text className='text-xs text-gray-600'>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
</View>
<Input
className='border border-neutral-800 mb-2'
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setjellyseerrServerUrl}
editable={!loginToJellyseerrMutation.isPending}
/>
<View>
<Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")}
</Text>
<Input
className='border border-neutral-800'
autoFocus={true}
focusable={true}
placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name },
)}
value={jellyseerrPassword}
keyboardType='default'
secureTextEntry={true}
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
onChangeText={setJellyseerrPassword}
editable={!loginToJellyseerrMutation.isPending}
/>
<Button
loading={loginToJellyseerrMutation.isPending}
disabled={loginToJellyseerrMutation.isPending}
color='purple'
className='h-12 mt-2'
onPress={() => loginToJellyseerrMutation.mutate()}
>
{t("home.settings.plugins.jellyseerr.login_button")}
</Button>
</View>
</View>
)}
</View>
</View>
);
};

View File

@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
/>
</OptionGroup>
<OptionGroup title={t("library.options.options_title")}>
<OptionGroup title='Options'>
<ToggleItem
label={t("library.options.show_titles")}
value={settings.showTitles}

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { Text } from "../common/Text";
@@ -18,21 +17,20 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const media = useMedia();
const { settings, updateSettings } = media;
const { t } = useTranslation();
const alignXOptions: AlignX[] = ["left", "center", "right"];
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
const alignXLabels: Record<AlignX, string> = {
left: t("player.alignment_left"),
center: t("player.alignment_center"),
right: t("player.alignment_right"),
left: "Left",
center: "Center",
right: "Right",
};
const alignYLabels: Record<AlignY, string> = {
top: t("player.alignment_top"),
center: t("player.alignment_center"),
bottom: t("player.alignment_bottom"),
top: "Top",
center: "Center",
bottom: "Bottom",
};
const alignXOptionGroups = useMemo(() => {
@@ -63,14 +61,14 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
return (
<View {...props}>
<ListGroup
title={t("player.mpv_subtitle_settings_title")}
title='MPV Subtitle Settings'
description={
<Text className='text-[#8E8D91] text-xs'>
{t("player.mpv_subtitle_settings_description")}
Advanced subtitle customization for MPV player
</Text>
}
>
<ListItem title={t("player.subtitle_scale")}>
<ListItem title='Subtitle Scale'>
<Stepper
value={settings.mpvSubtitleScale ?? 1.0}
step={0.1}
@@ -82,7 +80,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/>
</ListItem>
<ListItem title={t("player.vertical_margin")}>
<ListItem title='Vertical Margin'>
<Stepper
value={settings.mpvSubtitleMarginY ?? 0}
step={5}
@@ -92,7 +90,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/>
</ListItem>
<ListItem title={t("player.horizontal_alignment")}>
<ListItem title='Horizontal Alignment'>
<PlatformDropdown
groups={alignXOptionGroups}
trigger={
@@ -107,11 +105,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/>
</View>
}
title={t("player.horizontal_alignment")}
title='Horizontal Alignment'
/>
</ListItem>
<ListItem title={t("player.vertical_alignment")}>
<ListItem title='Vertical Alignment'>
<PlatformDropdown
groups={alignYOptionGroups}
trigger={
@@ -126,7 +124,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/>
</View>
}
title={t("player.vertical_alignment")}
title='Vertical Alignment'
/>
</ListItem>
</ListGroup>

View File

@@ -19,23 +19,23 @@ export const PluginSettings = () => {
className='mb-4'
>
<ListItem
onPress={() => router.push("/settings/plugins/seerr/page")}
title={"Seerr"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/streamystats/page")}
title={"Streamystats"}
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
title={"Jellyseerr"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title={"Marlin Search"}
title='Marlin Search'
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/streamystats/page")}
title='Streamystats'
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
title={"KefinTweaks"}
title='KefinTweaks'
showArrow
/>
</ListGroup>

View File

@@ -1,174 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { toast } from "sonner-native";
import { SeerrApi, useSeerr } from "@/hooks/useSeerr";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const SeerrSettings = () => {
const { seerrUser, setSeerrUser, clearAllSeerrData } = useSeerr();
const { t } = useTranslation();
const [user] = useAtom(userAtom);
const { settings, updateSettings } = useSettings();
const [seerrPassword, setSeerrPassword] = useState<string | undefined>(
undefined,
);
const [seerrServerUrl, setSeerrServerUrl] = useState<string | undefined>(
settings?.seerrServerUrl || undefined,
);
const loginToSeerrMutation = useMutation({
mutationFn: async () => {
if (!seerrServerUrl && !settings?.seerrServerUrl)
throw new Error("Missing server url");
if (!user?.Name)
throw new Error("Missing required information for login");
const seerrTempApi = new SeerrApi(
seerrServerUrl || settings.seerrServerUrl || "",
);
const testResult = await seerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return seerrTempApi.login(user.Name, seerrPassword || "");
},
onSuccess: (user) => {
setSeerrUser(user);
updateSettings({ seerrServerUrl });
},
onError: () => {
toast.error(t("seerr.failed_to_login"));
},
onSettled: () => {
setSeerrPassword(undefined);
},
});
const clearData = () => {
clearAllSeerrData().finally(() => {
setSeerrUser(undefined);
setSeerrPassword(undefined);
setSeerrServerUrl(undefined);
});
};
return (
<View className=''>
<View>
{seerrUser ? (
<>
<ListGroup title={"Seerr"}>
<ListItem
title={t("home.settings.plugins.seerr.total_media_requests")}
value={seerrUser?.requestCount?.toString()}
/>
<ListItem
title={t("home.settings.plugins.seerr.movie_quota_limit")}
value={
seerrUser?.movieQuotaLimit?.toString() ??
t("home.settings.plugins.seerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.seerr.movie_quota_days")}
value={
seerrUser?.movieQuotaDays?.toString() ??
t("home.settings.plugins.seerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.seerr.tv_quota_limit")}
value={
seerrUser?.tvQuotaLimit?.toString() ??
t("home.settings.plugins.seerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.seerr.tv_quota_days")}
value={
seerrUser?.tvQuotaDays?.toString() ??
t("home.settings.plugins.seerr.unlimited")
}
/>
</ListGroup>
<View className='p-4'>
<Button color='red' onPress={clearData}>
{t("home.settings.plugins.seerr.reset_seerr_config_button")}
</Button>
</View>
</>
) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.seerr.seerr_warning")}
</Text>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.seerr.server_url")}
</Text>
<View className='flex flex-col shrink mb-2'>
<Text className='text-xs text-gray-600'>
{t("home.settings.plugins.seerr.server_url_hint")}
</Text>
</View>
<Input
className='border border-neutral-800 mb-2'
placeholder={t(
"home.settings.plugins.seerr.server_url_placeholder",
)}
value={seerrServerUrl ?? settings?.seerrServerUrl}
defaultValue={settings?.seerrServerUrl ?? seerrServerUrl}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setSeerrServerUrl}
editable={!loginToSeerrMutation.isPending}
/>
<View>
<Text className='font-bold mb-2'>
{t("home.settings.plugins.seerr.password")}
</Text>
<Input
className='border border-neutral-800'
autoFocus={true}
focusable={true}
placeholder={t(
"home.settings.plugins.seerr.password_placeholder",
{ username: user?.Name },
)}
value={seerrPassword}
keyboardType='default'
secureTextEntry={true}
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
onChangeText={setSeerrPassword}
editable={!loginToSeerrMutation.isPending}
/>
<Button
loading={loginToSeerrMutation.isPending}
disabled={loginToSeerrMutation.isPending}
color='purple'
className='h-12 mt-2'
onPress={() => loginToSeerrMutation.mutate()}
>
{t("home.settings.plugins.seerr.login_button")}
</Button>
</View>
</View>
)}
</View>
</View>
);
};

View File

@@ -40,6 +40,7 @@ import { useVideoTime } from "./hooks/useVideoTime";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector";
interface Props {
item: BaseItemDto;
@@ -57,6 +58,7 @@ interface Props {
startPictureInPicture?: () => Promise<void>;
play: () => void;
pause: () => void;
aspectRatio?: AspectRatio;
isZoomedToFill?: boolean;
onZoomToggle?: () => void;
api?: Api | null;
@@ -87,6 +89,7 @@ export const Controls: FC<Props> = ({
showControls,
setShowControls,
mediaSource,
aspectRatio = "default",
isZoomedToFill = false,
onZoomToggle,
api = null,
@@ -473,7 +476,6 @@ export const Controls: FC<Props> = ({
{/* Technical Info Overlay - rendered outside animated views to stay visible */}
{getTechnicalInfo && (
<TechnicalInfoOverlay
showControls={showControls}
visible={showTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
@@ -495,6 +497,7 @@ export const Controls: FC<Props> = ({
goToNextItem={goToNextItem}
previousItem={previousItem}
nextItem={nextItem}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={onZoomToggle}
playbackSpeed={playbackSpeed}

View File

@@ -14,6 +14,7 @@ import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector";
import { ZoomToggle } from "./ZoomToggle";
interface HeaderControlsProps {
@@ -27,6 +28,7 @@ interface HeaderControlsProps {
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null;
aspectRatio?: AspectRatio;
isZoomedToFill?: boolean;
onZoomToggle?: () => void;
// Playback speed props
@@ -48,6 +50,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
goToNextItem,
previousItem,
nextItem,
aspectRatio: _aspectRatio = "default",
isZoomedToFill = false,
onZoomToggle,
playbackSpeed = 1.0,

View File

@@ -14,7 +14,6 @@ import { HEADER_LAYOUT } from "./constants";
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
interface TechnicalInfoOverlayProps {
showControls: boolean;
visible: boolean;
getTechnicalInfo: () => Promise<TechnicalInfo>;
playMethod?: PlayMethod;
@@ -120,13 +119,7 @@ const formatTranscodeReason = (reason: string): string => {
};
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
({
showControls,
visible,
getTechnicalInfo,
playMethod,
transcodeReasons,
}) => {
({ visible, getTechnicalInfo, playMethod, transcodeReasons }) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null);

View File

@@ -0,0 +1,105 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useMemo } from "react";
import { Platform, View } from "react-native";
import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { useHaptic } from "@/hooks/useHaptic";
export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
interface AspectRatioSelectorProps {
currentRatio: AspectRatio;
onRatioChange: (ratio: AspectRatio) => void;
disabled?: boolean;
}
interface AspectRatioOption {
id: AspectRatio;
label: string;
description: string;
}
const ASPECT_RATIO_OPTIONS: AspectRatioOption[] = [
{
id: "default",
label: "Original",
description: "Use video's original aspect ratio",
},
{
id: "16:9",
label: "16:9",
description: "Widescreen (most common)",
},
{
id: "4:3",
label: "4:3",
description: "Traditional TV format",
},
{
id: "1:1",
label: "1:1",
description: "Square format",
},
{
id: "21:9",
label: "21:9",
description: "Ultra-wide cinematic",
},
];
export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
currentRatio,
onRatioChange,
disabled = false,
}) => {
const lightHapticFeedback = useHaptic("light");
const handleRatioSelect = (ratio: AspectRatio) => {
onRatioChange(ratio);
lightHapticFeedback();
};
const optionGroups = useMemo<OptionGroup[]>(() => {
return [
{
options: ASPECT_RATIO_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: option.id,
selected: option.id === currentRatio,
onPress: () => handleRatioSelect(option.id),
disabled,
})),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentRatio, disabled]);
const trigger = useMemo(
() => (
<View
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
style={{ opacity: disabled ? 0.5 : 1 }}
>
<Ionicons name='crop-outline' size={24} color='white' />
</View>
),
[disabled],
);
// Hide on TV platforms
if (Platform.isTV) return null;
return (
<PlatformDropdown
title='Aspect Ratio'
groups={optionGroups}
trigger={trigger}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -1,7 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import {
@@ -46,7 +45,6 @@ const DropdownView = ({
const { settings, updateSettings } = useSettings();
const router = useRouter();
const isOffline = useOfflineMode();
const { t } = useTranslation();
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
useLocalSearchParams<{
@@ -217,7 +215,7 @@ const DropdownView = ({
return (
<PlatformDropdown
title={t("player.playback_options_title")}
title='Playback Options'
groups={optionGroups}
trigger={trigger}
expoUIConfig={{}}

39
constants/Languages.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
export const LANGUAGES: DefaultLanguageOption[] = [
{ label: "English", value: "eng" },
{ label: "Spanish", value: "spa" },
{ label: "Chinese (Mandarin)", value: "cmn" },
{ label: "Hindi", value: "hin" },
{ label: "Arabic", value: "ara" },
{ label: "French", value: "fra" },
{ label: "Russian", value: "rus" },
{ label: "Portuguese", value: "por" },
{ label: "Japanese", value: "jpn" },
{ label: "German", value: "deu" },
{ label: "Italian", value: "ita" },
{ label: "Korean", value: "kor" },
{ label: "Turkish", value: "tur" },
{ label: "Dutch", value: "nld" },
{ label: "Polish", value: "pol" },
{ label: "Vietnamese", value: "vie" },
{ label: "Thai", value: "tha" },
{ label: "Indonesian", value: "ind" },
{ label: "Greek", value: "ell" },
{ label: "Swedish", value: "swe" },
{ label: "Danish", value: "dan" },
{ label: "Norwegian", value: "nor" },
{ label: "Finnish", value: "fin" },
{ label: "Czech", value: "ces" },
{ label: "Hungarian", value: "hun" },
{ label: "Romanian", value: "ron" },
{ label: "Ukrainian", value: "ukr" },
{ label: "Hebrew", value: "heb" },
{ label: "Bengali", value: "ben" },
{ label: "Punjabi", value: "pan" },
{ label: "Tagalog", value: "tgl" },
{ label: "Swahili", value: "swa" },
{ label: "Malay", value: "msa" },
{ label: "Persian", value: "fas" },
{ label: "Urdu", value: "urd" },
];

6
constants/Values.ts Normal file
View File

@@ -0,0 +1,6 @@
import { Platform } from "react-native";
export const TAB_HEIGHT = Platform.OS === "android" ? 58 : 74;
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
export const POSTER_CAROUSEL_HEIGHT = 220;

View File

@@ -0,0 +1,37 @@
import { useCallback, useEffect, useRef } from "react";
import { useSharedValue } from "react-native-reanimated";
export const useControlsVisibility = (timeout = 3000) => {
const opacity = useSharedValue(1);
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const showControls = useCallback(() => {
opacity.value = 1;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
hideControlsTimerRef.current = setTimeout(() => {
opacity.value = 0;
}, timeout);
}, [timeout]);
const hideControls = useCallback(() => {
opacity.value = 0;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
};
}, []);
return { opacity, showControls, hideControls };
};

View File

@@ -0,0 +1,35 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
export const useDownloadedFileOpener = () => {
const router = useRouter();
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
const openFile = useCallback(
async (item: BaseItemDto) => {
if (!item.Id) {
writeToLog("ERROR", "Attempted to open a file without an ID.");
console.error("Attempted to open a file without an ID.");
return;
}
const queryParams = new URLSearchParams({
itemId: item.Id,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
try {
router.push(`/player/direct-player?${queryParams.toString()}`);
} catch (error) {
writeToLog("ERROR", "Error opening file", error);
console.error("Error opening file:", error);
}
},
[setOfflineSettings, setPlayUrl, router],
);
return { openFile };
};

Some files were not shown because too many files have changed in this diff Show More