Compare commits

..

19 Commits

Author SHA1 Message Date
Uruk
8b12693116 fix: add missing props to ChromecastControls 2026-01-18 14:01:37 +01:00
Uruk
6d97f28cb2 fix: correct useSettings destructuring in ChromecastControls 2026-01-18 14:00:06 +01:00
Uruk
fd0b6d4a87 fix: correct useSettings destructuring in PlayButton 2026-01-18 13:58:27 +01:00
Uruk
24b4f212fb feat: add useAdjacentEpisodes hook 2026-01-18 13:56:49 +01:00
Uruk
6eb74d3736 feat: add ChromecastControls component 2026-01-18 13:54:03 +01:00
tom-heidenreich
46555569e3 feat: continue playback with chromecast 2026-01-18 13:35:29 +01:00
tom-heidenreich
7827a9e279 feat: pass sessionId and mediaSourceId to chromecast session 2026-01-18 13:35:28 +01:00
tom-heidenreich
28eb18ab82 feat: use l18n for chromecast player 2026-01-18 13:35:20 +01:00
tom-heidenreich
ea8e8a9fa7 feat: share selected playback options to chromecast 2026-01-18 13:35:11 +01:00
tom-heidenreich
1f1231ce39 feat: move media loading for chromecast to extra util function
make media loading for chromecast more reusable
2026-01-18 13:35:11 +01:00
tom-heidenreich
3b7bc24c76 feat: get adjacent items in chromecast controls 2026-01-18 13:35:10 +01:00
tom-heidenreich
ea3397a026 feat: pass media item as custom data to chromecast 2026-01-18 13:35:06 +01:00
tom-heidenreich
b922b561f5 feat: add trickplay to chromecast control slider 2026-01-18 13:35:05 +01:00
tom-heidenreich
cd977d117e feat: add intro/credit skip buttons to chromecast controls 2026-01-18 13:33:53 +01:00
tom-heidenreich
962b2d1461 feat: pass item id as as content id to google cast 2026-01-18 13:33:52 +01:00
tom-heidenreich
c2391ba113 feat: display metadata for specific media type 2026-01-18 13:33:52 +01:00
tom-heidenreich
a13c0e8108 feat: use custom google cast player instead of default controls 2026-01-18 13:33:51 +01:00
tom-heidenreich
64765c1a4a feat: create custom google cast player 2026-01-18 13:33:41 +01:00
Uruk
3555ef964e fix: add Chromecast video progress tracking 2026-01-17 18:34:36 +01:00
125 changed files with 5092 additions and 4024 deletions

View File

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

View File

@@ -26,7 +26,7 @@ jobs:
steps: steps:
- name: 🔍 Get PR and Artifacts - name: 🔍 Get PR and Artifacts
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with: with:
script: | script: |
// Check if we're running from a fork (more precise detection) // Check if we're running from a fork (more precise detection)

View File

@@ -33,7 +33,7 @@ jobs:
swap-storage: false swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -41,12 +41,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -60,7 +60,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -73,7 +73,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -88,7 +88,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -116,7 +116,7 @@ jobs:
swap-storage: false swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -124,12 +124,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -143,7 +143,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -156,7 +156,7 @@ jobs:
run: bun run prebuild:tv run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -171,7 +171,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -187,7 +187,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -195,12 +195,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -216,12 +216,12 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 🔧 Setup Xcode - name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with: with:
xcode-version: "26.2" xcode-version: "26.2"
- name: 🏗️ Setup EAS - name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main uses: expo/expo-github-action@main
with: with:
eas-version: latest eas-version: latest
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
@@ -236,7 +236,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa path: build-*.ipa
@@ -251,7 +251,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -259,12 +259,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -280,7 +280,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 🔧 Setup Xcode - name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with: with:
xcode-version: "26.2" xcode-version: "26.2"
@@ -293,7 +293,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }} name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa path: build/*.ipa

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
if: always() && (steps.lint_pr_title.outputs.error_message != null) if: always() && (steps.lint_pr_title.outputs.error_message != null)
with: with:
header: pr-title-lint-error header: pr-title-lint-error
@@ -39,7 +39,7 @@ jobs:
``` ```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }} - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
with: with:
header: pr-title-lint-error header: pr-title-lint-error
delete: true delete: true
@@ -51,13 +51,13 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with: with:
fail-on-severity: high fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -69,14 +69,14 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: 🛒 Checkout repository - name: 🛒 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive
fetch-depth: 0 fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest
@@ -100,19 +100,19 @@ jobs:
steps: steps:
- name: "📥 Checkout PR code" - name: "📥 Checkout PR code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive
fetch-depth: 0 fetch-depth: 0
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '24.x' node-version: '24.x'
- name: "🍞 Setup Bun" - name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with: with:
bun-version: latest bun-version: latest

View File

@@ -18,17 +18,17 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '24.x' node-version: '24.x'
cache: 'npm' cache: 'npm'
- name: 🔍 Extract minor version from app.json - name: 🔍 Extract minor version from app.json
id: minor id: minor
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main uses: actions/github-script@main
with: with:
result-encoding: string result-encoding: string
script: | script: |
@@ -54,7 +54,7 @@ jobs:
dry_run: no-push dry_run: no-push
- name: 📬 Commit and create pull request - name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with: with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report branch: ci-update-bug-report

View File

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

101
README.md
View File

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

View File

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

View File

@@ -71,7 +71,7 @@ export default function page() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")} {t("home.settings.other.select_liraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
</ScrollView> </ScrollView>

View File

@@ -60,7 +60,7 @@ export default function page() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")} {t("home.settings.other.select_liraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
); );

View File

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

View File

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

View File

@@ -3,9 +3,9 @@ import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import { useMemo } from "react"; import { useMemo } from "react";
import SeerrPoster from "@/components/posters/SeerrPoster"; import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useSeerr } from "@/hooks/useSeerr"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import { import {
type MovieResult, type MovieResult,
@@ -13,9 +13,9 @@ import {
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider"; import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
export default function CompanyPage() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { companyId, image, type } = local as unknown as { const { companyId, image, type } = local as unknown as {
companyId: string; companyId: string;
@@ -25,12 +25,12 @@ export default function CompanyPage() {
}; };
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
queryKey: ["seerr", "company", type, companyId], queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
const params: any = { const params: any = {
page: Number(pageParam), page: Number(pageParam),
}; };
return seerrApi?.discover( return jellyseerrApi?.discover(
`${ `${
Number(type) === DiscoverSliderType.NETWORKS Number(type) === DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK ? Endpoints.DISCOVER_TV_NETWORK
@@ -39,7 +39,7 @@ export default function CompanyPage() {
params, params,
); );
}, },
enabled: !!seerrApi && !!companyId, enabled: !!jellyseerrApi && !!companyId,
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage, pages) => getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -53,24 +53,25 @@ export default function CompanyPage() {
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap( .flatMap(
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [], (p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
), ),
"id", "id",
) ?? [], ) ?? [],
[data, isSeerrMovieOrTvResult], [data],
); );
const backdrops = useMemo( const backdrops = useMemo(
() => () =>
seerrApi jellyseerrApi
? flatData.map((r) => ? flatData.map((r) =>
seerrApi.imageProxy( jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath, (r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces", "w1920_and_h800_multi_faces",
), ),
) )
: [], : [],
[seerrApi, flatData], [jellyseerrApi, flatData],
); );
return ( return (
@@ -91,7 +92,7 @@ export default function CompanyPage() {
key={companyId} key={companyId}
className='bottom-1 w-1/2' className='bottom-1 w-1/2'
source={{ source={{
uri: seerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER), uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}} }}
cachePolicy={"memory-disk"} cachePolicy={"memory-disk"}
contentFit='contain' 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 { uniqBy } from "lodash";
import { useMemo } from "react"; import { useMemo } from "react";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import SeerrPoster from "@/components/posters/SeerrPoster"; import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard"; import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useSeerr } from "@/hooks/useSeerr"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
export default function GenrePage() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { genreId, name, type } = local as unknown as { const { genreId, name, type } = local as unknown as {
genreId: string; genreId: string;
@@ -20,21 +20,21 @@ export default function GenrePage() {
}; };
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["seerr", "genre", type, genreId], queryKey: ["jellyseerr", "company", type, genreId],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
const params: any = { const params: any = {
page: Number(pageParam), page: Number(pageParam),
genre: genreId, genre: genreId,
}; };
return seerrApi?.discover( return jellyseerrApi?.discover(
type === DiscoverSliderType.MOVIE_GENRES type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES ? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV, : Endpoints.DISCOVER_TV,
params, params,
); );
}, },
enabled: !!seerrApi && !!genreId, enabled: !!jellyseerrApi && !!genreId,
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage, pages) => getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -48,7 +48,8 @@ export default function GenrePage() {
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap( .flatMap(
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [], (p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
), ),
"id", "id",
) ?? [], ) ?? [],
@@ -57,12 +58,15 @@ export default function GenrePage() {
const backdrops = useMemo( const backdrops = useMemo(
() => () =>
seerrApi jellyseerrApi
? flatData.map((r) => ? 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 ( return (
@@ -87,7 +91,7 @@ export default function GenrePage() {
{name} {name}
</Text> </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 { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags"; import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import RequestModal from "@/components/jellyseerr/RequestModal";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import { SeerrRatings } from "@/components/Ratings"; import { JellyserrRatings } from "@/components/Ratings";
import Cast from "@/components/seerr/Cast"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import DetailFacts from "@/components/seerr/DetailFacts";
import RequestModal from "@/components/seerr/RequestModal";
import SeerrSeasons from "@/components/series/SeerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions"; import { ItemActions } from "@/components/series/SeriesActions";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { import {
type IssueType, type IssueType,
@@ -68,7 +68,7 @@ const Page: React.FC = () => {
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>; } & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation(); const navigation = useNavigation();
const { seerrApi, seerrUser, requestMedia } = useSeerr(); const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>(); const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
@@ -83,8 +83,8 @@ const Page: React.FC = () => {
isLoading, isLoading,
refetch, refetch,
} = useQuery({ } = useQuery({
enabled: !!seerrApi && !!result && !!result.id, enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["seerr", "detail", mediaType, result.id], queryKey: ["jellyseerr", "detail", mediaType, result.id],
staleTime: 0, staleTime: 0,
refetchOnMount: true, refetchOnMount: true,
refetchOnReconnect: true, refetchOnReconnect: true,
@@ -93,18 +93,21 @@ const Page: React.FC = () => {
refetchInterval: 0, refetchInterval: 0,
queryFn: async () => { queryFn: async () => {
return mediaType === MediaType.MOVIE return mediaType === MediaType.MOVIE
? seerrApi?.movieDetails(result.id!) ? jellyseerrApi?.movieDetails(result.id!)
: seerrApi?.tvDetails(result.id!); : jellyseerrApi?.tvDetails(result.id!);
}, },
}); });
const [canRequest, hasAdvancedRequestPermission] = const [canRequest, hasAdvancedRequestPermission] =
useSeerrCanRequest(details); useJellyseerrCanRequest(details);
const canManageRequests = useMemo(() => { const canManageRequests = useMemo(() => {
if (!seerrUser) return false; if (!jellyseerrUser) return false;
return hasPermission(Permission.MANAGE_REQUESTS, seerrUser.permissions); return hasPermission(
}, [seerrUser]); Permission.MANAGE_REQUESTS,
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
const pendingRequest = useMemo(() => { const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find( return details?.mediaInfo?.requests?.find(
@@ -116,27 +119,27 @@ const Page: React.FC = () => {
if (!pendingRequest?.id) return; if (!pendingRequest?.id) return;
try { try {
await seerrApi?.approveRequest(pendingRequest.id); await jellyseerrApi?.approveRequest(pendingRequest.id);
toast.success(t("seerr.toasts.request_approved")); toast.success(t("jellyseerr.toasts.request_approved"));
refetch(); refetch();
} catch (error) { } 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); console.error("Failed to approve request:", error);
} }
}, [seerrApi, pendingRequest, refetch, t]); }, [jellyseerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => { const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return; if (!pendingRequest?.id) return;
try { try {
await seerrApi?.declineRequest(pendingRequest.id); await jellyseerrApi?.declineRequest(pendingRequest.id);
toast.success(t("seerr.toasts.request_declined")); toast.success(t("jellyseerr.toasts.request_declined"));
refetch(); refetch();
} catch (error) { } 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); console.error("Failed to decline request:", error);
} }
}, [seerrApi, pendingRequest, refetch, t]); }, [jellyseerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
@@ -151,7 +154,7 @@ const Page: React.FC = () => {
const submitIssue = useCallback(() => { const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) { if (result.id && issueType && issueMessage && details) {
seerrApi jellyseerrApi
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage) ?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
.then(() => { .then(() => {
setIssueType(undefined); setIssueType(undefined);
@@ -159,7 +162,7 @@ const Page: React.FC = () => {
bottomSheetModalRef?.current?.close(); bottomSheetModalRef?.current?.close();
}); });
} }
}, [seerrApi, details, result, issueType, issueMessage]); }, [jellyseerrApi, details, result, issueType, issueMessage]);
const handleIssueModalDismiss = useCallback(() => { const handleIssueModalDismiss = useCallback(() => {
setIssueTypeDropdownOpen(false); setIssueTypeDropdownOpen(false);
@@ -211,7 +214,7 @@ const Page: React.FC = () => {
const issueTypeOptionGroups = useMemo( const issueTypeOptionGroups = useMemo(
() => [ () => [
{ {
title: t("seerr.types"), title: t("jellyseerr.types"),
options: Object.entries(IssueTypeName) options: Object.entries(IssueTypeName)
.reverse() .reverse()
.map(([key, value]) => ({ .map(([key, value]) => ({
@@ -262,7 +265,7 @@ const Page: React.FC = () => {
height: "100%", height: "100%",
}} }}
source={{ source={{
uri: seerrApi?.imageProxy( uri: jellyseerrApi?.imageProxy(
result.backdropPath, result.backdropPath,
"w1920_and_h800_multi_faces", "w1920_and_h800_multi_faces",
), ),
@@ -292,7 +295,7 @@ const Page: React.FC = () => {
<View className='px-4'> <View className='px-4'>
<View className='flex flex-row justify-between w-full'> <View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'> <View className='flex flex-col w-56'>
<SeerrRatings <JellyserrRatings
result={ result={
result as result as
| MovieResult | MovieResult
@@ -327,7 +330,7 @@ const Page: React.FC = () => {
/> />
) : canRequest ? ( ) : canRequest ? (
<Button color='purple' onPress={request} className='mt-4'> <Button color='purple' onPress={request} className='mt-4'>
{t("seerr.request_button")} {t("jellyseerr.request_button")}
</Button> </Button>
) : ( ) : (
details?.mediaInfo?.jellyfinMediaId && ( details?.mediaInfo?.jellyfinMediaId && (
@@ -350,7 +353,7 @@ const Page: React.FC = () => {
}} }}
> >
<Text className='text-sm'> <Text className='text-sm'>
{t("seerr.report_issue_button")} {t("jellyseerr.report_issue_button")}
</Text> </Text>
</Button> </Button>
)} )}
@@ -386,12 +389,12 @@ const Page: React.FC = () => {
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
<Ionicons name='person-outline' size={16} color='#9CA3AF' /> <Ionicons name='person-outline' size={16} color='#9CA3AF' />
<Text className='text-sm text-neutral-400'> <Text className='text-sm text-neutral-400'>
{t("seerr.requested_by", { {t("jellyseerr.requested_by", {
user: user:
pendingRequest.requestedBy?.displayName || pendingRequest.requestedBy?.displayName ||
pendingRequest.requestedBy?.username || pendingRequest.requestedBy?.username ||
pendingRequest.requestedBy?.jellyfinUsername || pendingRequest.requestedBy?.jellyfinUsername ||
t("seerr.unknown_user"), t("jellyseerr.unknown_user"),
})} })}
</Text> </Text>
</View> </View>
@@ -412,7 +415,7 @@ const Page: React.FC = () => {
borderStyle: "solid", borderStyle: "solid",
}} }}
> >
<Text className='text-sm'>{t("seerr.approve")}</Text> <Text className='text-sm'>{t("jellyseerr.approve")}</Text>
</Button> </Button>
<Button <Button
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100' className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
@@ -430,7 +433,7 @@ const Page: React.FC = () => {
borderStyle: "solid", borderStyle: "solid",
}} }}
> >
<Text className='text-sm'>{t("seerr.decline")}</Text> <Text className='text-sm'>{t("jellyseerr.decline")}</Text>
</Button> </Button>
</View> </View>
</View> </View>
@@ -439,7 +442,7 @@ const Page: React.FC = () => {
</View> </View>
{mediaType === MediaType.TV && ( {mediaType === MediaType.TV && (
<SeerrSeasons <JellyseerrSeasons
isLoading={isLoading || isFetching} isLoading={isLoading || isFetching}
details={details as TvDetails} details={details as TvDetails}
refetch={refetch} 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 className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View> <View>
<Text className='font-bold text-2xl text-neutral-100'> <Text className='font-bold text-2xl text-neutral-100'>
{t("seerr.whats_wrong")} {t("jellyseerr.whats_wrong")}
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 items-start'> <View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col w-full'> <View className='flex flex-col w-full'>
<Text className='opacity-50 mb-1 text-xs'> <Text className='opacity-50 mb-1 text-xs'>
{t("seerr.issue_type")} {t("jellyseerr.issue_type")}
</Text> </Text>
<PlatformDropdown <PlatformDropdown
groups={issueTypeOptionGroups} groups={issueTypeOptionGroups}
@@ -503,11 +506,11 @@ const Page: React.FC = () => {
<Text numberOfLines={1}> <Text numberOfLines={1}>
{issueType {issueType
? IssueTypeName[issueType] ? IssueTypeName[issueType]
: t("seerr.select_an_issue")} : t("jellyseerr.select_an_issue")}
</Text> </Text>
</View> </View>
} }
title={t("seerr.types")} title={t("jellyseerr.types")}
open={issueTypeDropdownOpen} open={issueTypeDropdownOpen}
onOpenChange={setIssueTypeDropdownOpen} onOpenChange={setIssueTypeDropdownOpen}
/> />
@@ -519,7 +522,7 @@ const Page: React.FC = () => {
maxLength={254} maxLength={254}
style={{ color: "white" }} style={{ color: "white" }}
clearButtonMode='always' clearButtonMode='always'
placeholder={t("seerr.describe_the_issue")} placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF' placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal // Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668 // https://github.com/callstack/react-native-paper/issues/1668
@@ -529,7 +532,7 @@ const Page: React.FC = () => {
</View> </View>
</View> </View>
<Button className='mt-auto' onPress={submitIssue} color='purple'> <Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("seerr.submit_button")} {t("jellyseerr.submit_button")}
</Button> </Button>
</View> </View>
</BottomSheetView> </BottomSheetView>

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,15 @@ export default function Layout() {
animation: "fade", animation: "fade",
}} }}
/> />
<Stack.Screen
name="google-cast-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack> </Stack>
</> </>
); );

View File

@@ -48,7 +48,6 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
getMpvAudioId, getMpvAudioId,
@@ -72,6 +71,9 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false); const [isPipMode, setIsPipMode] = useState(false);
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
"default",
);
const [isZoomedToFill, setIsZoomedToFill] = useState(false); const [isZoomedToFill, setIsZoomedToFill] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
@@ -447,7 +449,7 @@ export default function page() {
async (data: { nativeEvent: MpvOnProgressEventPayload }) => { async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.get() || isPlaybackStopped) return;
const { position, cacheSeconds } = data.nativeEvent; const { position } = data.nativeEvent;
// MPV reports position in seconds, convert to ms // MPV reports position in seconds, convert to ms
const currentTime = position * 1000; const currentTime = position * 1000;
@@ -457,12 +459,6 @@ export default function page() {
progress.set(currentTime); progress.set(currentTime);
// Update cache progress (current position + buffered seconds ahead)
if (cacheSeconds !== undefined && cacheSeconds > 0) {
const cacheEnd = currentTime + cacheSeconds * 1000;
cacheProgress.set(cacheEnd);
}
// Update URL immediately after seeking, or every 30 seconds during normal playback // Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now(); const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get(); const shouldUpdateUrl = wasJustSeeking.get();
@@ -502,31 +498,6 @@ export default function page() {
return ticksToSeconds(getInitialPlaybackTicks()); return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]); }, [getInitialPlaybackTicks]);
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
const nowPlayingMetadata = useMemo(() => {
if (!item || !api) return undefined;
const artworkUri = getPrimaryImageUrl({
api,
item,
quality: 90,
width: 500,
});
return {
title: item.Name || "",
artist:
item.Type === "Episode"
? item.SeriesName || ""
: item.AlbumArtist || "",
albumTitle:
item.Type === "Episode" && item.SeasonName
? item.SeasonName
: undefined,
artworkUri: artworkUri || undefined,
};
}, [item, api]);
/** Build video source config for MPV */ /** Build video source config for MPV */
const videoSource = useMemo<MpvVideoSource | undefined>(() => { const videoSource = useMemo<MpvVideoSource | undefined>(() => {
if (!stream?.url) return undefined; if (!stream?.url) return undefined;
@@ -955,7 +926,6 @@ export default function page() {
ref={videoRef} ref={videoRef}
source={videoSource} source={videoSource}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress} onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged} onPlaybackStateChange={onPlaybackStateChanged}
onLoad={() => setIsVideoLoaded(true)} onLoad={() => setIsVideoLoaded(true)}
@@ -1005,6 +975,7 @@ export default function page() {
pause={pause} pause={pause}
seek={seek} seek={seek}
enableTrickplay={true} enableTrickplay={true}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill} isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle} onZoomToggle={handleZoomToggle}
api={api} api={api}

View File

@@ -0,0 +1,184 @@
import React, { useMemo, useState, useRef } from "react";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Button } from "@/components/Button";
import { Feather } from "@expo/vector-icons";
import { RoundButton } from "@/components/RoundButton";
import GoogleCast, {
CastButton,
CastContext,
CastState,
useCastDevice,
useCastState,
useDevices,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { useRouter } from "expo-router";
import { useHaptic } from "@/hooks/useHaptic";
import ChromecastControls from "@/components/ChromecastControls";
import { useTranslation } from "react-i18next";
export default function Player() {
const castState = useCastState();
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus();
const [wasMediaPlaying, setWasMediaPlaying] = useState(false);
const reportPlaybackStopedRef = useRef(() => {});
useEffect(() => {
if (mediaStatus) return; // media currently playing
// media was just playing, report playback stopped
if (wasMediaPlaying) {
reportPlaybackStopedRef.current();
setWasMediaPlaying(false);
}
}, [mediaStatus, wasMediaPlaying]);
const router = useRouter();
const lightHapticFeedback = useHaptic("light");
const { t } = useTranslation();
useEffect(() => {
(async () => {
if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return;
}
await discoveryManager.startDiscovery();
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS]
);
const GoHomeButton = useCallback(
() => (
<Button
onPress={() => {
router.push("/(auth)/(home)/");
}}
>
{t("chromecast.go_home")}
</Button>
),
[router]
);
const ChromecastControlsMemoized = useMemo(() => {
if (!mediaStatus || !client) return undefined;
return (
<ChromecastControls
mediaStatus={mediaStatus}
client={client}
setWasMediaPlaying={setWasMediaPlaying}
reportPlaybackStopedRef={reportPlaybackStopedRef}
/>
);
}, [mediaStatus, client]);
if (
castState === CastState.NO_DEVICES_AVAILABLE ||
castState === CastState.NOT_CONNECTED
) {
// no devices to connect to
if (devices.length === 0) {
return (
<View className="w-screen h-screen flex flex-col ">
<AndroidCastButton />
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white text-lg">
{t("chromecast.no_devices_available")}
</Text>
<Text className="text-gray-400">
{t("chromecast.are_you_on_same_network")}
</Text>
</View>
<View className="px-10">
<GoHomeButton />
</View>
</View>
);
}
// no device selected
return (
<View className="w-screen h-screen flex flex-col ">
<AndroidCastButton />
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<RoundButton
size="large"
background={false}
onPress={() => {
lightHapticFeedback();
CastContext.showCastDialog();
}}
>
<AndroidCastButton />
<Feather name="cast" size={42} color={"white"} />
</RoundButton>
<Text className="text-white text-xl mt-2">
{t("chromecast.no_device_selected")}
</Text>
<Text className="text-gray-400">
{t("chromecast.click_icon_to_connect")}
</Text>
</View>
<View className="px-10">
<GoHomeButton />
</View>
</View>
);
}
if (castState === CastState.CONNECTING) {
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white font-semibold lg mb-2">
{t("chromecast.establishing_connection")}
</Text>
<Loader />
</View>
);
}
// connected, but no media playing
if (!mediaStatus) {
return (
<View className="w-screen h-screen flex flex-col ">
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white text-lg">
{t("chromecast.no_media_selected")}
</Text>
<Text className="text-gray-400">{t("chromecast.start_playing")}</Text>
</View>
<View className="px-10">
<GoHomeButton />
</View>
</View>
);
}
return ChromecastControlsMemoized;
}

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 "./api";
export * from "./mmkv"; export * from "./mmkv";
export * from "./number"; export * from "./number";
export * from "./string";

View File

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

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 {};

244
bun.lock
View File

@@ -19,7 +19,7 @@
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.90.18", "@tanstack/query-sync-storage-persister": "^5.90.18",
"@tanstack/react-pacer": "^0.19.1", "@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.90.20", "@tanstack/react-query": "5.90.17",
"@tanstack/react-query-persist-client": "^5.90.18", "@tanstack/react-query-persist-client": "^5.90.18",
"axios": "^1.7.9", "axios": "^1.7.9",
"expo": "~54.0.31", "expo": "~54.0.31",
@@ -50,14 +50,14 @@
"expo-system-ui": "~6.0.9", "expo-system-ui": "~6.0.9",
"expo-task-manager": "14.0.9", "expo-task-manager": "14.0.9",
"expo-web-browser": "~15.0.10", "expo-web-browser": "~15.0.10",
"i18next": "^26.0.0", "i18next": "^25.0.0",
"jotai": "2.16.2", "jotai": "2.16.2",
"lodash": "4.17.23", "lodash": "4.17.21",
"nativewind": "^2.0.11", "nativewind": "^2.0.11",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "17.0.8", "react-i18next": "16.5.3",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-awesome-slider": "^2.9.0", "react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0", "react-native-bottom-tabs": "1.1.0",
@@ -97,16 +97,16 @@
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.6", "@babel/core": "7.28.6",
"@biomejs/biome": "2.3.11", "@biomejs/biome": "2.3.11",
"@react-native-community/cli": "20.1.3", "@react-native-community/cli": "20.1.0",
"@react-native-tvos/config-tv": "0.1.6", "@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/lodash": "4.17.23", "@types/lodash": "4.17.23",
"@types/react": "19.1.17", "@types/react": "19.1.17",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.18.22", "expo-doctor": "1.17.14",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.5", "lint-staged": "16.2.7",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
"typescript": "5.9.3", "typescript": "5.9.3",
}, },
@@ -404,7 +404,7 @@
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
@@ -462,8 +462,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -514,33 +512,33 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@react-native-community/cli": ["@react-native-community/cli@20.1.3", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.3", "@react-native-community/cli-config": "20.1.3", "@react-native-community/cli-doctor": "20.1.3", "@react-native-community/cli-server-api": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "@react-native-community/cli-types": "20.1.3", "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-sLo8cu9JyFNfuuF1C+8NJ4DHE/PEFaXGd4enkcxi/OJjGG8+sOQrdjNQ4i+cVh/2c+ah1mEMwsYjc3z0+/MqSg=="], "@react-native-community/cli": ["@react-native-community/cli@20.1.0", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.0", "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-doctor": "20.1.0", "@react-native-community/cli-server-api": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "@react-native-community/cli-types": "20.1.0", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-441WsVtRe4nGJ9OzA+QMU1+22lA6Q2hRWqqIMKD0wjEMLqcSfOZyu2UL9a/yRpL/dRpyUsU4n7AxqKfTKO/Csg=="],
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-sFLdLzapfC0scjgzBJJWYDY2RhHPjuuPkA5r6q0gc/UQH/izXpMpLrhh1DW84cMDraNACK0U62tU7ebNaQ1LMQ=="], "@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-77L4DifWfxAT8ByHnkypge7GBMYpbJAjBGV+toowt5FQSGaTBDcBHCX+FFqFRukD5fH6i8sZ41Gtw+nbfCTTIA=="],
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-n73nW0cG92oNF0r994pPqm0DjAShOm3F8LSffDYhJqNAno+h/csmv/37iL4NtSpmKIO8xqsG3uVTXz9X/hzNaQ=="], "@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-1x9rhLLR/dKKb92Lb5O0l0EmUG08FHf+ZVyVEf9M+tX+p5QIm52MRiy43R0UAZ2jJnFApxRk+N3sxoYK4Dtnag=="],
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "fast-glob": "^3.3.2", "fast-xml-parser": "^5.3.6", "picocolors": "^1.1.1" } }, "sha512-DNHDP+OWLyhKShGciBqPcxhxfp1Z/7GQcb4F+TGyCeKQAr+JdnUjRXN3X+YCU/v+g2kbYYyRJKlGabzkVvdrAw=="], "@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-3A01ZDyFeCALzzPcwP/fleHoP3sGNq1UX7FzxkTrOFX8RRL9ntXNXQd27E56VU4BBxGAjAJT4Utw8pcOjJceIA=="],
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-QX9B83nAfCPs0KiaYz61kAEHWr9sttooxzRzNdQwvZTwnsIpvWOT9GvMMj/19OeXiQzMJBzZX0Pgt6+spiUsDQ=="], "@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-n6JVs8Q3yxRbtZQOy05ofeb1kGtspGN3SgwPmuaqvURF9fsuS7c4/9up2Kp9C+1D2J1remPJXiZLNGOcJvfpOA=="],
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.3", "", { "dependencies": { "@react-native-community/cli-config": "20.1.3", "@react-native-community/cli-platform-android": "20.1.3", "@react-native-community/cli-platform-apple": "20.1.3", "@react-native-community/cli-platform-ios": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "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-EI+mAPWn255/WZ4CQohy1I049yiaxVr41C3BeQ2BCyhxODIDR8XRsLzYb1t9MfqK/C3ZncUN2mPSRXFeKPPI1w=="], "@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.0", "", { "dependencies": { "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", "@react-native-community/cli-platform-apple": "20.1.0", "@react-native-community/cli-platform-ios": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-QfJF1GVjA4PBrIT3SJ0vFFIu0km1vwOmLDlOYVqfojajZJ+Dnvl0f94GN1il/jT7fITAxom///XH3/URvi7YTQ=="],
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.3", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-bzB9ELPOISuqgtDZXFPQlkuxx1YFkNx3cNgslc5ElCrk+5LeCLQLIBh/dmIuK8rwUrPcrramjeBj++Noc+TaAA=="], "@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-TeHPDThOwDppQRpndm9kCdRCBI8AMy3HSIQ+iy7VYQXL5BtZ5LfmGdusoj7nVN/ZGn0Lc6Gwts5qowyupXdeKg=="],
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.3", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.3", "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-xml-parser": "^5.3.6", "picocolors": "^1.1.1" } }, "sha512-XJ+DqAD4hkplWVXK5AMgN7pP9+4yRSe5KfZ/b42+ofkDBI55ALlUmX+9HWE3fMuRjcotTCoNZqX2ov97cFDXpQ=="], "@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-0ih1hrYezSM2cuOlVnwBEFtMwtd8YgpTLmZauDJCv50rIumtkI1cQoOgLoS4tbPCj9U/Vn2a9BFH0DLFOOIacg=="],
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.3", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.3" } }, "sha512-2qL48SINotuHbZO73cgqSwqd/OWNx0xTbFSdujhpogV4p8BNwYYypfjh4vJY5qJEB5PxuoVkMXT+aCADpg9nBg=="], "@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.0", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.0" } }, "sha512-XN7Da9z4WsJxtqVtEzY8q2bv22OsvzaFP5zy5+phMWNoJlU4lf7IvBSxqGYMpQ9XhYP7arDw5vmW4W34s06rnA=="],
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.3", "body-parser": "^2.2.2", "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-hsNsdUKZDd2T99OuNuiXz4VuvLa1UN0zcxefmPjXQgI0byrBLzzDr+o7p03sKuODSzKi2h+BMnUxiS07HACQLA=="], "@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "ws": "^6.2.3" } }, "sha512-Tb415Oh8syXNT2zOzLzFkBXznzGaqKCiaichxKzGCDKg6JGHp3jSuCmcTcaPeYC7oc32n/S3Psw7798r4Q/7lA=="],
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.3", "", { "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-EAn0vPCMxtHhfWk2UwLmSUfPfLUnFgC7NjiVJVTKJyVk5qGnkPfoT8te/1IUXFTysUB0F0RIi+NgDB4usFOLeA=="], "@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.0", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-/YmzHGOkY6Bgrv4OaA1L8rFqsBlQd1EB2/ipAoKPiieV0EcB5PUamUSuNeFU3sBZZTYQCUENwX4wgOHgFUlDnQ=="],
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.3", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-IdAcegf0pH1hVraxWTG1ACLkYC0LDQfqtaEf42ESyLIF3Xap70JzL/9tAlxw7lSCPZPFWhrcgU0TBc4SkC/ecw=="], "@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.0", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-D0kDspcwgbVXyNjwicT7Bb1JgXjijTw1JJd+qxyF/a9+sHv7TU4IchV+gN38QegeXqVyM4Ym7YZIvXMFBmyJqA=="],
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="], "@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.6", "", { "dependencies": { "getenv": "^1.0.0", "glob": "^11.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-VxMSIcro+U1EVb64pYShZsc+uE3HNGhfHppoUhTyGwx9ELQkhWvReRTOI4gpb/qeRWEcT+UbUc9Gd9Zlwm572w=="], "@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="],
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="], "@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
@@ -604,7 +602,7 @@
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="], "@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="], "@tanstack/react-query": ["@tanstack/react-query@5.90.17", "", { "dependencies": { "@tanstack/query-core": "5.90.17" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ=="],
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="], "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
@@ -776,7 +774,7 @@
"bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="], "bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -830,7 +828,7 @@
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
@@ -846,7 +844,7 @@
"color-string": ["color-string@2.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA=="], "color-string": ["color-string@2.1.2", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA=="],
"colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
@@ -992,7 +990,7 @@
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
@@ -1032,7 +1030,7 @@
"expo-device": ["expo-device@8.0.10", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA=="], "expo-device": ["expo-device@8.0.10", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA=="],
"expo-doctor": ["expo-doctor@1.18.22", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-AEGwceidWxyYpWEfIf3XnUvc+FbI3OjjyBaXctuoZg10x9An+utrdRf6go/3UFRAG5EkpMOWgUT0j1TKcYDsSQ=="], "expo-doctor": ["expo-doctor@1.17.14", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-+UsXFP5ZTVobDuGS5Du8aKU6O6s2sa49QOdGHdzP8UEjQKH8gPb59uw6hxEQmo6YtVboLwQd13QEdcSolBMvLw=="],
"expo-file-system": ["expo-file-system@19.0.21", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg=="], "expo-file-system": ["expo-file-system@19.0.21", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg=="],
@@ -1096,9 +1094,7 @@
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
"fast-xml-parser": ["fast-xml-parser@5.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
@@ -1152,7 +1148,7 @@
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -1168,7 +1164,7 @@
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@@ -1208,9 +1204,9 @@
"hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="],
"i18next": ["i18next@26.2.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA=="], "i18next": ["i18next@25.6.1", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-yUWvdXtalZztmKrKw3yz/AvSP3yKyqIkVPx/wyvoYy9lkLmwzItLxp0iHZLG5hfVQ539Jor4XLO+U+NHIXg7pw=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -1280,7 +1276,7 @@
"istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="], "istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="],
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="], "jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="],
@@ -1374,13 +1370,13 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lint-staged": ["lint-staged@17.0.5", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw=="], "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="],
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="], "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
@@ -1404,7 +1400,7 @@
"mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="], "mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
@@ -1452,7 +1448,7 @@
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1466,6 +1462,8 @@
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nativewind": ["nativewind@2.0.11", "", { "dependencies": { "@babel/generator": "^7.18.7", "@babel/helper-module-imports": "7.18.6", "@babel/types": "7.19.0", "css-mediaquery": "^0.1.2", "css-to-react-native": "^3.0.0", "micromatch": "^4.0.5", "postcss": "^8.4.12", "postcss-calc": "^8.2.4", "postcss-color-functional-notation": "^4.2.2", "postcss-css-variables": "^0.18.0", "postcss-nested": "^5.0.6", "react-is": "^18.1.0", "use-sync-external-store": "^1.1.0" }, "peerDependencies": { "tailwindcss": "~3" } }, "sha512-qCEXUwKW21RYJ33KRAJl3zXq2bCq82WoI564fI21D/TiqhfmstZOqPN53RF8qK1NDK6PGl56b2xaTxgObEePEg=="], "nativewind": ["nativewind@2.0.11", "", { "dependencies": { "@babel/generator": "^7.18.7", "@babel/helper-module-imports": "7.18.6", "@babel/types": "7.19.0", "css-mediaquery": "^0.1.2", "css-to-react-native": "^3.0.0", "micromatch": "^4.0.5", "postcss": "^8.4.12", "postcss-calc": "^8.2.4", "postcss-color-functional-notation": "^4.2.2", "postcss-css-variables": "^0.18.0", "postcss-nested": "^5.0.6", "react-is": "^18.1.0", "use-sync-external-store": "^1.1.0" }, "peerDependencies": { "tailwindcss": "~3" } }, "sha512-qCEXUwKW21RYJ33KRAJl3zXq2bCq82WoI564fI21D/TiqhfmstZOqPN53RF8qK1NDK6PGl56b2xaTxgObEePEg=="],
@@ -1548,8 +1546,6 @@
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -1562,7 +1558,9 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
@@ -1618,7 +1616,7 @@
"qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="], "qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
@@ -1628,7 +1626,7 @@
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
@@ -1642,7 +1640,7 @@
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="], "react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="], "react-i18next": ["react-i18next@16.5.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw=="],
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="], "react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
@@ -1698,7 +1696,7 @@
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#003afd0", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-003afd0", "sha512-HR7BaMDMBhQ4xAy5XeQEkT0fBosWw2x9+X2QOD4buocxuX03D770LaRKm5rgQ/qDzchr92KW7+d8fISI14fRLA=="], "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#003afd0", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-003afd0"],
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="], "react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
@@ -1828,7 +1826,7 @@
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="], "slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="], "slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
@@ -1856,8 +1854,6 @@
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="], "strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"strict-url-sanitise": ["strict-url-sanitise@0.0.1", "", {}, "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -1874,7 +1870,7 @@
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
@@ -1912,8 +1908,6 @@
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
@@ -1938,7 +1932,7 @@
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], "type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@@ -2018,7 +2012,7 @@
"wonka": ["wonka@6.3.5", "", {}, "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw=="], "wonka": ["wonka@6.3.5", "", {}, "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw=="],
"wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -2030,8 +2024,6 @@
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
"xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="], "xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
@@ -2040,7 +2032,7 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -2124,8 +2116,6 @@
"@expo/cli/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], "@expo/cli/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"@expo/cli/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], "@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="], "@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
@@ -2162,8 +2152,6 @@
"@expo/fingerprint/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], "@expo/fingerprint/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"@expo/fingerprint/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@expo/fingerprint/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@expo/fingerprint/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@expo/image-utils/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], "@expo/image-utils/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
@@ -2182,8 +2170,6 @@
"@expo/metro-config/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], "@expo/metro-config/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"@expo/metro-config/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], "@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
"@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], "@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
@@ -2192,6 +2178,12 @@
"@expo/xcpretty/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], "@expo/xcpretty/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -2228,8 +2220,6 @@
"@react-native/codegen/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], "@react-native/codegen/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@react-native/codegen/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"@react-native/community-cli-plugin/metro": ["metro@0.83.2", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-symbolicate": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw=="], "@react-native/community-cli-plugin/metro": ["metro@0.83.2", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-symbolicate": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw=="],
"@react-native/community-cli-plugin/metro-config": ["metro-config@0.83.2", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.2", "metro-cache": "0.83.2", "metro-core": "0.83.2", "metro-runtime": "0.83.2", "yaml": "^2.6.1" } }, "sha512-1FjCcdBe3e3D08gSSiU9u3Vtxd7alGH3x/DNFqWDFf5NouX4kLgbVloDDClr1UrLz62c0fHh2Vfr9ecmrOZp+g=="], "@react-native/community-cli-plugin/metro-config": ["metro-config@0.83.2", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.2", "metro-cache": "0.83.2", "metro-core": "0.83.2", "metro-runtime": "0.83.2", "yaml": "^2.6.1" } }, "sha512-1FjCcdBe3e3D08gSSiU9u3Vtxd7alGH3x/DNFqWDFf5NouX4kLgbVloDDClr1UrLz62c0fHh2Vfr9ecmrOZp+g=="],
@@ -2252,7 +2242,7 @@
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], "@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], "@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.17", "", {}, "sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ=="],
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], "@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
@@ -2268,12 +2258,12 @@
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="], "ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="],
"ansi-fragments/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], "ansi-fragments/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"babel-plugin-jest-hoist/@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], "babel-plugin-jest-hoist/@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
@@ -2288,11 +2278,13 @@
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -2328,6 +2320,8 @@
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -2344,20 +2338,16 @@
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"lint-staged/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"metro/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "metro/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
@@ -2384,8 +2374,6 @@
"metro-babel-transformer/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], "metro-babel-transformer/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
"metro-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"metro-source-map/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], "metro-source-map/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"metro-source-map/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "metro-source-map/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
@@ -2406,8 +2394,6 @@
"metro-transform-worker/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "metro-transform-worker/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"nativewind/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], "nativewind/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"nativewind/@babel/types": ["@babel/types@7.19.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.18.10", "@babel/helper-validator-identifier": "^7.18.6", "to-fast-properties": "^2.0.0" } }, "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA=="], "nativewind/@babel/types": ["@babel/types@7.19.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.18.10", "@babel/helper-validator-identifier": "^7.18.6", "to-fast-properties": "^2.0.0" } }, "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA=="],
@@ -2424,32 +2410,22 @@
"patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], "patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
"patch-package/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"postcss-load-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"react-i18next/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"react-native/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "react-native/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
@@ -2464,12 +2440,8 @@
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"requireg/resolve": ["resolve@1.7.1", "", { "dependencies": { "path-parse": "^1.0.5" } }, "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw=="], "requireg/resolve": ["resolve@1.7.1", "", { "dependencies": { "path-parse": "^1.0.5" } }, "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw=="],
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
@@ -2498,21 +2470,15 @@
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -2672,6 +2638,10 @@
"@expo/cli/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@expo/cli/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@expo/config-plugins/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"@expo/config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"@expo/fingerprint/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "@expo/fingerprint/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
@@ -2704,6 +2674,12 @@
"@expo/package-manager/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], "@expo/package-manager/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
@@ -2778,8 +2754,6 @@
"@react-native/codegen/@babel/parser/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "@react-native/codegen/@babel/parser/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@react-native/codegen/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@react-native/community-cli-plugin/metro/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@react-native/community-cli-plugin/metro/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@react-native/community-cli-plugin/metro/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="], "@react-native/community-cli-plugin/metro/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
@@ -2824,8 +2798,6 @@
"@react-native/community-cli-plugin/metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="], "@react-native/community-cli-plugin/metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
"@react-native/community-cli-plugin/metro-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="], "@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="],
"@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -2858,6 +2830,8 @@
"babel-preset-expo/@babel/helper-module-imports/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "babel-preset-expo/@babel/helper-module-imports/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -2884,6 +2858,8 @@
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"istanbul-lib-instrument/@babel/core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "istanbul-lib-instrument/@babel/core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"istanbul-lib-instrument/@babel/core/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], "istanbul-lib-instrument/@babel/core/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
@@ -2906,16 +2882,8 @@
"log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"logkitty/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], "logkitty/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"logkitty/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "logkitty/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -3010,14 +2978,8 @@
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"react-native/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -3026,22 +2988,16 @@
"serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], "serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"sucrase/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"sucrase/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], "sucrase/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
@@ -3124,8 +3080,6 @@
"@react-native/codegen/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], "@react-native/codegen/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@react-native/codegen/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"@react-native/community-cli-plugin/metro/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], "@react-native/community-cli-plugin/metro/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@react-native/community-cli-plugin/metro/@babel/core/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], "@react-native/community-cli-plugin/metro/@babel/core/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
@@ -3174,6 +3128,8 @@
"expo-manifests/@expo/config/@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "expo-manifests/@expo/config/@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"expo-manifests/@expo/config/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"expo-manifests/@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "expo-manifests/@expo/config/sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"istanbul-lib-instrument/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], "istanbul-lib-instrument/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
@@ -3184,12 +3140,6 @@
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"log-update/slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"log-update/wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"logkitty/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "logkitty/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"logkitty/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "logkitty/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -3212,14 +3162,8 @@
"metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], "metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"react-native/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"sucrase/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -3254,12 +3198,6 @@
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"sucrase/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"sucrase/glob/jackspeak/@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"sucrase/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], "@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
@@ -3272,12 +3210,6 @@
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"sucrase/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"sucrase/glob/jackspeak/@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"sucrase/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
} }
} }

View File

@@ -1,5 +1,8 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react"; import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler"; import { Pressable } from "react-native-gesture-handler";
import GoogleCast, { import GoogleCast, {
@@ -10,7 +13,9 @@ import GoogleCast, {
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { useRouter } from "expo-router";
export function Chromecast({ export function Chromecast({
width = 48, width = 48,
@@ -24,6 +29,12 @@ export function Chromecast({
const sessionManager = GoogleCast.getSessionManager(); const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager(); const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const lastReportedProgressRef = useRef(0);
const router = useRouter();
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -36,6 +47,53 @@ export function Chromecast({
})(); })();
}, [client, devices, castDevice, sessionManager, discoveryManager]); }, [client, devices, castDevice, sessionManager, discoveryManager]);
// Report video progress to Jellyfin server
useEffect(() => {
if (
!api ||
!user?.Id ||
!mediaStatus ||
!mediaStatus.mediaInfo?.contentId
) {
return;
}
const streamPosition = mediaStatus.streamPosition || 0;
// Report every 10 seconds
if (Math.abs(streamPosition - lastReportedProgressRef.current) < 10) {
return;
}
const contentId = mediaStatus.mediaInfo.contentId;
const positionTicks = Math.floor(streamPosition * 10000000);
const isPaused = mediaStatus.playerState === "paused";
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
const isTranscoding = streamUrl.includes("m3u8");
const progressInfo: PlaybackProgressInfo = {
ItemId: contentId,
PositionTicks: positionTicks,
IsPaused: isPaused,
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
PlaySessionId: contentId,
};
getPlaystateApi(api)
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
.then(() => {
lastReportedProgressRef.current = streamPosition;
})
.catch((error) => {
console.error("Failed to report Chromecast progress:", error);
});
}, [
api,
user?.Id,
mediaStatus?.streamPosition,
mediaStatus?.mediaInfo?.contentId,
]);
// Android requires the cast button to be present for startDiscovery to work // Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback( const AndroidCastButton = useCallback(
() => () =>
@@ -66,7 +124,7 @@ export function Chromecast({
className='mr-2' className='mr-2'
background={false} background={false}
onPress={() => { onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
else CastContext.showCastDialog(); else CastContext.showCastDialog();
}} }}
{...props} {...props}
@@ -80,7 +138,7 @@ export function Chromecast({
<RoundButton <RoundButton
size='large' size='large'
onPress={() => { onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
else CastContext.showCastDialog(); else CastContext.showCastDialog();
}} }}
{...props} {...props}

View File

@@ -0,0 +1,897 @@
import React, { useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Feather, Ionicons } from "@expo/vector-icons";
import { RoundButton } from "@/components/RoundButton";
import {
CastButton,
CastContext,
MediaStatus,
RemoteMediaClient,
useStreamPosition,
} from "react-native-google-cast";
import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { Image } from "expo-image";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
SharedValue,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { debounce } from "lodash";
import { useSettings } from "@/utils/atoms/settings";
import { useHaptic } from "@/hooks/useHaptic";
import { writeToLog } from "@/utils/log";
import { formatTimeString } from "@/utils/time";
import SkipButton from "@/components/video-player/controls/SkipButton";
import NextEpisodeCountDownButton from "@/components/video-player/controls/NextEpisodeCountDownButton";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useTrickplay } from "@/hooks/useTrickplay";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia";
import { useAtomValue } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast as chromecastProfile } from "@/utils/profiles/chromecast";
import { SelectedOptions } from "./ItemContent";
import {
getDefaultPlaySettings,
previousIndexes,
} from "@/utils/jellyfin/getDefaultPlaySettings";
import { useQuery } from "@tanstack/react-query";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useTranslation } from "react-i18next";
import { Colors } from "@/constants/Colors";
import { useRouter } from "expo-router";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { ItemImage } from "@/components/common/ItemImage";
import { BitrateSelector } from "@/components/BitrateSelector";
import { ItemHeader } from "@/components/ItemHeader";
import { MediaSourceSelector } from "@/components/MediaSourceSelector";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { ItemTechnicalDetails } from "@/components/ItemTechnicalDetails";
import { OverviewText } from "@/components/OverviewText";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PlayedStatus } from "./PlayedStatus";
import { AddToFavorites } from "./AddToFavorites";
export default function ChromecastControls({
mediaStatus,
client,
setWasMediaPlaying,
reportPlaybackStopedRef,
}: {
mediaStatus: MediaStatus;
client: RemoteMediaClient;
setWasMediaPlaying: (wasPlaying: boolean) => void;
reportPlaybackStopedRef: React.MutableRefObject<() => void>;
}) {
const lightHapticFeedback = useHaptic("light");
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(Infinity);
const max = useSharedValue(mediaStatus.mediaInfo?.streamDuration || 0);
const streamPosition = useStreamPosition();
const progress = useSharedValue(streamPosition || 0);
const wasPlayingRef = useRef(false);
const isSeeking = useSharedValue(false);
const isPlaying = useMemo(
() => mediaStatus.playerState === "playing",
[mediaStatus.playerState]
);
const isBufferingOrLoading = useMemo(
() =>
mediaStatus.playerState === null ||
mediaStatus.playerState === "buffering" ||
mediaStatus.playerState === "loading",
[mediaStatus.playerState]
);
// request update of media status every player state change
useEffect(() => {
client.requestStatus();
}, [mediaStatus.playerState]);
// update max progress
useEffect(() => {
if (mediaStatus.mediaInfo?.streamDuration)
max.value = mediaStatus.mediaInfo?.streamDuration;
}, [mediaStatus.mediaInfo?.streamDuration]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
setCurrentTime(currentProgress);
setRemainingTime(maxValue - currentProgress);
},
[]
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
const { mediaMetadata, itemId, streamURL } = useMemo(
() => ({
mediaMetadata: mediaStatus.mediaInfo?.metadata,
itemId: mediaStatus.mediaInfo?.contentId,
streamURL: mediaStatus.mediaInfo?.contentUrl,
}),
[mediaStatus]
);
const type = useMemo(
() => mediaMetadata?.type || "generic",
[mediaMetadata?.type]
);
const images = useMemo(
() => mediaMetadata?.images || [],
[mediaMetadata?.images]
);
const { playbackOptions, sessionId, mediaSourceId } = useMemo(() => {
const mediaCustomData = mediaStatus.mediaInfo?.customData as
| {
playbackOptions: SelectedOptions;
sessionId?: string;
mediaSourceId?: string;
}
| undefined;
return (
mediaCustomData || {
playbackOptions: undefined,
sessionId: undefined,
mediaSourceId: undefined,
}
);
}, [mediaStatus.mediaInfo?.customData]);
const {
data: item,
// currently nothing is indicating that item is loading, because most of the time it loads very fast
isLoading: isLoadingItem,
isError: isErrorItem,
error,
refetch,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (!itemId) return;
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId,
staleTime: 0,
});
const onProgress = useCallback(
async (progressInTicks: number, isPlaying: boolean) => {
if (!item?.Id || !streamURL) return;
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: playbackOptions?.audioIndex,
subtitleStreamIndex: playbackOptions?.subtitleIndex,
mediaSourceId,
positionTicks: Math.floor(progressInTicks),
isPaused: !isPlaying,
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: sessionId,
});
},
[api, item, playbackOptions, mediaSourceId, streamURL, sessionId]
);
// update progess on stream position change
useEffect(() => {
if (streamPosition) {
progress.value = streamPosition;
onProgress(secondsToTicks(streamPosition), isPlaying);
}
}, [streamPosition, isPlaying]);
const reportPlaybackStart = useCallback(async () => {
if (!streamURL) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id!,
audioStreamIndex: playbackOptions?.audioIndex,
subtitleStreamIndex: playbackOptions?.subtitleIndex,
mediaSourceId,
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: sessionId,
});
}, [api, item, playbackOptions, mediaSourceId, streamURL, sessionId]);
// report playback started
useEffect(() => {
setWasMediaPlaying(true);
reportPlaybackStart();
}, [reportPlaybackStart]);
// update the reportPlaybackStoppedRef
useEffect(() => {
reportPlaybackStopedRef.current = async () => {
if (!streamURL) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId,
positionTicks: secondsToTicks(progress.value),
playSessionId: sessionId,
});
};
}, [
api,
item,
playbackOptions,
progress,
mediaSourceId,
streamURL,
sessionId,
]);
const { previousItem, nextItem } = useAdjacentItems({
item: {
Id: itemId,
SeriesId: item?.SeriesId,
Type: item?.Type,
},
});
const goToItem = useCallback(
async (item: BaseItemDto) => {
if (!api) {
console.warn("Failed to go to item: No api!");
return;
}
const previousIndexes: previousIndexes = {
subtitleIndex: playbackOptions?.subtitleIndex || undefined,
audioIndex: playbackOptions?.audioIndex || undefined,
};
const {
mediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(item, settings, previousIndexes, undefined);
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: defaultAudioIndex,
// maxStreamingBitrate: playbackOptions.bitrate?.value, // TODO handle bitrate limit
subtitleStreamIndex: defaultSubtitleIndex,
mediaSourceId: mediaSource?.Id,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert("Client error", "Could not create stream for Chromecast");
return;
}
await chromecastLoadMedia({
client,
item,
contentUrl: data.url,
sessionId: data.sessionId || undefined,
mediaSourceId: data.mediaSource?.Id || undefined,
playbackOptions,
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
});
await client.requestStatus();
},
[client, api]
);
const goToNextItem = useCallback(() => {
if (!nextItem) {
console.warn("Failed to skip to next item: No next item!");
return;
}
lightHapticFeedback();
goToItem(nextItem);
}, [nextItem, lightHapticFeedback]);
const goToPreviousItem = useCallback(() => {
if (!previousItem) {
console.warn("Failed to skip to next item: No next item!");
return;
}
lightHapticFeedback();
goToItem(previousItem);
}, [previousItem, lightHapticFeedback]);
const pause = useCallback(() => {
client.pause();
}, [client]);
const play = useCallback(() => {
client.play();
}, [client]);
const seek = useCallback(
(time: number) => {
// skip to next episode if seeking to end (for credit skipping)
// with 1 second room to react
if (nextItem && time >= max.value - 1) {
goToNextItem();
return;
}
client.seek({
position: time,
});
},
[client, goToNextItem, nextItem, max]
);
const togglePlay = useCallback(() => {
if (isPlaying) pause();
else play();
}, [isPlaying, play, pause]);
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = Math.max(0, curr - settings.rewindSkipTime);
seek(newTime);
if (wasPlayingRef.current === true) play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying]);
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = curr + settings.forwardSkipTime;
seek(Math.max(0, newTime));
if (wasPlayingRef.current === true) play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying]);
const { showSkipButton, skipIntro } = useIntroSkipper(
itemId,
currentTime,
seek,
play,
false
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
itemId,
currentTime,
seek,
play,
false
);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS]
);
const TrickplaySliderMemoized = useMemo(
() => (
<TrickplaySlider
item={item}
progress={progress}
wasPlayingRef={wasPlayingRef}
isPlaying={isPlaying}
isSeeking={isSeeking}
range={{ max }}
play={play}
pause={pause}
seek={seek}
/>
),
[
item,
progress,
wasPlayingRef,
isPlaying,
isSeeking,
max,
play,
pause,
seek,
]
);
const NextEpisodeButtonMemoized = useMemo(
() => (
<NextEpisodeCountDownButton
show={nextItem !== null && max.value > 0 && remainingTime < 10}
onFinish={goToNextItem}
onPress={goToNextItem}
/>
),
[nextItem, max, remainingTime, goToNextItem]
);
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
const logoUrl = useMemo(() => images[0]?.url, [images]);
if (isErrorItem) {
return (
<View className="w-full h-full flex flex-col items-center justify-center bg-black">
<View className="p-12 flex gap-4">
<Text className="text-center font-semibold text-red-500 text-lg">
{t("chromecast.error_loading_item")}
</Text>
{error && (
<Text className="text-center opacity-80">{error.message}</Text>
)}
</View>
<View className="flex gap-2 mt-auto mb-20">
<TouchableOpacity
className="flex flex-row items-center justify-center gap-2"
onPress={() => refetch()}
>
<Ionicons name="reload" size={24} color={Colors.primary} />
<Text className="ml-2 text-purple-600 text-lg">
{t("chromecast.retry_load_item")}
</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex flex-row items-center justify-center gap-2"
onPress={() => {
router.push("/(auth)/(home)/");
}}
>
<Ionicons name="home" size={16} color={Colors.text} />
<Text className="ml-2 text-white text-sm underline">
{t("chromecast.go_home")}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
if (!item) {
return <Text>Do something when item is undefined</Text>;
}
if (!playbackOptions) {
return <Text>Do something when playbackOptions is undefined</Text>;
}
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
{/* TODO do navigation header properly */}
<View
className="flex flex-row justify-between absolute w-full top-2 z-50"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<RoundButton size="large" icon="arrow-back" />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<RoundButton
size="large"
onPress={() => {
CastContext.showCastDialog();
}}
>
<AndroidCastButton />
<Feather name="cast" size={24} color={"white"} />
</RoundButton>
<PlayedStatus items={[item]} size="large" />
<AddToFavorites item={item} />
</View>
)}
</View>
<ParallaxScrollView
className={`flex-1 ${loadingLogo ? "opacity-0" : "opacity-100"}`}
headerHeight={headerHeight}
headerImage={
<View style={[{ flex: 1 }]}>
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null}
</>
}
>
<View className="flex flex-col bg-transparent shrink">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && !Platform.isTV && (
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector
className="mr-1"
onChange={(val) =>
// setSelectedOptions(
// (prev) => prev && { ...prev, bitrate: val }
// )
console.log("new selected options", val)
}
selected={playbackOptions.bitrate}
/>
<MediaSourceSelector
className="mr-1"
item={item}
onChange={(val) =>
// setSelectedOptions((prev) =>
// prev && {
// ...prev,
// mediaSource: val,
// }
// )
console.log("new selected options", val)
}
selected={playbackOptions.mediaSource}
/>
<AudioTrackSelector
className="mr-1"
source={playbackOptions.mediaSource}
onChange={(val) => {
// setSelectedOptions((prev) =>
// prev && {
// ...prev,
// audioIndex: val,
// }
// );
console.log("new selected options", val);
}}
selected={playbackOptions.audioIndex}
/>
<SubtitleTrackSelector
source={playbackOptions.mediaSource}
onChange={(val) =>
// setSelectedOptions(
// (prev) =>
// prev && {
// ...prev,
// subtitleIndex: val,
// }
// )
console.log("new selected options", val)
}
selected={playbackOptions.subtitleIndex}
/>
</View>
)}
</View>
<ItemTechnicalDetails source={playbackOptions.mediaSource} />
<OverviewText text={item.Overview} className="px-4 mb-4" />
</View>
</ParallaxScrollView>
<View className="pt-2">
{TrickplaySliderMemoized}
<View className="flex flex-row items-center justify-between mt-2">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime, "s")}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime, "s")}
</Text>
</View>
<View className="flex flex-row w-full items-center justify-evenly mt-2 mb-10">
<TouchableOpacity onPress={goToPreviousItem} disabled={!previousItem}>
<Ionicons
name="play-skip-back-outline"
size={30}
color={previousItem ? "white" : "gray"}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons name="play-back-outline" size={30} color="white" />
</TouchableOpacity>
<TouchableOpacity
onPress={() => togglePlay()}
className="flex w-14 h-14 items-center justify-center"
>
{!isBufferingOrLoading ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={50}
color="white"
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="play-forward-outline" size={30} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={goToNextItem} disabled={!nextItem}>
<Ionicons
name="play-skip-forward-outline"
size={30}
color={nextItem ? "white" : "gray"}
/>
</TouchableOpacity>
</View>
</View>
{/* TODO find proper placement for these buttons */}
{/* <View className="flex flex-row w-full justify-end px-6 pb-6">
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText="Skip Intro"
/>
<SkipButton
showButton={showSkipCreditButton}
onPress={skipCredit}
buttonText="Skip Credits"
/>
{NextEpisodeButtonMemoized}
</View> */}
</View>
);
}
type TrickplaySliderProps = {
item?: BaseItemDto;
progress: SharedValue<number>;
wasPlayingRef: React.MutableRefObject<boolean>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
range: { min?: SharedValue<number>; max: SharedValue<number> };
play: () => void;
pause: () => void;
seek: (time: number) => void;
};
function TrickplaySlider({
item,
progress,
wasPlayingRef,
isPlaying,
isSeeking,
range,
play,
pause,
seek,
}: TrickplaySliderProps) {
const [isSliding, setIsSliding] = useState(false);
const lastProgressRef = useRef<number>(0);
const min = useSharedValue(range.min?.value || 0);
const {
trickPlayUrl,
calculateTrickplayUrl,
trickplayInfo,
prefetchAllTrickplayImages,
} = useTrickplay(
{
Id: item?.Id,
RunTimeTicks: secondsToTicks(progress.value),
Trickplay: item?.Trickplay,
},
true
);
useEffect(() => {
prefetchAllTrickplayImages();
}, []);
const handleSliderStart = useCallback(() => {
setIsSliding(true);
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
pause();
isSeeking.value = true;
}, [isPlaying]);
const handleSliderComplete = useCallback(async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
seek(Math.max(0, Math.floor(value)));
if (wasPlayingRef.current === true) play();
}, []);
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
const handleSliderChange = useCallback(
debounce((value: number) => {
calculateTrickplayUrl(secondsToTicks(value));
const progressInSeconds = Math.floor(value);
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
}, 3),
[]
);
const memoizedRenderBubble = useCallback(() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
left: -62,
bottom: 0,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5,
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: tileWidth,
height: tileHeight,
alignSelf: "center",
transform: [{ scale: 1.4 }],
borderRadius: 5,
}}
className="bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
resizeMode: "cover",
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
</Text>
</View>
);
}, [trickPlayUrl, trickplayInfo, time]);
return (
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#666",
heartbeatColor: "#999",
}}
renderThumb={() => null}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => isSliding && memoizedRenderBubble()}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={range.max}
/>
);
}

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

View File

@@ -1,14 +1,20 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { Alert, TouchableOpacity, View } from "react-native";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
MediaStreamType,
PlayServicesState, PlayServicesState,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
@@ -23,29 +29,16 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text"; import { SelectedOptions } from "./ItemContent";
import type { SelectedOptions } from "./ItemContent"; import { chromecastProfile } from "@/utils/profiles/chromecast";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia";
interface Props extends React.ComponentProps<typeof TouchableOpacity> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
colors?: ThemeColors;
} }
const ANIMATION_DURATION = 500; const ANIMATION_DURATION = 500;
@@ -54,60 +47,56 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
colors, ...props
}: Props) => { }: Props) => {
const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const { t } = useTranslation(); const { t } = useTranslation();
const { showModal, hideModal } = useGlobalModal();
const [globalColorAtom] = useAtom(itemThemeColorAtom); const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
// Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom;
const router = useRouter(); const router = useRouter();
const startWidth = useSharedValue(0); const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0); const targetWidth = useSharedValue(0);
const endColor = useSharedValue(effectiveColors); const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(effectiveColors); const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const { settings, updateSettings } = useSettings(); const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string) => { (q: string, bitrateValue: number | undefined) => {
if (settings.maxAutoPlayEpisodeCount.value !== -1) { if (!bitrateValue) {
updateSettings({ autoPlayEpisodeCount: 0 }); router.push(`/player/direct-player?${q}`);
return;
} }
router.push(`/player/direct-player?${q}`); router.push(`/player/transcoding-player?${q}`);
}, },
[router, isOffline], [router]
); );
const handleNormalPlayFlow = useCallback(async () => { const onPress = useCallback(async () => {
if (!item) return; if (!item) return;
lightHapticFeedback();
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id!, itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "", audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
}); });
const queryString = queryParams.toString(); const queryString = queryParams.toString();
if (!client) { if (!client) {
goToPlayer(queryString); goToPlayer(queryString, selectedOptions.bitrate?.value);
return; return;
} }
@@ -127,155 +116,65 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
await CastContext.getPlayServicesState().then(async (state) => { await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) { if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
} else { else {
// Check if user wants H265 for Chromecast // Get a new URL with the Chromecast device profile:
const enableH265 = settings.enableH265ForChromecast; const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
// Validate required parameters before calling getStreamUrl if (!data?.url) {
if (!api) { console.warn("No URL returned from getStreamUrl", data);
console.warn("API not available for Chromecast streaming");
Alert.alert( Alert.alert(
t("player.client_error"), t("player.client_error"),
t("player.missing_parameters"), t("player.could_not_create_stream_for_chromecast")
);
return;
}
if (!user?.Id) {
console.warn(
"User not authenticated for Chromecast streaming",
);
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!item?.Id) {
console.warn("Item not available for Chromecast streaming");
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
); );
return; return;
} }
// Get a new URL with the Chromecast device profile chromecastLoadMedia({
try { client,
const data = await getStreamUrl({ item,
api, contentUrl: data.url,
item, sessionId: data.sessionId || undefined,
deviceProfile: enableH265 ? chromecasth265 : chromecast, mediaSourceId: data.mediaSource?.Id || undefined,
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0, playbackOptions: selectedOptions,
userId: user.Id, images: [
audioStreamIndex: selectedOptions.audioIndex, {
maxStreamingBitrate: selectedOptions.bitrate?.value, url: getParentBackdropImageUrl({
mediaSourceId: selectedOptions.mediaSource?.Id, api,
subtitleStreamIndex: selectedOptions.subtitleIndex, item,
}); quality: 90,
width: 2000,
console.log("URL: ", data?.url, enableH265); })!,
},
if (!data?.url) { ],
console.warn("No URL returned from getStreamUrl", data); }).then(() => {
Alert.alert( // state is already set when reopening current media, so skip it here.
t("player.client_error"), if (isOpeningCurrentlyPlayingMedia) {
t("player.could_not_create_stream_for_chromecast"),
);
return; return;
} }
router.push("/player/google-cast-player");
// Calculate start time in seconds from playback position });
const startTimeSeconds =
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
// Calculate stream duration in seconds from runtime
const streamDurationSeconds = item.RunTimeTicks
? item.RunTimeTicks / 10000000
: undefined;
client
.loadMedia({
mediaInfo: {
contentId: item.Id,
contentUrl: data?.url,
contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED,
streamDuration: streamDurationSeconds,
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: startTimeSeconds,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
}
} }
}); });
break; break;
case 1: case 1:
goToPlayer(queryString); goToPlayer(queryString, selectedOptions.bitrate?.value);
break; break;
case cancelButtonIndex: case cancelButtonIndex:
break; break;
} }
}, }
); );
}, [ }, [
item, item,
@@ -287,140 +186,16 @@ export const PlayButton: React.FC<Props> = ({
showActionSheetWithOptions, showActionSheetWithOptions,
mediaStatus, mediaStatus,
selectedOptions, selectedOptions,
goToPlayer,
isOffline,
t,
]);
const onPress = useCallback(async () => {
if (!item) return;
lightHapticFeedback();
// Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
// If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return;
}
// If online but file is downloaded, ask user which version to play
if (downloadedItem) {
if (Platform.OS === "android") {
// Show bottom sheet for Android
showModal(
<BottomSheetView>
<View className='px-4 mt-4 mb-12'>
<View className='pb-6'>
<Text className='text-2xl font-bold mb-2'>
{t("player.downloaded_file_title")}
</Text>
<Text className='opacity-70 text-base'>
{t("player.downloaded_file_message")}
</Text>
</View>
<View className='space-y-3'>
<Button
onPress={() => {
hideModal();
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}}
color='purple'
>
{Platform.OS === "android"
? "Play downloaded file"
: t("player.downloaded_file_yes")}
</Button>
<Button
onPress={() => {
hideModal();
handleNormalPlayFlow();
}}
color='white'
variant='border'
>
{Platform.OS === "android"
? "Stream file"
: t("player.downloaded_file_no")}
</Button>
</View>
</View>
</BottomSheetView>,
{
snapPoints: ["35%"],
enablePanDownToClose: true,
},
);
} else {
// Show alert for iOS
Alert.alert(
t("player.downloaded_file_title"),
t("player.downloaded_file_message"),
[
{
text: t("player.downloaded_file_yes"),
onPress: () => {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
},
isPreferred: true,
},
{
text: t("player.downloaded_file_no"),
onPress: () => {
handleNormalPlayFlow();
},
},
{
text: t("player.downloaded_file_cancel"),
style: "cancel",
},
],
);
}
return;
}
// If not downloaded, proceed with normal flow
handleNormalPlayFlow();
}, [
item,
lightHapticFeedback,
handleNormalPlayFlow,
goToPlayer,
t,
showModal,
hideModal,
effectiveColors,
]); ]);
const derivedTargetWidth = useDerivedValue(() => { const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0; if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData; const userData = item.UserData;
if (userData?.PlaybackPositionTicks) { if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0
? Math.max( ? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH, MIN_PLAYBACK_WIDTH
) )
: 0; : 0;
} }
@@ -437,11 +212,11 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0), easing: Easing.bezier(0.7, 0, 0.3, 1.0),
}); });
}, },
[item], [item]
); );
useAnimatedReaction( useAnimatedReaction(
() => effectiveColors, () => colorAtom,
(newColor) => { (newColor) => {
endColor.value = newColor; endColor.value = newColor;
colorChangeProgress.value = 0; colorChangeProgress.value = 0;
@@ -450,19 +225,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99), easing: Easing.bezier(0.9, 0, 0.31, 0.99),
}); });
}, },
[effectiveColors], [colorAtom]
); );
useEffect(() => { useEffect(() => {
const timeout_2 = setTimeout(() => { const timeout_2 = setTimeout(() => {
startColor.value = effectiveColors; startColor.value = colorAtom;
startWidth.value = targetWidth.value; startWidth.value = targetWidth.value;
}, ANIMATION_DURATION); }, ANIMATION_DURATION);
return () => { return () => {
clearTimeout(timeout_2); clearTimeout(timeout_2);
}; };
}, [effectiveColors, item]); }, [colorAtom, item]);
/** /**
* ANIMATED STYLES * ANIMATED STYLES
@@ -471,7 +246,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary], [startColor.value.primary, endColor.value.primary]
), ),
})); }));
@@ -479,7 +254,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary], [startColor.value.primary, endColor.value.primary]
), ),
})); }));
@@ -487,7 +262,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate( width: `${interpolate(
widthProgress.value, widthProgress.value,
[0, 1], [0, 1],
[startWidth.value, targetWidth.value], [startWidth.value, targetWidth.value]
)}%`, )}%`,
})); }));
@@ -495,61 +270,83 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor( color: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.text, endColor.value.text], [startColor.value.text, endColor.value.text]
), ),
})); }));
/**
* *********************
*/
return ( return (
<TouchableOpacity <View>
disabled={!item} <TouchableOpacity
accessibilityLabel='Play button' disabled={!item}
accessibilityHint='Tap to play the media' accessibilityLabel="Play button"
onPress={onPress} accessibilityHint="Tap to play the media"
className={"relative flex-1"} onPress={onPress}
> className={`relative`}
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'> {...props}
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className='absolute w-full h-full top-0 left-0 rounded-full'
/>
<View
style={{
borderWidth: 1,
borderColor: effectiveColors.primary,
borderStyle: "solid",
}}
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
> >
<View className='flex flex-row items-center space-x-2'> <View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}> <Animated.View
{runtimeTicksToMinutes( style={[
(item?.RunTimeTicks || 0) - animatedPrimaryStyle,
(item?.UserData?.PlaybackPositionTicks || 0), animatedWidthStyle,
)} {
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"} height: "100%",
</Animated.Text> },
<Animated.Text style={animatedTextStyle}> ]}
<Ionicons name='play-circle' size={24} /> />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name='cast' size={22} />
<CastButton tintColor='transparent' />
</Animated.Text>
)}
</View> </View>
</View>
</TouchableOpacity> <Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
>
<View className="flex flex-row items-center space-x-2">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>
{/* <View className="mt-2 flex flex-row items-center">
<Ionicons
name="information-circle"
size={12}
className=""
color={"#9BA1A6"}
/>
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View> */}
</View>
); );
}; };

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ interface Props extends TouchableOpacityProps {
mediaType: MediaType; mediaType: MediaType;
} }
export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
result, result,
mediaTitle, mediaTitle,
releaseYear, releaseYear,
@@ -42,26 +42,18 @@ export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({
onPress={() => { onPress={() => {
if (!result) return; if (!result) return;
// Build URL with query params - avoids Expo Router's strict type checking. router.push({
// Every value is coerced defensively: releaseYear/mediaType can be pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
// undefined or NaN at runtime, so `.toString()` would throw. // @ts-expect-error
const params = new URLSearchParams({ params: {
...Object.fromEntries( ...result,
Object.entries(result).map(([key, value]) => [ mediaTitle,
key, releaseYear,
String(value ?? ""), canRequest: canRequest.toString(),
]), posterSrc,
), mediaType,
mediaTitle: mediaTitle ?? "", },
releaseYear: String(releaseYear ?? ""),
canRequest: String(canRequest ?? false),
posterSrc: posterSrc ?? "",
mediaType: String(mediaType ?? ""),
}); });
router.push(
`/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/page?${params.toString()}`,
);
}} }}
{...props} {...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.streamyStatsSeriesRecommendations ||
settings.streamyStatsPromotedWatchlists; settings.streamyStatsPromotedWatchlists;
const streamystatsSections = const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent index === streamystatsIndex && hasStreamystatsContent ? (
? [ <>
settings.streamyStatsMovieRecommendations && ( {settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations <StreamystatsRecommendations
key='movie-recommendations' title={t(
title={t( "home.settings.plugins.streamystats.recommended_movies",
"home.settings.plugins.streamystats.recommended_movies", )}
)} type='Movie'
type='Movie' />
/> )}
), {settings.streamyStatsSeriesRecommendations && (
settings.streamyStatsSeriesRecommendations && ( <StreamystatsRecommendations
<StreamystatsRecommendations title={t(
key='series-recommendations' "home.settings.plugins.streamystats.recommended_series",
title={t( )}
"home.settings.plugins.streamystats.recommended_series", type='Series'
)} />
type='Series' )}
/> {settings.streamyStatsPromotedWatchlists && (
), <StreamystatsPromotedWatchlists />
settings.streamyStatsPromotedWatchlists && ( )}
<StreamystatsPromotedWatchlists key='promoted-watchlists' /> </>
), ) : null;
].filter(Boolean)
: null;
if (section.type === "InfiniteScrollingCollectionList") { if (section.type === "InfiniteScrollingCollectionList") {
return ( return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,11 +3,9 @@ import { useMemo } from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({ const JellyseerrMediaIcon: React.FC<
mediaType, { mediaType: "tv" | "movie" } & ViewProps
className, > = ({ mediaType, className, ...props }) => {
...props
}) => {
const style = useMemo( const style = useMemo(
() => () =>
mediaType === MediaType.MOVIE 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; onPress?: () => void;
} }
const SeerrStatusIcon: React.FC<Props & ViewProps> = ({ const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
mediaStatus, mediaStatus,
showRequestIcon, showRequestIcon,
onPress, 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='px-4'>
<View className='flex flex-row flex-wrap'> <View className='flex flex-row flex-wrap'>
{Array.from({ length: 9 }, (_, i) => ( {Array.from({ length: 9 }, (_, i) => (
<GridSkeleton key={i} /> <GridSkeleton key={i} index={i} />
))} ))}
</View> </View>
</View> </View>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,19 +3,23 @@ import { uniqBy } from "lodash";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import type { ViewProps } from "react-native"; import type { ViewProps } from "react-native";
import SeerrPoster from "@/components/posters/SeerrPoster"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { type DiscoverEndpoint, Endpoints, useSeerr } from "@/hooks/useSeerr"; import {
type DiscoverEndpoint,
Endpoints,
useJellyseerr,
} from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
slide, slide,
...props ...props
}) => { }) => {
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["seerr", "discover", slide.id], queryKey: ["jellyseerr", "discover", slide.id],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined; let endpoint: DiscoverEndpoint | undefined;
let params: any = { let params: any = {
@@ -46,13 +50,13 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
break; break;
} }
return endpoint ? seerrApi?.discover(endpoint, params) : null; return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
}, },
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage, pages) => getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1, 1,
enabled: !!seerrApi, enabled: !!jellyseerrApi,
staleTime: 0, staleTime: 0,
}); });
@@ -61,10 +65,12 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
uniqBy( uniqBy(
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))), .flatMap((p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
),
"id", "id",
), ),
[data, isSeerrMovieOrTvResult], [data],
); );
return ( return (
@@ -78,7 +84,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
onEndReached={() => { onEndReached={() => {
if (hasNextPage) fetchNextPage(); 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 { useQuery } from "@tanstack/react-query";
import type React from "react"; import type React from "react";
import type { ViewProps } from "react-native"; import type { ViewProps } from "react-native";
import SeerrPoster from "@/components/posters/SeerrPoster"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common"; import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
@@ -16,36 +16,36 @@ type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({ const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
request, request,
}) => { }) => {
const { seerrApi } = useSeerr(); const { jellyseerrApi } = useJellyseerr();
const { data: details } = useQuery({ const { data: details } = useQuery({
queryKey: [ queryKey: [
"seerr", "jellyseerr",
"detail", "detail",
request.media.mediaType, request.media.mediaType,
request.media.tmdbId, request.media.tmdbId,
], ],
queryFn: async () => { queryFn: async () => {
return request.media.mediaType === MediaType.MOVIE return request.media.mediaType === MediaType.MOVIE
? seerrApi?.movieDetails(request.media.tmdbId) ? jellyseerrApi?.movieDetails(request.media.tmdbId)
: seerrApi?.tvDetails(request.media.tmdbId); : jellyseerrApi?.tvDetails(request.media.tmdbId);
}, },
enabled: !!seerrApi, enabled: !!jellyseerrApi,
refetchOnMount: true, refetchOnMount: true,
staleTime: 0, staleTime: 0,
}); });
const { data: refreshedRequest } = useQuery({ const { data: refreshedRequest } = useQuery({
queryKey: ["seerr", "requests", request.media.mediaType, request.id], queryKey: ["jellyseerr", "requests", request.media.mediaType, request.id],
queryFn: async () => seerrApi?.getRequest(request.id), queryFn: async () => jellyseerrApi?.getRequest(request.id),
enabled: !!seerrApi, enabled: !!jellyseerrApi,
refetchOnMount: true, refetchOnMount: true,
refetchInterval: 5000, refetchInterval: 5000,
staleTime: 0, staleTime: 0,
}); });
return ( return (
<SeerrPoster <JellyseerrPoster
horizontal horizontal
showDownloadInfo showDownloadInfo
item={details} item={details}
@@ -58,12 +58,12 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
slide, slide,
...props ...props
}) => { }) => {
const { seerrApi } = useSeerr(); const { jellyseerrApi } = useJellyseerr();
const { data: requests } = useQuery({ const { data: requests } = useQuery({
queryKey: ["seerr", "recent_requests"], queryKey: ["jellyseerr", "recent_requests"],
queryFn: async () => seerrApi?.requests(), queryFn: async () => jellyseerrApi?.requests(),
enabled: !!seerrApi, enabled: !!jellyseerrApi,
refetchOnMount: true, refetchOnMount: true,
staleTime: 0, staleTime: 0,
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -197,7 +197,10 @@ export const TrackOptionsSheet: React.FC<Props> = ({
const artistId = track?.ArtistItems?.[0]?.Id; const artistId = track?.ArtistItems?.[0]?.Id;
if (artistId) { if (artistId) {
setOpen(false); setOpen(false);
router.push(`/music/artist/${artistId}`); router.push({
pathname: "/music/artist/[artistId]",
params: { artistId },
});
} }
}, [track?.ArtistItems, router, setOpen]); }, [track?.ArtistItems, router, setOpen]);
@@ -205,7 +208,10 @@ export const TrackOptionsSheet: React.FC<Props> = ({
const albumId = track?.AlbumId || track?.ParentId; const albumId = track?.AlbumId || track?.ParentId;
if (albumId) { if (albumId) {
setOpen(false); setOpen(false);
router.push(`/music/album/${albumId}`); router.push({
pathname: "/music/album/[albumId]",
params: { albumId },
});
} }
}, [track?.AlbumId, track?.ParentId, router, setOpen]); }, [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, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { TouchableSeerrRouter } from "@/components/common/SeerrItemRouter"; import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Tag, Tags } from "@/components/GenreTags"; import { Tag, Tags } from "@/components/GenreTags";
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard"; import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import SeerrMediaIcon from "@/components/seerr/SeerrMediaIcon"; import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon"; import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker"; import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
@@ -34,13 +34,13 @@ interface Props extends ViewProps {
mediaRequest?: MediaRequest; mediaRequest?: MediaRequest;
} }
const SeerrPoster: React.FC<Props> = ({ const JellyseerrPoster: React.FC<Props> = ({
item, item,
horizontal, horizontal,
showDownloadInfo, showDownloadInfo,
mediaRequest, mediaRequest,
}) => { }) => {
const { seerrApi, getTitle, getYear, getMediaType } = useSeerr(); const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
const loadingOpacity = useSharedValue(1); const loadingOpacity = useSharedValue(1);
const imageOpacity = useSharedValue(0); const imageOpacity = useSharedValue(0);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -56,13 +56,16 @@ const SeerrPoster: React.FC<Props> = ({
const backdropSrc = useMemo( const backdropSrc = useMemo(
() => () =>
seerrApi?.imageProxy(item?.backdropPath, "w1920_and_h800_multi_faces"), jellyseerrApi?.imageProxy(
[item, seerrApi, horizontal], item?.backdropPath,
"w1920_and_h800_multi_faces",
),
[item, jellyseerrApi, horizontal],
); );
const posterSrc = useMemo( const posterSrc = useMemo(
() => seerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"), () => jellyseerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
[item, seerrApi, horizontal], [item, jellyseerrApi, horizontal],
); );
const title = useMemo(() => getTitle(item), [item]); 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 size = useMemo(() => (horizontal ? "h-28" : "w-28"), [horizontal]);
const ratio = useMemo(() => (horizontal ? "15/10" : "10/15"), [horizontal]); const ratio = useMemo(() => (horizontal ? "15/10" : "10/15"), [horizontal]);
const [canRequest] = useSeerrCanRequest(item); const [canRequest] = useJellyseerrCanRequest(item);
const is4k = useMemo(() => mediaRequest?.is4k === true, [mediaRequest]); const is4k = useMemo(() => mediaRequest?.is4k === true, [mediaRequest]);
@@ -106,7 +109,7 @@ const SeerrPoster: React.FC<Props> = ({
second, second,
third, third,
fourth, 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; return seasons;
@@ -118,7 +121,7 @@ const SeerrPoster: React.FC<Props> = ({
}, [mediaRequest, is4k]); }, [mediaRequest, is4k]);
return ( return (
<TouchableSeerrRouter <TouchableJellyseerrRouter
result={item} result={item}
mediaTitle={title} mediaTitle={title}
releaseYear={releaseYear} 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' className='absolute right-1 top-1 text-right bg-black border border-neutral-800/50'
text={mediaRequest?.requestedBy.displayName} text={mediaRequest?.requestedBy.displayName}
/> />
{(requestedSeasons?.length ?? 0) > 0 && ( {requestedSeasons.length > 0 && (
<Tags <Tags
className='absolute bottom-1 left-0.5 w-32' className='absolute bottom-1 left-0.5 w-32'
tagProps={{ tagProps={{
@@ -181,12 +184,12 @@ const SeerrPoster: React.FC<Props> = ({
)} )}
</> </>
)} )}
<SeerrStatusIcon <JellyseerrStatusIcon
className='absolute bottom-1 right-1' className='absolute bottom-1 right-1'
showRequestIcon={canRequest} showRequestIcon={canRequest}
mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status} mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
/> />
<SeerrMediaIcon <JellyseerrMediaIcon
className='absolute top-1 left-1' className='absolute top-1 left-1'
mediaType={mediaType} mediaType={mediaType}
/> />
@@ -198,8 +201,8 @@ const SeerrPoster: React.FC<Props> = ({
{releaseYear || ""} {releaseYear || ""}
</Text> </Text>
</View> </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 { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { SeerrSearchSort } from "@/components/seerr/SeerrIndexPage"; import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
interface DiscoverFiltersProps { interface DiscoverFiltersProps {
searchFilterId: string; searchFilterId: string;
orderFilterId: string; orderFilterId: string;
seerrOrderBy: SeerrSearchSort; jellyseerrOrderBy: JellyseerrSearchSort;
setSeerrOrderBy: (value: SeerrSearchSort) => void; setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
seerrSortOrder: "asc" | "desc"; jellyseerrSortOrder: "asc" | "desc";
setSeerrSortOrder: (value: "asc" | "desc") => void; setJellyseerrSortOrder: (value: "asc" | "desc") => void;
t: (key: string) => string; t: (key: string) => string;
} }
const sortOptions = Object.keys(SeerrSearchSort).filter((v) => const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)), Number.isNaN(Number(v)),
); );
@@ -22,10 +22,10 @@ const orderOptions = ["asc", "desc"] as const;
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
searchFilterId, searchFilterId,
orderFilterId, orderFilterId,
seerrOrderBy, jellyseerrOrderBy,
setSeerrOrderBy, setJellyseerrOrderBy,
seerrSortOrder, jellyseerrSortOrder,
setSeerrSortOrder, setJellyseerrSortOrder,
t, t,
}) => { }) => {
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
@@ -52,16 +52,16 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
<Picker <Picker
label={t("library.filters.sort_by")} label={t("library.filters.sort_by")}
options={sortOptions.map((item) => options={sortOptions.map((item) =>
t(`home.settings.plugins.seerr.order_by.${item}`), t(`home.settings.plugins.jellyseerr.order_by.${item}`),
)} )}
variant='menu' variant='menu'
selectedIndex={sortOptions.indexOf( selectedIndex={sortOptions.indexOf(
seerrOrderBy as unknown as string, jellyseerrOrderBy as unknown as string,
)} )}
onOptionSelected={(event: any) => { onOptionSelected={(event: any) => {
const index = event.nativeEvent.index; const index = event.nativeEvent.index;
setSeerrOrderBy( setJellyseerrOrderBy(
sortOptions[index] as unknown as SeerrSearchSort, sortOptions[index] as unknown as JellyseerrSearchSort,
); );
}} }}
/> />
@@ -69,10 +69,10 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
label={t("library.filters.sort_order")} label={t("library.filters.sort_order")}
options={orderOptions.map((item) => t(`library.filters.${item}`))} options={orderOptions.map((item) => t(`library.filters.${item}`))}
variant='menu' variant='menu'
selectedIndex={orderOptions.indexOf(seerrSortOrder)} selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
onOptionSelected={(event: any) => { onOptionSelected={(event: any) => {
const index = event.nativeEvent.index; const index = event.nativeEvent.index;
setSeerrSortOrder(orderOptions[index]); setJellyseerrSortOrder(orderOptions[index]);
}} }}
/> />
</ContextMenu.Items> </ContextMenu.Items>
@@ -86,15 +86,17 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
<View className='flex flex-row justify-end items-center space-x-1'> <View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton <FilterButton
id={searchFilterId} id={searchFilterId}
queryKey='seerr_search' queryKey='jellyseerr_search'
queryFn={async () => 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])} set={(value) => setJellyseerrOrderBy(value[0])}
values={[seerrOrderBy]} values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")} title={t("library.filters.sort_by")}
renderItemLabel={(item) => renderItemLabel={(item) =>
t(`home.settings.plugins.seerr.order_by.${item}`) t(`home.settings.plugins.jellyseerr.order_by.${item}`)
} }
disableSearch={true} disableSearch={true}
/> />
@@ -102,8 +104,8 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
id={orderFilterId} id={orderFilterId}
queryKey='jellysearr_search' queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]} queryFn={async () => ["asc", "desc"]}
set={(value) => setSeerrSortOrder(value[0])} set={(value) => setJellyseerrSortOrder(value[0])}
values={[seerrSortOrder]} values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")} title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)} renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true} disableSearch={true}

View File

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

View File

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

View File

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

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 { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout"; import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector";
interface Props { interface Props {
item: BaseItemDto; item: BaseItemDto;
@@ -57,6 +58,7 @@ interface Props {
startPictureInPicture?: () => Promise<void>; startPictureInPicture?: () => Promise<void>;
play: () => void; play: () => void;
pause: () => void; pause: () => void;
aspectRatio?: AspectRatio;
isZoomedToFill?: boolean; isZoomedToFill?: boolean;
onZoomToggle?: () => void; onZoomToggle?: () => void;
api?: Api | null; api?: Api | null;
@@ -87,6 +89,7 @@ export const Controls: FC<Props> = ({
showControls, showControls,
setShowControls, setShowControls,
mediaSource, mediaSource,
aspectRatio = "default",
isZoomedToFill = false, isZoomedToFill = false,
onZoomToggle, onZoomToggle,
api = null, api = null,
@@ -495,6 +498,7 @@ export const Controls: FC<Props> = ({
goToNextItem={goToNextItem} goToNextItem={goToNextItem}
previousItem={previousItem} previousItem={previousItem}
nextItem={nextItem} nextItem={nextItem}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill} isZoomedToFill={isZoomedToFill}
onZoomToggle={onZoomToggle} onZoomToggle={onZoomToggle}
playbackSpeed={playbackSpeed} playbackSpeed={playbackSpeed}

View File

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

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

View File

@@ -1,2 +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. // Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
export const POSTER_CAROUSEL_HEIGHT = 220; export const POSTER_CAROUSEL_HEIGHT = 220;

View File

@@ -0,0 +1,64 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useAtomValue } from "jotai";
interface AdjacentEpisodesProps {
item?: BaseItemDto | null;
}
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
const api = useAtomValue(apiAtom);
const { data: adjacentItems } = useQuery({
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
queryFn: async (): Promise<BaseItemDto[] | null> => {
if (!api || !item || !item.SeriesId) {
return null;
}
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId,
adjacentTo: item.Id,
limit: 3,
fields: ["MediaSources", "MediaStreams", "ParentId"],
});
return res.data.Items || null;
},
enabled:
!!api &&
!!item?.Id &&
!!item?.SeriesId &&
(item?.Type === "Episode" || item?.Type === "Audio"),
staleTime: 0,
});
const previousItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
}
return adjacentItems[0];
}, [adjacentItems, item]);
const nextItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
}
return adjacentItems[2];
}, [adjacentItems, item]);
return { previousItem, nextItem };
};

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 };
};

120
hooks/useImageColors.ts Normal file
View File

@@ -0,0 +1,120 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { Platform } from "react-native";
import type * as ImageColorsType from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
// Conditionally import react-native-image-colors only on non-TV platforms
const ImageColors = Platform.isTV
? null
: (require("react-native-image-colors") as typeof ImageColorsType);
import {
adjustToNearBlack,
calculateTextColor,
isCloseToBlack,
itemThemeColorAtom,
} from "@/utils/atoms/primaryColor";
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
/**
* Custom hook to extract and manage image colors for a given item.
*
* @param item - The BaseItemDto object representing the item.
* @param disabled - A boolean flag to disable color extraction.
*
*/
export const useImageColors = ({
item,
url,
disabled,
}: {
item?: BaseItemDto | null;
url?: string | null;
disabled?: boolean;
}) => {
const api = useAtomValue(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
const isTv = Platform.isTV;
const source = useMemo(() => {
if (!api) return;
if (url) return { uri: url };
if (item)
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
return null;
}, [api, item, url]);
useEffect(() => {
if (isTv) return;
if (disabled) return;
if (source?.uri) {
const _primary = storage.getString(`${source.uri}-primary`);
const _text = storage.getString(`${source.uri}-text`);
if (_primary && _text) {
setPrimaryColor({
primary: _primary,
text: _text,
});
return;
}
// Extract colors from the image
if (!ImageColors?.getColors) return;
ImageColors.getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors: ImageColorsType.ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";
// Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
backup = colors.vibrant;
} else if (colors.platform === "ios") {
primary = colors.detail;
backup = colors.primary;
}
// Adjust the primary color if it's too close to black
if (primary && isCloseToBlack(primary)) {
if (backup && !isCloseToBlack(backup)) primary = backup;
primary = adjustToNearBlack(primary);
}
// Calculate the text color based on the primary color
if (primary) text = calculateTextColor(primary);
setPrimaryColor({
primary,
text,
});
// Cache the colors in storage
if (source.uri && primary) {
storage.set(`${source.uri}-primary`, primary);
storage.set(`${source.uri}-text`, text);
}
})
.catch((error: any) => {
console.error("Error getting colors", error);
});
}
}, [isTv, source?.uri, setPrimaryColor, disabled]);
if (isTv) return;
};

View File

@@ -2,7 +2,7 @@ import axios, { type AxiosError, type AxiosInstance } from "axios";
import { atom } from "jotai"; import { atom } from "jotai";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai/index";
import { inRange } from "lodash"; import { inRange } from "lodash";
import type { User as SeerrUser } from "@/utils/jellyseerr/server/entity/User"; import type { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
import type { import type {
MovieResult, MovieResult,
Results, Results,
@@ -62,41 +62,12 @@ interface SearchResults {
results: Results[]; results: Results[];
} }
const SEERR_USER = "SEERR_USER"; const JELLYSEERR_USER = "JELLYSEERR_USER";
const SEERR_COOKIES = "SEERR_COOKIES"; const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
// One-time migration of the legacy Jellyseerr storage keys to the Seerr-branded export const clearJellyseerrStorageData = () => {
// keys. Runs at module load, before seerrUserAtom reads SEERR_USER, so logged-in storage.remove(JELLYSEERR_USER);
// users keep their session through the rename instead of being silently logged out. storage.remove(JELLYSEERR_COOKIES);
const LEGACY_USER_KEY = "JELLYSEERR_USER";
const LEGACY_COOKIES_KEY = "JELLYSEERR_COOKIES";
function migrateLegacySeerrStorage() {
const legacyUser = storage.get<SeerrUser>(LEGACY_USER_KEY);
if (
legacyUser !== undefined &&
storage.get<SeerrUser>(SEERR_USER) === undefined
) {
storage.setAny(SEERR_USER, legacyUser);
}
const legacyCookies = storage.get<string[]>(LEGACY_COOKIES_KEY);
if (
legacyCookies !== undefined &&
storage.get<string[]>(SEERR_COOKIES) === undefined
) {
storage.setAny(SEERR_COOKIES, legacyCookies);
}
storage.remove(LEGACY_USER_KEY);
storage.remove(LEGACY_COOKIES_KEY);
}
migrateLegacySeerrStorage();
export const clearSeerrStorageData = () => {
storage.remove(SEERR_USER);
storage.remove(SEERR_COOKIES);
}; };
export enum Endpoints { export enum Endpoints {
@@ -140,27 +111,12 @@ export type TestResult =
isValid: false; isValid: false;
}; };
/** export class JellyseerrApi {
* Normalizes a URL by ensuring it has a protocol prefix (https:// or http://)
* @param url - The URL to normalize
* @returns The normalized URL with protocol prefix
*/
function normalizeUrl(url: string): string {
const trimmed = url.trim().replace(/\/+$/, ""); // Remove trailing slashes
if (trimmed.match(/^https?:\/\//i)) {
return trimmed;
}
// Default to https if no protocol is specified
return `https://${trimmed}`;
}
export class SeerrApi {
axios: AxiosInstance; axios: AxiosInstance;
constructor(baseUrl: string) { constructor(baseUrl: string) {
const normalizedUrl = normalizeUrl(baseUrl);
this.axios = axios.create({ this.axios = axios.create({
baseURL: normalizedUrl, baseURL: baseUrl,
withCredentials: true, withCredentials: true,
withXSRFToken: true, withXSRFToken: true,
xsrfHeaderName: "XSRF-TOKEN", xsrfHeaderName: "XSRF-TOKEN",
@@ -170,8 +126,8 @@ export class SeerrApi {
} }
async test(): Promise<TestResult> { async test(): Promise<TestResult> {
const user = storage.get<SeerrUser>(SEERR_USER); const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
const cookies = storage.get<string[]>(SEERR_COOKIES); const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (user && cookies) { if (user && cookies) {
return Promise.resolve({ return Promise.resolve({
@@ -186,13 +142,15 @@ export class SeerrApi {
const { status, headers, data } = response; const { status, headers, data } = response;
if (inRange(status, 200, 299)) { if (inRange(status, 200, 299)) {
if (data.version < "2.0.0") { if (data.version < "2.0.0") {
const error = t("seerr.toasts.seer_does_not_meet_requirements"); const error = t(
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
);
toast.error(error); toast.error(error);
throw Error(error); throw Error(error);
} }
storage.setAny( storage.setAny(
SEERR_COOKIES, JELLYSEERR_COOKIES,
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? [], headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? [],
); );
return { return {
@@ -200,9 +158,9 @@ export class SeerrApi {
requiresPass: true, requiresPass: true,
}; };
} }
toast.error(t("seerr.toasts.seerr_test_failed")); toast.error(t("jellyseerr.toasts.jellyseerr_test_failed"));
writeErrorLog( writeErrorLog(
`Seerr returned a ${status} for url:\n${response.config.url}`, `Jellyseerr returned a ${status} for url:\n${response.config.url}`,
response.data, response.data,
); );
return { return {
@@ -211,7 +169,7 @@ export class SeerrApi {
}; };
}) })
.catch((e) => { .catch((e) => {
const msg = t("seerr.toasts.failed_to_test_seerr_server_url"); const msg = t("jellyseerr.toasts.failed_to_test_jellyseerr_server_url");
toast.error(msg); toast.error(msg);
console.error(msg, e); console.error(msg, e);
return { return {
@@ -221,9 +179,9 @@ export class SeerrApi {
}); });
} }
async login(username: string, password: string): Promise<SeerrUser> { async login(username: string, password: string): Promise<JellyseerrUser> {
return this.axios return this.axios
?.post<SeerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, { ?.post<JellyseerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
username, username,
password, password,
email: username, email: username,
@@ -231,7 +189,7 @@ export class SeerrApi {
.then((response) => { .then((response) => {
const user = response?.data; const user = response?.data;
if (!user) throw Error("Login failed"); if (!user) throw Error("Login failed");
storage.setAny(SEERR_USER, user); storage.setAny(JELLYSEERR_USER, user);
return user; return user;
}); });
} }
@@ -406,7 +364,7 @@ export class SeerrApi {
const issue = response.data; const issue = response.data;
if (issue.status === IssueStatus.OPEN) { if (issue.status === IssueStatus.OPEN) {
toast.success(t("seerr.toasts.issue_submitted")); toast.success(t("jellyseerr.toasts.issue_submitted"));
} }
return issue; return issue;
}); });
@@ -434,7 +392,7 @@ export class SeerrApi {
const cookies = response.headers["set-cookie"]; const cookies = response.headers["set-cookie"];
if (cookies) { if (cookies) {
storage.setAny( storage.setAny(
SEERR_COOKIES, JELLYSEERR_COOKIES,
response.headers["set-cookie"]?.flatMap((c) => c.split("; ")), response.headers["set-cookie"]?.flatMap((c) => c.split("; ")),
); );
} }
@@ -442,11 +400,11 @@ export class SeerrApi {
}, },
(error: AxiosError) => { (error: AxiosError) => {
writeErrorLog( writeErrorLog(
`Seerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`, `Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
error.response?.data, error.response?.data,
); );
if (error.response?.status === 403) { if (error.response?.status === 403) {
clearSeerrStorageData(); clearJellyseerrStorageData();
} }
return Promise.reject(error); return Promise.reject(error);
}, },
@@ -454,7 +412,7 @@ export class SeerrApi {
this.axios.interceptors.request.use( this.axios.interceptors.request.use(
async (config) => { async (config) => {
const cookies = storage.get<string[]>(SEERR_COOKIES); const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (cookies) { if (cookies) {
const headerName = this.axios.defaults.xsrfHeaderName!; const headerName = this.axios.defaults.xsrfHeaderName!;
const xsrfToken = cookies const xsrfToken = cookies
@@ -467,77 +425,78 @@ export class SeerrApi {
return config; return config;
}, },
(error) => { (error) => {
console.error("Seerr request error", error); console.error("Jellyseerr request error", error);
return Promise.reject(error);
}, },
); );
} }
} }
const seerrUserAtom = atom(storage.get<SeerrUser>(SEERR_USER)); const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
export const useSeerr = () => { export const useJellyseerr = () => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const [seerrUser, setSeerrUser] = useAtom(seerrUserAtom); const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
const seerrApi = useMemo(() => { const jellyseerrApi = useMemo(() => {
const cookies = storage.get<string[]>(SEERR_COOKIES); const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (settings?.seerrServerUrl && cookies && seerrUser) { if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) {
return new SeerrApi(settings?.seerrServerUrl); return new JellyseerrApi(settings?.jellyseerrServerUrl);
} }
return undefined; return undefined;
}, [settings?.seerrServerUrl, seerrUser]); }, [settings?.jellyseerrServerUrl, jellyseerrUser]);
const clearAllSeerrData = useCallback(async () => { const clearAllJellyseerData = useCallback(async () => {
clearSeerrStorageData(); clearJellyseerrStorageData();
setSeerrUser(undefined); setJellyseerrUser(undefined);
updateSettings({ seerrServerUrl: undefined }); updateSettings({ jellyseerrServerUrl: undefined });
}, [setSeerrUser, updateSettings]); }, []);
const requestMedia = useCallback( const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => { (title: string, request: MediaRequestBody, onSuccess?: () => void) => {
seerrApi?.request?.(request)?.then(async (mediaRequest) => { jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ["search", "seerr"], queryKey: ["search", "jellyseerr"],
}); });
switch (mediaRequest.status) { switch (mediaRequest.status) {
case MediaRequestStatus.PENDING: case MediaRequestStatus.PENDING:
case MediaRequestStatus.APPROVED: case MediaRequestStatus.APPROVED:
toast.success(t("seerr.toasts.requested_item", { item: title })); toast.success(
t("jellyseerr.toasts.requested_item", { item: title }),
);
onSuccess?.(); onSuccess?.();
break; break;
case MediaRequestStatus.DECLINED: case MediaRequestStatus.DECLINED:
toast.error(t("seerr.toasts.you_dont_have_permission_to_request")); toast.error(
t("jellyseerr.toasts.you_dont_have_permission_to_request"),
);
break; break;
case MediaRequestStatus.FAILED: case MediaRequestStatus.FAILED:
toast.error( toast.error(
t("seerr.toasts.something_went_wrong_requesting_media"), t("jellyseerr.toasts.something_went_wrong_requesting_media"),
); );
break; break;
} }
}); });
}, },
[seerrApi, queryClient], [jellyseerrApi],
); );
const isSeerrMovieOrTvResult = useCallback( const isJellyseerrMovieOrTvResult = (
(items: any | null | undefined): items is MovieResult | TvResult => { items: any | null | undefined,
return ( ): items is MovieResult | TvResult => {
items && return (
Object.hasOwn(items, "mediaType") && items &&
(items.mediaType === MediaType.MOVIE || Object.hasOwn(items, "mediaType") &&
items.mediaType === MediaType.TV) (items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
); );
}, };
[],
);
const getTitle = ( const getTitle = (
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast, item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
) => { ) => {
return isSeerrMovieOrTvResult(item) return isJellyseerrMovieOrTvResult(item)
? item.mediaType === MediaType.MOVIE ? item.mediaType === MediaType.MOVIE
? item?.title ? item?.title
: item?.name : item?.name
@@ -550,7 +509,7 @@ export const useSeerr = () => {
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast, item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
) => { ) => {
return new Date( return new Date(
(isSeerrMovieOrTvResult(item) (isJellyseerrMovieOrTvResult(item)
? item.mediaType === MediaType.MOVIE ? item.mediaType === MediaType.MOVIE
? item?.releaseDate ? item?.releaseDate
: item?.firstAirDate : item?.firstAirDate
@@ -563,35 +522,32 @@ export const useSeerr = () => {
const getMediaType = ( const getMediaType = (
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast, item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
): MediaType => { ): MediaType => {
return isSeerrMovieOrTvResult(item) return isJellyseerrMovieOrTvResult(item)
? (item.mediaType as MediaType) ? (item.mediaType as MediaType)
: item?.mediaInfo?.mediaType; : item?.mediaInfo?.mediaType;
}; };
const seerrRegion = useMemo( const jellyseerrRegion = useMemo(
// streamingRegion and discoverRegion exists. region doesn't // streamingRegion and discoverRegion exists. region doesn't
() => seerrUser?.settings?.discoverRegion || "US", () => jellyseerrUser?.settings?.discoverRegion || "US",
[seerrUser], [jellyseerrUser],
); );
const seerrLocale = useMemo(() => { const jellyseerrLocale = useMemo(() => {
const locale = seerrUser?.settings?.locale || "en"; return jellyseerrUser?.settings?.locale || "en";
// Use regex to check if locale already contains region code (e.g., zh-CN, pt-BR) }, [jellyseerrUser]);
// If not, append the region to create a valid BCP 47 locale string
return /^[a-z]{2,3}-/i.test(locale) ? locale : `${locale}-${seerrRegion}`;
}, [seerrUser, seerrRegion]);
return { return {
seerrApi, jellyseerrApi,
seerrUser, jellyseerrUser,
setSeerrUser, setJellyseerrUser,
clearAllSeerrData, clearAllJellyseerData,
isSeerrMovieOrTvResult, isJellyseerrMovieOrTvResult,
getTitle, getTitle,
getYear, getYear,
getMediaType, getMediaType,
seerrRegion, jellyseerrRegion,
seerrLocale, jellyseerrLocale,
requestMedia, requestMedia,
}; };
}; };

View File

@@ -1,13 +1,10 @@
package expo.modules.mpvplayer package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.content.res.AssetManager
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import java.io.File
import java.io.FileOutputStream
/** /**
* MPV renderer that wraps libmpv for video playback. * MPV renderer that wraps libmpv for video playback.
@@ -29,7 +26,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
} }
interface Delegate { interface Delegate {
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) fun onPositionChanged(position: Double, duration: Double)
fun onPauseChanged(isPaused: Boolean) fun onPauseChanged(isPaused: Boolean)
fun onLoadingChanged(isLoading: Boolean) fun onLoadingChanged(isLoading: Boolean)
fun onReadyToSeek() fun onReadyToSeek()
@@ -49,7 +46,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// Cached state // Cached state
private var cachedPosition: Double = 0.0 private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0 private var cachedDuration: Double = 0.0
private var cachedCacheSeconds: Double = 0.0
private var _isPaused: Boolean = true private var _isPaused: Boolean = true
private var _isLoading: Boolean = false private var _isLoading: Boolean = false
private var _playbackSpeed: Double = 1.0 private var _playbackSpeed: Double = 1.0
@@ -105,52 +101,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.create(context) MPVLib.create(context)
MPVLib.addObserver(this) MPVLib.addObserver(this)
/**
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
*
* Technical Background:
* ====================
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
* even when subtitle tracks are properly detected and loaded.
*
* Why This Is Necessary:
* =====================
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
* mpv cannot access them directly due to sandboxing and library isolation.
*
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
* configured directory, mpv either:
* - Fails silently (subtitles don't appear)
* - Falls back to a default font that may not support the required character set
* - Crashes or produces rendering errors
*
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
*
* Reference:
* =========
* This workaround is documented in the mpv-android project:
* https://github.com/mpv-android/mpv-android/issues/96
*
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
*/
// Create mpv config directory and copy font files
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
//Log.i(TAG, "mpv config dir: $mpvDir")
if (!mpvDir.exists()) mpvDir.mkdirs()
// This needs to be named `subfont.ttf` else it won't work
arrayOf("subfont.ttf").forEach { fileName ->
val file = File(mpvDir, fileName)
if (file.exists()) return@forEach
context.assets
.open(fileName, AssetManager.ACCESS_STREAMING)
.copyTo(FileOutputStream(file))
}
MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", mpvDir.path)
// Configure mpv options before initialization (based on Findroid) // Configure mpv options before initialization (based on Findroid)
MPVLib.setOptionString("vo", "gpu") MPVLib.setOptionString("vo", "gpu")
MPVLib.setOptionString("gpu-context", "android") MPVLib.setOptionString("gpu-context", "android")
@@ -174,7 +124,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.setOptionString("hr-seek-framedrop", "yes") MPVLib.setOptionString("hr-seek-framedrop", "yes")
// Subtitle settings // Subtitle settings
MPVLib.setOptionString("sub-scale-with-window", "no") MPVLib.setOptionString("sub-scale-with-window", "yes")
MPVLib.setOptionString("sub-use-margins", "no") MPVLib.setOptionString("sub-use-margins", "no")
MPVLib.setOptionString("subs-match-os-language", "yes") MPVLib.setOptionString("subs-match-os-language", "yes")
MPVLib.setOptionString("subs-fallback", "yes") MPVLib.setOptionString("subs-fallback", "yes")
@@ -333,7 +283,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64) MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
// Video dimensions for PiP aspect ratio // Video dimensions for PiP aspect ratio
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64) MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64) MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
@@ -612,7 +561,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
when (property) { when (property) {
"duration" -> { "duration" -> {
cachedDuration = value cachedDuration = value
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) } mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
} }
"time-pos" -> { "time-pos" -> {
cachedPosition = value cachedPosition = value
@@ -621,12 +570,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000) val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000)
if (shouldUpdate) { if (shouldUpdate) {
lastProgressUpdateTime = now lastProgressUpdateTime = now
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) } mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
} }
} }
"demuxer-cache-duration" -> {
cachedCacheSeconds = value
}
} }
} }

View File

@@ -43,12 +43,6 @@ class MpvPlayerModule : Module() {
view.loadVideo(config) view.loadVideo(config)
} }
// Now Playing metadata for media controls (iOS-only, no-op on Android)
// Android handles media session differently via MediaSessionCompat
Prop("nowPlayingMetadata") { _: MpvPlayerView, _: Map<String, String>? ->
// No-op on Android - media session integration would require MediaSessionCompat
}
// Async function to play video // Async function to play video
AsyncFunction("play") { view: MpvPlayerView -> AsyncFunction("play") { view: MpvPlayerView ->
view.play() view.play()

View File

@@ -307,7 +307,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - MPVLayerRenderer.Delegate // MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) { override fun onPositionChanged(position: Double, duration: Double) {
cachedPosition = position cachedPosition = position
cachedDuration = duration cachedDuration = duration
@@ -319,8 +319,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
onProgress(mapOf( onProgress(mapOf(
"position" to position, "position" to position,
"duration" to duration, "duration" to duration,
"progress" to if (duration > 0) position / duration else 0.0, "progress" to if (duration > 0) position / duration else 0.0
"cacheSeconds" to cacheSeconds
)) ))
} }

View File

@@ -5,12 +5,11 @@ import CoreVideo
import AVFoundation import AVFoundation
protocol MPVLayerRendererDelegate: AnyObject { protocol MPVLayerRendererDelegate: AnyObject {
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double)
func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool) func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool)
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String)
} }
/// MPV player using vo_avfoundation for video output. /// MPV player using vo_avfoundation for video output.
@@ -45,7 +44,6 @@ final class MPVLayerRenderer {
// Thread-safe state for playback // Thread-safe state for playback
private var _cachedDuration: Double = 0 private var _cachedDuration: Double = 0
private var _cachedPosition: Double = 0 private var _cachedPosition: Double = 0
private var _cachedCacheSeconds: Double = 0
private var _isPaused: Bool = true private var _isPaused: Bool = true
private var _playbackSpeed: Double = 1.0 private var _playbackSpeed: Double = 1.0
private var _isLoading: Bool = false private var _isLoading: Bool = false
@@ -77,10 +75,6 @@ final class MPVLayerRenderer {
get { stateQueue.sync { _cachedPosition } } get { stateQueue.sync { _cachedPosition } }
set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } } set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } }
} }
private var cachedCacheSeconds: Double {
get { stateQueue.sync { _cachedCacheSeconds } }
set { stateQueue.async(flags: .barrier) { self._cachedCacheSeconds = newValue } }
}
private var isPaused: Bool { private var isPaused: Bool {
get { stateQueue.sync { _isPaused } } get { stateQueue.sync { _isPaused } }
set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } } set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } }
@@ -170,12 +164,7 @@ final class MPVLayerRenderer {
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU // Enable composite OSD mode - renders subtitles directly onto video frames using GPU
// This is better for PiP as subtitles are baked into the video // This is better for PiP as subtitles are baked into the video
// NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit
#if targetEnvironment(simulator)
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "no"))
#else
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes")) checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
#endif
// Hardware decoding with VideoToolbox // Hardware decoding with VideoToolbox
// On simulator, use software decoding since VideoToolbox is not available // On simulator, use software decoding since VideoToolbox is not available
@@ -351,9 +340,7 @@ final class MPVLayerRenderer {
("time-pos", MPV_FORMAT_DOUBLE), ("time-pos", MPV_FORMAT_DOUBLE),
("pause", MPV_FORMAT_FLAG), ("pause", MPV_FORMAT_FLAG),
("track-list/count", MPV_FORMAT_INT64), ("track-list/count", MPV_FORMAT_INT64),
("paused-for-cache", MPV_FORMAT_FLAG), ("paused-for-cache", MPV_FORMAT_FLAG)
("demuxer-cache-duration", MPV_FORMAT_DOUBLE),
("current-ao", MPV_FORMAT_STRING)
] ]
for (name, format) in properties { for (name, format) in properties {
mpv_observe_property(handle, 0, name, format) mpv_observe_property(handle, 0, name, format)
@@ -497,7 +484,7 @@ final class MPVLayerRenderer {
cachedDuration = value cachedDuration = value
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds) self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
} }
} }
case "time-pos": case "time-pos":
@@ -512,16 +499,10 @@ final class MPVLayerRenderer {
lastProgressUpdateTime = now lastProgressUpdateTime = now
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds) self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
} }
} }
} }
case "demuxer-cache-duration":
var value = Double(0)
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value)
if status >= 0 {
cachedCacheSeconds = value
}
case "pause": case "pause":
var flag: Int32 = 0 var flag: Int32 = 0
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag)
@@ -558,15 +539,6 @@ final class MPVLayerRenderer {
self.delegate?.renderer(self, didBecomeTracksReady: true) self.delegate?.renderer(self, didBecomeTracksReady: true)
} }
} }
case "current-ao":
// Audio output is now active - notify delegate
if let aoName = getStringProperty(handle: handle, name: name) {
print("[MPV] 🔊 Audio output selected: \(aoName)")
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.delegate?.renderer(self, didSelectAudioOutput: aoName)
}
}
default: default:
break break
} }

View File

@@ -1,188 +0,0 @@
import Foundation
import MediaPlayer
import UIKit
import AVFoundation
/// Simple manager for Now Playing info and remote commands.
/// Stores all state internally and updates Now Playing when ready.
class MPVNowPlayingManager {
static let shared = MPVNowPlayingManager()
// State
private var title: String?
private var artist: String?
private var albumTitle: String?
private var cachedArtwork: MPMediaItemArtwork?
private var duration: TimeInterval = 0
private var position: TimeInterval = 0
private var isPlaying: Bool = false
private var isCommandsSetup = false
private var artworkTask: URLSessionDataTask?
private init() {}
// MARK: - Audio Session
func activateAudioSession() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .moviePlayback)
try session.setActive(true)
print("[NowPlaying] Audio session activated")
} catch {
print("[NowPlaying] Audio session error: \(error)")
}
}
func deactivateAudioSession() {
do {
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
print("[NowPlaying] Audio session deactivated")
} catch {
print("[NowPlaying] Deactivation error: \(error)")
}
}
// MARK: - Remote Commands
func setupRemoteCommands(
playHandler: @escaping () -> Void,
pauseHandler: @escaping () -> Void,
toggleHandler: @escaping () -> Void,
seekHandler: @escaping (TimeInterval) -> Void,
skipForward: @escaping (TimeInterval) -> Void,
skipBackward: @escaping (TimeInterval) -> Void
) {
guard !isCommandsSetup else { return }
isCommandsSetup = true
DispatchQueue.main.async {
UIApplication.shared.beginReceivingRemoteControlEvents()
}
let cc = MPRemoteCommandCenter.shared()
cc.playCommand.isEnabled = true
cc.playCommand.addTarget { _ in playHandler(); return .success }
cc.pauseCommand.isEnabled = true
cc.pauseCommand.addTarget { _ in pauseHandler(); return .success }
cc.togglePlayPauseCommand.isEnabled = true
cc.togglePlayPauseCommand.addTarget { _ in toggleHandler(); return .success }
cc.skipForwardCommand.isEnabled = true
cc.skipForwardCommand.preferredIntervals = [15]
cc.skipForwardCommand.addTarget { e in
if let ev = e as? MPSkipIntervalCommandEvent { skipForward(ev.interval) }
return .success
}
cc.skipBackwardCommand.isEnabled = true
cc.skipBackwardCommand.preferredIntervals = [15]
cc.skipBackwardCommand.addTarget { e in
if let ev = e as? MPSkipIntervalCommandEvent { skipBackward(ev.interval) }
return .success
}
cc.changePlaybackPositionCommand.isEnabled = true
cc.changePlaybackPositionCommand.addTarget { e in
if let ev = e as? MPChangePlaybackPositionCommandEvent { seekHandler(ev.positionTime) }
return .success
}
print("[NowPlaying] Remote commands ready")
}
func cleanupRemoteCommands() {
guard isCommandsSetup else { return }
let cc = MPRemoteCommandCenter.shared()
cc.playCommand.removeTarget(nil)
cc.pauseCommand.removeTarget(nil)
cc.togglePlayPauseCommand.removeTarget(nil)
cc.skipForwardCommand.removeTarget(nil)
cc.skipBackwardCommand.removeTarget(nil)
cc.changePlaybackPositionCommand.removeTarget(nil)
DispatchQueue.main.async {
UIApplication.shared.endReceivingRemoteControlEvents()
}
isCommandsSetup = false
print("[NowPlaying] Remote commands cleaned up")
}
// MARK: - State Updates (call these whenever data changes)
/// Set metadata (title, artist, artwork URL)
func setMetadata(title: String?, artist: String?, albumTitle: String?, artworkUrl: String?) {
self.title = title
self.artist = artist
self.albumTitle = albumTitle
print("[NowPlaying] Metadata: \(title ?? "nil")")
// Load artwork async
artworkTask?.cancel()
if let urlString = artworkUrl, let url = URL(string: urlString) {
artworkTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
if let data = data, let image = UIImage(data: data) {
self?.cachedArtwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
print("[NowPlaying] Artwork loaded")
DispatchQueue.main.async { self?.refresh() }
}
}
artworkTask?.resume()
}
refresh()
}
/// Update playback state (position, duration, playing)
func updatePlayback(position: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
self.position = position
self.duration = duration
self.isPlaying = isPlaying
refresh()
}
/// Clear everything
func clear() {
artworkTask?.cancel()
title = nil
artist = nil
albumTitle = nil
cachedArtwork = nil
duration = 0
position = 0
isPlaying = false
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
print("[NowPlaying] Cleared")
}
// MARK: - Private
/// Refresh Now Playing info if we have enough data
private func refresh() {
guard duration > 0 else {
print("[NowPlaying] refresh skipped - duration is 0")
return
}
var info: [String: Any] = [
MPMediaItemPropertyPlaybackDuration: duration,
MPNowPlayingInfoPropertyElapsedPlaybackTime: position,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0
]
if let title { info[MPMediaItemPropertyTitle] = title }
if let artist { info[MPMediaItemPropertyArtist] = artist }
if let albumTitle { info[MPMediaItemPropertyAlbumTitle] = albumTitle }
if let cachedArtwork { info[MPMediaItemPropertyArtwork] = cachedArtwork }
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
print("[NowPlaying] ✅ Set info: title=\(title ?? "nil"), dur=\(Int(duration))s, pos=\(Int(position))s, rate=\(isPlaying ? 1.0 : 0.0)")
}
}

View File

@@ -43,21 +43,6 @@ public class MpvPlayerModule: Module {
view.loadVideo(config: config) view.loadVideo(config: config)
} }
// Now Playing metadata for iOS Control Center and Lock Screen
Prop("nowPlayingMetadata") { (view: MpvPlayerView, metadata: [String: Any]?) in
guard let metadata = metadata else { return }
// Convert Any values to String, filtering out nil/null values
var stringMetadata: [String: String] = [:]
for (key, value) in metadata {
if let stringValue = value as? String {
stringMetadata[key] = stringValue
}
}
if !stringMetadata.isEmpty {
view.setNowPlayingMetadata(stringMetadata)
}
}
// Async function to play video // Async function to play video
AsyncFunction("play") { (view: MpvPlayerView) in AsyncFunction("play") { (view: MpvPlayerView) in
view.play() view.play()

View File

@@ -1,7 +1,6 @@
import AVFoundation import AVFoundation
import CoreMedia import CoreMedia
import ExpoModulesCore import ExpoModulesCore
import MediaPlayer
import UIKit import UIKit
/// Configuration for loading a video /// Configuration for loading a video
@@ -42,6 +41,7 @@ class MpvPlayerView: ExpoView {
private var renderer: MPVLayerRenderer? private var renderer: MPVLayerRenderer?
private var videoContainer: UIView! private var videoContainer: UIView!
private var pipController: PiPController? private var pipController: PiPController?
let onLoad = EventDispatcher() let onLoad = EventDispatcher()
let onPlaybackStateChange = EventDispatcher() let onPlaybackStateChange = EventDispatcher()
let onProgress = EventDispatcher() let onProgress = EventDispatcher()
@@ -53,14 +53,11 @@ class MpvPlayerView: ExpoView {
private var cachedDuration: Double = 0 private var cachedDuration: Double = 0
private var intendedPlayState: Bool = false private var intendedPlayState: Bool = false
private var _isZoomedToFill: Bool = false private var _isZoomedToFill: Bool = false
// Reference to now playing manager
private let nowPlayingManager = MPVNowPlayingManager.shared
required init(appContext: AppContext? = nil) { required init(appContext: AppContext? = nil) {
super.init(appContext: appContext) super.init(appContext: appContext)
setupNotifications()
setupView() setupView()
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
} }
private func setupView() { private func setupView() {
@@ -112,77 +109,6 @@ class MpvPlayerView: ExpoView {
CATransaction.commit() CATransaction.commit()
} }
// MARK: - Audio Session & Notifications
private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
NotificationCenter.default.addObserver(
self, selector: #selector(handleAudioSessionInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
}
@objc func handleAudioSessionInterruption(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Interruption began - pause the video
print("[MPV] Audio session interrupted - pausing video")
self.pause()
case .ended:
// Interruption ended - check if we should resume
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
print("[MPV] Audio session interruption ended - can resume")
// Don't auto-resume - let user manually resume playback
} else {
print("[MPV] Audio session interruption ended - should not resume")
}
}
@unknown default:
break
}
}
private func setupRemoteCommands() {
nowPlayingManager.setupRemoteCommands(
playHandler: { [weak self] in self?.play() },
pauseHandler: { [weak self] in self?.pause() },
toggleHandler: { [weak self] in
guard let self else { return }
if self.intendedPlayState { self.pause() } else { self.play() }
},
seekHandler: { [weak self] time in self?.seekTo(position: time) },
skipForward: { [weak self] interval in self?.seekBy(offset: interval) },
skipBackward: { [weak self] interval in self?.seekBy(offset: -interval) }
)
}
// MARK: - Now Playing Info
func setNowPlayingMetadata(_ metadata: [String: String]) {
print("[MPV] setNowPlayingMetadata: \(metadata["title"] ?? "nil")")
nowPlayingManager.setMetadata(
title: metadata["title"],
artist: metadata["artist"],
albumTitle: metadata["albumTitle"],
artworkUrl: metadata["artworkUri"]
)
}
private func clearNowPlayingInfo() {
nowPlayingManager.cleanupRemoteCommands()
nowPlayingManager.deactivateAudioSession()
nowPlayingManager.clear()
}
func loadVideo(config: VideoLoadConfig) { func loadVideo(config: VideoLoadConfig) {
// Skip reload if same URL is already playing // Skip reload if same URL is already playing
if currentURL == config.url { if currentURL == config.url {
@@ -223,7 +149,6 @@ class MpvPlayerView: ExpoView {
func play() { func play() {
intendedPlayState = true intendedPlayState = true
setupRemoteCommands()
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
pipController?.updatePlaybackState() pipController?.updatePlaybackState()
@@ -237,17 +162,10 @@ class MpvPlayerView: ExpoView {
} }
func seekTo(position: Double) { func seekTo(position: Double) {
// Update cached position and Now Playing immediately for smooth Control Center feedback
cachedPosition = position
syncNowPlaying(isPlaying: !isPaused())
renderer?.seek(to: position) renderer?.seek(to: position)
} }
func seekBy(offset: Double) { func seekBy(offset: Double) {
// Update cached position and Now Playing immediately for smooth Control Center feedback
let newPosition = max(0, min(cachedPosition + offset, cachedDuration))
cachedPosition = newPosition
syncNowPlaying(isPlaying: !isPaused())
renderer?.seek(by: offset) renderer?.seek(by: offset)
} }
@@ -374,37 +292,27 @@ class MpvPlayerView: ExpoView {
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() renderer?.stop()
displayLayer.removeFromSuperlayer() displayLayer.removeFromSuperlayer()
clearNowPlayingInfo()
NotificationCenter.default.removeObserver(self)
} }
} }
// MARK: - MPVLayerRendererDelegate // MARK: - MPVLayerRendererDelegate
extension MpvPlayerView: MPVLayerRendererDelegate { extension MpvPlayerView: MPVLayerRendererDelegate {
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) {
// MARK: - Single location for Now Playing updates
private func syncNowPlaying(isPlaying: Bool) {
print("[MPV] syncNowPlaying: pos=\(Int(cachedPosition))s, dur=\(Int(cachedDuration))s, playing=\(isPlaying)")
nowPlayingManager.updatePlayback(position: cachedPosition, duration: cachedDuration, isPlaying: isPlaying)
}
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position cachedPosition = position
cachedDuration = duration cachedDuration = duration
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
// Update PiP current time for progress bar
if self.pipController?.isPictureInPictureActive == true { if self.pipController?.isPictureInPictureActive == true {
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration) self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
} }
self.onProgress([ self.onProgress([
"position": position, "position": position,
"duration": duration, "duration": duration,
"progress": duration > 0 ? position / duration : 0, "progress": duration > 0 ? position / duration : 0,
"cacheSeconds": cacheSeconds,
]) ])
} }
} }
@@ -412,10 +320,12 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) { func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
// Don't update intendedPlayState here - it's only set by user actions (play/pause)
// This prevents PiP UI flicker during seeking
print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)") // Sync timebase rate with actual playback state
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0) self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
self.syncNowPlaying(isPlaying: !isPaused)
self.onPlaybackStateChange([ self.onPlaybackStateChange([
"isPaused": isPaused, "isPaused": isPaused,
"isPlaying": !isPaused, "isPlaying": !isPaused,
@@ -447,13 +357,6 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
self.onTracksReady([:]) self.onTracksReady([:])
} }
} }
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
// Audio output is now active - this is the right time to activate audio session and set Now Playing
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
syncNowPlaying(isPlaying: !isPaused())
}
} }
// MARK: - PiPControllerDelegate // MARK: - PiPControllerDelegate

View File

@@ -15,8 +15,6 @@ export type OnProgressEventPayload = {
position: number; position: number;
duration: number; duration: number;
progress: number; progress: number;
/** Seconds of video buffered ahead of current position */
cacheSeconds: number;
}; };
export type OnErrorEventPayload = { export type OnErrorEventPayload = {
@@ -25,13 +23,6 @@ export type OnErrorEventPayload = {
export type OnTracksReadyEventPayload = Record<string, never>; export type OnTracksReadyEventPayload = Record<string, never>;
export type NowPlayingMetadata = {
title?: string;
artist?: string;
albumTitle?: string;
artworkUri?: string;
};
export type MpvPlayerModuleEvents = { export type MpvPlayerModuleEvents = {
onChange: (params: ChangeEventPayload) => void; onChange: (params: ChangeEventPayload) => void;
}; };
@@ -55,8 +46,6 @@ export type VideoSource = {
export type MpvPlayerViewProps = { export type MpvPlayerViewProps = {
source?: VideoSource; source?: VideoSource;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
/** Metadata for iOS Control Center and Lock Screen now playing info */
nowPlayingMetadata?: NowPlayingMetadata;
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void; onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
onPlaybackStateChange?: (event: { onPlaybackStateChange?: (event: {
nativeEvent: OnPlaybackStateChangePayload; nativeEvent: OnPlaybackStateChangePayload;

View File

@@ -1,13 +1,11 @@
import { useTranslation } from "react-i18next";
import { MpvPlayerViewProps } from "./MpvPlayer.types"; import { MpvPlayerViewProps } from "./MpvPlayer.types";
export default function MpvPlayerView(props: MpvPlayerViewProps) { export default function MpvPlayerView(props: MpvPlayerViewProps) {
const url = props.source?.url ?? ""; const url = props.source?.url ?? "";
const { t } = useTranslation();
return ( return (
<div> <div>
<iframe <iframe
title={t("player.mpv_player_title")} title='MPV Player'
style={{ flex: 1 }} style={{ flex: 1 }}
src={url} src={url}
onLoad={() => props.onLoad?.({ nativeEvent: { url } })} onLoad={() => props.onLoad?.({ nativeEvent: { url } })}

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